Compare commits

...
10 Commits
Author SHA1 Message Date
fam a0253e118f video2slides: 重构为纯客户端 app(浏览器抽帧 + IndexedDB),后端归零
deploy music / build-and-deploy (push) Canceled after 0s
deploy webgl / build-and-deploy (push) Canceled after 0s
deploy karaoke / build-and-deploy (push) Canceled after 0s
deploy articulate / build-and-deploy (push) Canceled after 0s
deploy cube / build-and-deploy (push) Canceled after 0s
deploy llm-proxy / build-and-deploy (push) Canceled after 0s
deploy notes / build-and-deploy (push) Canceled after 0s
deploy simpleasm / build-and-deploy (push) Canceled after 0s
deploy video2slides / build-and-deploy (push) Canceled after 0s
deploy werewolf / build-and-deploy (push) Canceled after 0s
deploy write / build-and-deploy (push) Canceled after 0s
- 不再上传视频:<video>+canvas 原生解码按时间戳 seek 抽帧,逐帧 256px 灰度
  MAE 算差异,缩略图(320)+大图(1280) 随抽随写 IndexedDB,带进度条+ETA
- 阈值/手动 保留弃用/缩放偏好 持久化到 IndexedDB,刷新仍在
- PDF 导出回到客户端 jsPDF,保留帧逐张 base64 嵌入、单帧处理防 OOM
- 后端删光业务逻辑(core.rs/handlers.rs),main.rs 缩成 cube_core::base 静态服务
- 不再需要 ffmpeg → Dockerfile 回归 FROM scratch;k8s 去掉 hostPath 卷、降资源
- 真浏览器(Playwright)验证:抽帧/差异/阈值/持久化/导出 全通过
2026-06-14 22:25:20 +01:00
fam ef29561b28 video2slides(ui): toolbar 加缩略图缩放 slider(CSS 变量控制列宽,记忆到 localStorage) 2026-06-14 22:07:42 +01:00
fam b36e30ecbf video2slides: 加 PDF 导出(服务端流式)+ 点击切换帧 保留/弃用
- 点击卡片切换 use/discard,手动覆盖阈值判定;手动标记角标 + 重置按钮
- 导出 PDF:后台 job,逐张把帧 JPEG 以 DCTDecode 直接嵌入、边读边写到磁盘,
  内存峰值只一张帧,防大视频 OOM;前端轮询进度条
- 下载走流式(ReaderStream),不把整份 PDF 读进内存;?c= query 触发下载
- 手撸极简 PDF writer(无新 PDF 依赖),只扫 JPEG 头取宽高分量数
2026-06-14 21:58:27 +01:00
fam 25a28ea092 video2slides: 修缩略图/大图 400 — img 标签带不了头,改用 ?c= query 传 client 2026-06-14 21:42:44 +01:00
fam bb65c4c415 cube(portal): 首页加 video2slides 入口 2026-06-14 21:38:44 +01:00
fam 4ee9b6ce78 video2slides: 新 app — 长视频抽帧 + 逐帧差异 + 拖阈值挑幻灯片
Rust + axum + cube-core,前端纯静态单页。ffmpeg 每 N 秒抽一帧(限宽
1280),image crate 逐帧灰度 MAE 算差异;前端拖阈值实时把相近帧变灰、
关键变化高亮,悬停出彩色大图,分页 + toolbar 常驻。按 X-Client-Id 分目录
隔离,存储走 hostPath。镜像非 scratch:debian-slim + ffmpeg + musl binary。
2026-06-14 21:29:47 +01:00
fam 8224766926 webgl(ui): 生成等待期加活体指示 — spinner + 计时 + 状态文案
gemma 首字延迟那十几秒里代码 tab 是空的,看着像坏了。现在 thinking/
writing 两个阶段都有 spinner + 计时(Ns) + 行数 + 提示,一眼看出在干活。
2026-06-03 22:45:15 +01:00
fam bde57065d1 webgl(ui): sidebar 加一组英文 demo 快捷例子 2026-06-03 22:29:05 +01:00
fam 4336cde189 webgl: 新 app — 说需求→gemma 流式生成可交互 WebGL 小程序
左 sidebar 聊天说需求,右主区 运行/代码 两 tab。后端 SSE 流式把
gemma 生成的自包含纯原生 WebGL HTML 一段段回吐,前端实时显示代码、
写完丢进沙箱 iframe 跑。完全无状态(迭代靠前端回传 current_code)。
给儿子体验 WebGL。
2026-06-03 22:21:33 +01:00
fam cf360f0193 music(chord): 修抓取 mode 传错 + 自动抓取 role 与展示对齐
startChordFetch 把内部 state key 'chord' 直接当 API mode 发给后端,
后端只认 letters/functional → '抓取失败'。改用 CHORD_API_MODE='functional'。

连带:自动抓取存的 role 'chord_functional' 与前端展示 + 手动上传用的
'chord' 不一致,抓成功也不显示。chord_mode_to_role 默认分支改回 'chord'。
2026-05-30 22:26:55 +01:00
31 changed files with 3738 additions and 3 deletions
+52
View File
@@ -0,0 +1,52 @@
name: deploy video2slides
# video2slides.famzheng.me — 长视频抽帧,逐帧差异比较,拖阈值挑关键画面/幻灯片。
# host shell runner(fam 用户)。镜像非 scratch:debian-slim + ffmpeg + 静态 musl binary。
on:
push:
branches: [master]
paths:
- 'apps/video2slides/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-video2slides.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: video2slides
IMAGE: registry.famzheng.me/mochi/video2slides
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: |
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
# COPY binary 这层必须 --no-cache,否则 docker layer cache 会套用历史 binary
# (cube 平台踩过的坑)。
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f "apps/$APP/k8s/all.yaml"
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
+57
View File
@@ -0,0 +1,57 @@
name: deploy webgl
# webgl.famzheng.me — 说需求现场生成可交互 WebGL 小程序。host shell runner(fam 用户)。
on:
push:
branches: [master]
paths:
- 'apps/webgl/**'
- 'crates/cube-core/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.gitea/workflows/deploy-webgl.yml'
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
APP: webgl
IMAGE: registry.famzheng.me/mochi/webgl
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: tag
run: |
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- name: Build rust (musl static)
run: |
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
- name: Build frontend
run: |
cd "apps/$APP/frontend"
npm ci
npm run build
- name: Build & push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
# FROM scratch + COPY musl binary —— 必须 --no-cache,否则 docker layer cache
# 会把 COPY binary 这层套用历史 binary(cube 平台踩过的坑)。
docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
- name: Initialize K8s resources
run: |
kubectl apply -f "apps/$APP/k8s/all.yaml"
- name: Roll out to k3s
run: |
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
Generated
+22
View File
@@ -1770,6 +1770,14 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "video2slides"
version = "0.1.0"
dependencies = [
"cube-core",
"tokio",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -1882,6 +1890,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webgl"
version = "0.1.0"
dependencies = [
"axum",
"cube-core",
"futures",
"reqwest",
"serde",
"serde_json",
"tokio",
"tokio-stream",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
+4
View File
@@ -11,6 +11,8 @@ members = [
"apps/notes",
"apps/llm-proxy",
"apps/write",
"apps/webgl",
"apps/video2slides",
]
[workspace.package]
@@ -31,6 +33,8 @@ rusqlite = { version = "0.32", features = ["bundled"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
futures = "0.3"
tokio-stream = "0.1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
uuid = { version = "1", features = ["v4"] }
[profile.release]
opt-level = "z"
+14
View File
@@ -68,5 +68,19 @@
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。",
"url": "https://notes.famzheng.me",
"status": "live"
},
{
"slug": "webgl",
"name": "webgl",
"description": "说需求,gemma 现场生成可交互的 WebGL 小程序(给小孩玩)。左边聊天说需求,右边 运行/代码 两个 tab,沙箱 iframe 跑、能看代码。无状态一次性。",
"url": "https://webgl.famzheng.me",
"status": "live"
},
{
"slug": "video2slides",
"name": "video2slides",
"description": "长视频(演讲/录屏)按固定间隔抽帧,逐帧比差异,拖动阈值把重复画面变灰、留下关键变化(≈ 幻灯片翻页点)。悬停看彩色大图,按浏览器隔离。",
"url": "https://video2slides.famzheng.me",
"status": "live"
}
]
+4 -2
View File
@@ -587,6 +587,8 @@ async function runInspire() {
}
// chord —— 单 mode(只抓 yopu 默认数字级数版)
// 'chord' 是内部 state key / tab 名;发给后端 API 的 mode 必须是 'functional'(数字级数版)
const CHORD_API_MODE = 'functional'
const chordStates = ref({ chord: 'idle' })
const chordErrors = ref({ chord: '' })
const chordPollTimers = { chord: null }
@@ -989,7 +991,7 @@ async function startChordFetch(mode) {
chordStates.value = { ...chordStates.value, [mode]: 'pending' }
chordErrors.value = { ...chordErrors.value, [mode]: '' }
try {
const r = await chordFetch(selectedId.value, mode)
const r = await chordFetch(selectedId.value, CHORD_API_MODE)
if (r.status === 'completed') {
await reloadPiece()
chordStates.value = { ...chordStates.value, [mode]: 'completed' }
@@ -1014,7 +1016,7 @@ async function pollChord(mode) {
return
}
try {
const r = await chordStatus(selectedId.value, mode)
const r = await chordStatus(selectedId.value, CHORD_API_MODE)
chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' }
chordErrors.value = { ...chordErrors.value, [mode]: r.error || '' }
if (r.status === 'completed') {
+2 -1
View File
@@ -1397,7 +1397,8 @@ struct ChordModeQuery {
fn chord_mode_to_role(mode: &str) -> &'static str {
match mode {
"letters" => "chord_letters",
_ => "chord_functional",
// 单 mode 简化后,数字级数版统一用 role 'chord',与手动上传 + 前端展示一致
_ => "chord",
}
}
+2
View File
@@ -0,0 +1,2 @@
data/
target/
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "video2slides"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "video2slides.famzheng.me — 纯客户端:浏览器抽帧+逐帧差异+拖阈值挑幻灯片,IndexedDB 持久化"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
tokio = { workspace = true }
+8
View File
@@ -0,0 +1,8 @@
# video2slides — video2slides.famzheng.me
# 纯客户端 app,后端只发静态文件 → 回归 cube 惯例:FROM scratch + 静态 musl binary。
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/video2slides /video2slides
COPY apps/video2slides/frontend /dist
EXPOSE 8080
ENV VIDEO2SLIDES_DIST_DIR=/dist
ENTRYPOINT ["/video2slides"]
+36
View File
@@ -0,0 +1,36 @@
# video2slides
把一段长视频(演讲 / 课程录屏)按固定间隔抽帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化(≈ 幻灯片翻页点)。
线上:<https://video2slides.famzheng.me>
## 纯客户端,无后端
视频**不上传**——浏览器用 `<video>` + canvas 原生解码、按时间戳 seek 抽帧,逐帧差异、缩略图、PDF 导出全在前端。结果(缩略图 + 大图 + 元数据)存 **IndexedDB**,每个浏览器各自持久化、刷新还在,换设备/清缓存即清空。
因此后端没有任何业务逻辑/存储,这个 Rust 进程只是个**静态文件服务器**(`cube_core::base`),把前端发出去而已。也因此镜像回归 cube 惯例:`FROM scratch` + 静态 musl binary,不再需要 ffmpeg / hostPath。
为什么不上传 + 不用服务端 ffmpeg:大视频(1~5GB)上传慢且易失败,服务端还得存。纯客户端零上传、无限扩展,且内存安全——抽帧逐帧即弃、只在内存留一张 256px 灰度算 diff;缩略图/大图随抽随写 IndexedDB。
## 流程
1. **选视频** —— 拖拽或选本地文件(不上传)。
2. **处理** —— 设「每隔 N 秒抽一帧」(默认 8)→ `<video>` 逐点 seek、canvas 抓帧 → 生成缩略图(320px)+大图(1280px) 写 IndexedDB,相邻帧 256px 灰度逐像素 MAE(0–100) 算差异。带进度条 + 预计剩余(ETA)。
3. **挑帧** —— toolbar 常驻:差异阈值滑块(`diff ≥ 阈值`=关键变化高亮保留,`<`=变灰)、缩略图缩放、仅显示保留、保留/总数、分页;**点击卡片手动切换 保留/弃用**(覆盖阈值,带角标 + 重置);悬停缩略图浮彩色大图。阈值/手动选择都存回 IndexedDB。
4. **导出 PDF** —— 客户端 jsPDF,把保留帧逐张(大图 JPEG 以 base64 直接嵌入、单帧处理避免 OOM)打成 PDF 下载。
## 格式支持
走浏览器原生解码,支持浏览器能播的格式(mp4/H.264、webm/VP9/AV1 等,演讲视频基本都是 mp4)。mkv/avi/冷门编码可能读不了——浏览器播不了就处理不了。
## 本地跑
```bash
VIDEO2SLIDES_DIST_DIR=apps/video2slides/frontend cargo run -p video2slides
```
打开 <http://localhost:8080>。后端纯静态,不需要 ffmpeg。
## 部署
push 到 master 改 `apps/video2slides/**` → CI build musl → `FROM scratch` 镜像 → push registry → `kubectl apply` + rollout。namespace `cube-video2slides`,`registry-creds` 已手工建好(不进 git)。
+465
View File
@@ -0,0 +1,465 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>video2slides — 长视频抽帧挑幻灯片</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --line:#2a303c;
--fg:#e8ebf0; --mut:#8b94a3; --accent:#f2b50c; --accent2:#3b82f6;
--keep:#f2b50c; --ok:#22c55e; --err:#ef4444; --thumbw:180px;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header.appbar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);flex:0 0 auto;z-index:50}
header.appbar h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.3px}
header.appbar .tag{color:var(--mut);font-size:12px}
header.appbar .spacer{flex:1}
.layout{display:flex;flex:1;min-height:0}
aside{width:240px;flex:0 0 240px;background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
aside .side-head{padding:10px 12px;border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center}
aside .vlist{overflow:auto;flex:1;padding:6px}
.vitem{padding:8px 10px;border-radius:8px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
.vitem:hover{background:var(--panel2)}
.vitem.active{background:var(--panel2);border-color:var(--accent)}
.vitem .vname{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vitem .vmeta{font-size:11px;color:var(--mut);display:flex;gap:6px;align-items:center;margin-top:2px}
.badge{font-size:10px;padding:1px 6px;border-radius:99px;background:rgba(34,197,94,.18);color:var(--ok)}
.vdel{margin-left:auto;color:var(--mut);cursor:pointer;font-size:14px;opacity:0;padding:0 4px}
.vitem:hover .vdel{opacity:1}
.vdel:hover{color:var(--err)}
main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0;position:relative}
.toolbar{position:sticky;top:0;z-index:40;background:var(--panel2);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:18px;flex-wrap:wrap;box-shadow:0 2px 8px rgba(0,0,0,.3)}
.toolbar .grp{display:flex;align-items:center;gap:8px}
.toolbar label{color:var(--mut);font-size:12px;white-space:nowrap}
.toolbar input[type=range]{accent-color:var(--accent)}
.thval{font-variant-numeric:tabular-nums;min-width:42px;text-align:right;color:var(--accent);font-weight:600}
.stat{font-size:12px;color:var(--mut)}
.stat b{color:var(--keep)}
.content{flex:1;overflow:auto;padding:16px;min-height:0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--thumbw,180px),1fr));gap:12px}
.card{position:relative;background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s;cursor:pointer}
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000;transition:filter .15s}
.card .cap{display:flex;justify-content:space-between;padding:5px 8px;font-size:11px;color:var(--mut)}
.card .cap .d{font-variant-numeric:tabular-nums}
.card.keep{border-color:var(--keep)}
.card.dim{opacity:.45}
.card.dim img{filter:grayscale(1)}
.card.dim:hover{opacity:.92}
.card.dim:hover img{filter:grayscale(0)}
.card .pin{position:absolute;top:6px;left:6px;font-size:10px;line-height:1;padding:3px 6px;border-radius:6px;font-weight:700;display:none;z-index:2;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.card.pinned .pin{display:block}
.card .pin.use{background:var(--keep);color:#1a1500}
.card .pin.discard{background:var(--err);color:#fff}
.card.pinned{box-shadow:0 0 0 2px rgba(255,255,255,.12) inset}
#preview{position:fixed;z-index:100;pointer-events:none;display:none;border:2px solid var(--accent);border-radius:10px;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.6);background:#000}
#preview img{display:block;max-width:560px;max-height:360px}
#preview .pcap{position:absolute;left:0;bottom:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));color:#fff;font-size:12px;padding:14px 10px 6px}
.pane{flex:1;display:flex;align-items:center;justify-content:center;padding:24px}
.pane .box{max-width:540px;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:28px}
.pane h2{margin:0 0 6px;font-size:18px}
.pane p{color:var(--mut);margin:0 0 18px}
.drop{border:2px dashed var(--line);border-radius:12px;padding:34px;text-align:center;color:var(--mut);cursor:pointer;transition:.15s}
.drop.over{border-color:var(--accent);background:rgba(242,181,12,.06);color:var(--fg)}
button{font:inherit;border:0;border-radius:8px;padding:8px 14px;background:var(--accent);color:#1a1500;font-weight:600;cursor:pointer}
button.ghost{background:var(--panel2);color:var(--fg);border:1px solid var(--line);font-weight:500}
button:disabled{opacity:.5;cursor:not-allowed}
button.sm{padding:5px 10px;font-size:12px}
input[type=number]{font:inherit;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;width:90px}
.progress{height:10px;background:var(--bg);border-radius:99px;overflow:hidden;border:1px solid var(--line)}
.progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .2s}
.pmsg{font-size:12px;color:var(--mut);margin-top:8px;text-align:center}
.row{display:flex;gap:10px;align-items:center}
.err{color:var(--err);font-size:12px}
.pager{display:flex;gap:6px;align-items:center}
.pager .pg{padding:4px 9px;background:var(--panel);border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:12px}
.pager .pg.cur{background:var(--accent);color:#1a1500;border-color:var(--accent)}
.switch{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
#expmask{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center}
#expmask.on{display:flex}
#expmask .box2{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:24px 30px;text-align:center}
#ebar{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .15s}
</style>
</head>
<body>
<header class="appbar">
<h1>🎞️ video2slides</h1>
<span class="tag">长视频 → 抽帧 → 挑关键画面/幻灯片 · 全在本地浏览器跑,不上传</span>
<span class="spacer"></span>
<button id="btnUpload" class="sm">+ 选择视频</button>
<input type="file" id="fileInput" accept="video/*" hidden />
</header>
<div class="layout">
<aside>
<div class="side-head"><b style="font-size:13px">我的视频</b><span class="tag" style="margin-left:auto;font-size:11px">本机</span></div>
<div class="vlist" id="vlist"></div>
</aside>
<main id="main"><!-- 动态 --></main>
</div>
<div id="preview"><img id="previewImg" alt="" /><div class="pcap" id="previewCap"></div></div>
<div id="expmask"><div class="box2">
<div class="progress" style="width:280px"><i id="ebar"></i></div>
<div class="pmsg" id="emsg">导出中…</div>
</div></div>
<script src="jspdf.umd.min.js"></script>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
const genId = ()=>(crypto.randomUUID?crypto.randomUUID():'v'+Date.now()+Math.random().toString(16).slice(2)).replace(/-/g,'').slice(0,16);
const fmtDur=s=>{s=Math.round(s||0);const m=Math.floor(s/60),x=s%60;return `${m}:${String(x).padStart(2,'0')}`;};
const fmtSize=b=>b>1e9?(b/1e9).toFixed(1)+'G':b>1e6?(b/1e6).toFixed(1)+'M':(b/1e3|0)+'K';
const fmtT=s=>{s=Math.round(s);const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;const p=n=>String(n).padStart(2,'0');return h?`${h}:${p(m)}:${p(x)}`:`${m}:${p(x)}`;};
const fmtEta=s=>{if(!isFinite(s)||s<0)return '…';s=Math.round(s);return s>=60?`${Math.floor(s/60)}分${s%60}秒`:`${s}秒`;};
// ====================== IndexedDB ======================
// videos: {id,name,size,duration,interval,frames:[{idx,t,diff,pw,ph}],threshold,overrides,createdAt}
// frames: {key:`${id}|${idx}|thumb|preview`, vid:id, blob}
let _db;
function idb(){return _db?Promise.resolve(_db):new Promise((res,rej)=>{
const r=indexedDB.open('video2slides',1);
r.onupgradeneeded=()=>{const db=r.result;
if(!db.objectStoreNames.contains('videos'))db.createObjectStore('videos',{keyPath:'id'});
if(!db.objectStoreNames.contains('frames')){const s=db.createObjectStore('frames',{keyPath:'key'});s.createIndex('vid','vid');}
};
r.onsuccess=()=>{_db=r.result;res(_db);};
r.onerror=()=>rej(r.error);
});}
const store=(name,mode)=>idb().then(db=>db.transaction(name,mode).objectStore(name));
const req2p=r=>new Promise((res,rej)=>{r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});
const idbPut=(name,val)=>store(name,'readwrite').then(s=>req2p(s.put(val)));
const idbGet=(name,key)=>store(name,'readonly').then(s=>req2p(s.get(key)));
const idbGetAll=name=>store(name,'readonly').then(s=>req2p(s.getAll()));
async function idbDeleteVideo(id){
await store('videos','readwrite').then(s=>req2p(s.delete(id)));
const s=await store('frames','readwrite');
await new Promise((res,rej)=>{
const c=s.index('vid').openKeyCursor(IDBKeyRange.only(id));
c.onsuccess=()=>{const cur=c.result;if(cur){s.delete(cur.primaryKey);cur.continue();}else res();};
c.onerror=()=>rej(c.error);
});
}
// ====================== 全局状态 ======================
let videos=[];
let cur=null, curId=null;
let threshold=10, page=0, pageSize=48, onlyKept=false, overrides={};
let thumbW=+(localStorage.getItem('v2s_thumbw')||180);
let pendingFile=null;
let gridUrls=[]; // 当前网格的 object URL,重渲染时回收
function applyThumbW(){document.documentElement.style.setProperty('--thumbw',thumbW+'px');}
applyThumbW();
// ====================== 侧栏 ======================
async function loadVideos(){
videos=(await idbGetAll('videos')).sort((a,b)=>b.createdAt-a.createdAt);
renderSidebar();
}
function renderSidebar(){
const box=$('#vlist');box.innerHTML='';
if(!videos.length){const e=el('div');e.style.cssText='color:var(--mut);font-size:12px;padding:14px;text-align:center';e.textContent='还没有视频,点右上角选择';box.appendChild(e);return;}
for(const v of videos){
const it=el('div','vitem'+(v.id===curId?' active':''));
const nm=el('div','vname');nm.textContent=v.name;nm.title=v.name;
const mt=el('div','vmeta');
const bd=el('span','badge');bd.textContent=`${v.frames.length}帧`;mt.appendChild(bd);
const info=el('span');info.textContent=`${fmtDur(v.duration)} · 每${v.interval}s`;mt.appendChild(info);
const del=el('span','vdel');del.textContent='✕';del.title='删除';
del.onclick=async(ev)=>{ev.stopPropagation();if(!confirm('删除「'+v.name+'」?(仅删本机缓存)'))return;await idbDeleteVideo(v.id);if(curId===v.id){curId=null;cur=null;}await loadVideos();render();};
it.append(nm,mt,del);
it.onclick=()=>openVideo(v.id);
box.appendChild(it);
}
}
// ====================== 打开 / 渲染 ======================
async function openVideo(id){
curId=id;page=0;
cur=videos.find(v=>v.id===id)||await idbGet('videos',id);
threshold=cur.threshold??10; overrides=cur.overrides||{};
renderSidebar();render();
}
function render(){
const m=$('#main');revokeGridUrls();m.innerHTML='';
if(!curId||!cur){m.appendChild(emptyPane());return;}
m.appendChild(buildToolbar());
const content=el('div','content');content.id='content';m.appendChild(content);
renderGrid();
}
function emptyPane(){
const p=el('div','pane'),b=el('div','box');
b.innerHTML='<h2>从演讲视频里挑幻灯片</h2><p>选一个本地视频,每隔 N 秒抽一帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化。<br><br>视频<b>不会上传</b>,全程在你浏览器里跑;结果缓存在本机(IndexedDB),换设备/清缓存即清空。</p>';
const drop=el('div','drop');drop.innerHTML='把视频拖到这里,或 <b>点击选择</b>';
wireDrop(drop);b.appendChild(drop);p.appendChild(b);return p;
}
// ====================== 选择文件 → 设置 → 抽帧 ======================
function wireDrop(drop){
drop.onclick=()=>$('#fileInput').click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('over');};
drop.ondragleave=()=>drop.classList.remove('over');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('over');if(e.dataTransfer.files[0])pickFile(e.dataTransfer.files[0]);};
}
$('#btnUpload').onclick=()=>$('#fileInput').click();
$('#fileInput').onchange=e=>{if(e.target.files[0])pickFile(e.target.files[0]);e.target.value='';};
function pickFile(file){
pendingFile=file;curId=null;cur=null;renderSidebar();
const m=$('#main');revokeGridUrls();m.innerHTML='';
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>设置抽帧参数</h2><p>${file.name} · ${fmtSize(file.size)}</p>`;
const row=el('div','row');
row.innerHTML=`<label style="color:var(--mut)">每隔</label>`;
const inp=el('input');inp.type='number';inp.min='0.2';inp.max='600';inp.step='0.5';inp.value=8;
const unit=el('span');unit.textContent='秒抽一帧';unit.style.color='var(--mut)';
const go=el('button');go.textContent='开始处理';
go.onclick=()=>extract(file,parseFloat(inp.value));
row.append(inp,unit,go);b.appendChild(row);
const hint=el('p');hint.style.cssText='margin-top:14px;font-size:12px';hint.textContent='间隔越小帧越多、越慢但越细。演讲翻页慢,8 秒通常够。长视频抽帧需要点时间,会显示进度和预计剩余。';
b.appendChild(hint);p.appendChild(b);m.appendChild(p);
}
function seekTo(video,t){return new Promise((res)=>{
let done=false;
const ok=()=>{if(done)return;done=true;video.removeEventListener('seeked',ok);res();};
video.addEventListener('seeked',ok);
try{video.currentTime=t;}catch(e){ok();return;}
setTimeout(ok,8000); // 容错:个别 seek 不触发就跳过
});}
const toBlob=(canvas,q)=>new Promise(r=>canvas.toBlob(b=>r(b),'image/jpeg',q));
function toGray(d){const a=d.data,n=a.length>>2,g=new Uint8Array(n);for(let i=0,j=0;j<n;i+=4,j++)g[j]=(a[i]*299+a[i+1]*587+a[i+2]*114)/1000|0;return g;}
function mae(a,b){const n=Math.min(a.length,b.length);if(!n)return 0;let s=0;for(let i=0;i<n;i++)s+=Math.abs(a[i]-b[i]);return Math.round(s/n/255*10000)/100;}
async function extract(file,interval){
if(!(interval>=0.2&&interval<=600)){alert('间隔需在 0.2~600 秒');return;}
// 进度屏
const m=$('#main');m.innerHTML='';
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>正在处理…</h2><p id="exname">${file.name}</p><div class="progress"><i id="pbar"></i></div><div class="pmsg" id="pmsg">读取视频…</div>`;
p.appendChild(b);m.appendChild(p);
const setP=(frac,msg)=>{const bar=$('#pbar'),t=$('#pmsg');if(bar)bar.style.width=Math.round(frac*100)+'%';if(t)t.textContent=msg;};
const video=document.createElement('video');
video.muted=true;video.playsInline=true;video.preload='auto';
const url=URL.createObjectURL(file);video.src=url;
try{
await new Promise((res,rej)=>{video.onloadedmetadata=()=>res();video.onerror=()=>rej(new Error('浏览器无法读取该视频(格式/编码可能不支持,建议用 mp4/H.264 或 webm)'));});
const dur=video.duration,W=video.videoWidth,H=video.videoHeight;
if(!isFinite(dur)||dur<=0)throw new Error('无法获取视频时长');
if(!W||!H)throw new Error('无法获取视频画面尺寸');
const pw=Math.min(1280,W),ph=Math.round(H*Math.min(1280,W)/W);
const tw=Math.min(320,W),th=Math.round(H*Math.min(320,W)/W);
const dw=256,dh=Math.max(1,Math.round(H*256/W));
const pc=el('canvas');pc.width=pw;pc.height=ph;const pctx=pc.getContext('2d');
const tc=el('canvas');tc.width=tw;tc.height=th;const tctx=tc.getContext('2d');
const dc=el('canvas');dc.width=dw;dc.height=dh;const dctx=dc.getContext('2d',{willReadFrequently:true});
const id=genId(),total=Math.max(1,Math.ceil(dur/interval)),frames=[];
let prev=null;const t0=performance.now();
for(let i=0;i<total;i++){
const t=Math.min(i*interval,Math.max(0,dur-0.04));
await seekTo(video,t);
pctx.drawImage(video,0,0,pw,ph);
tctx.drawImage(video,0,0,tw,th);
dctx.drawImage(video,0,0,dw,dh);
const g=toGray(dctx.getImageData(0,0,dw,dh));
const diff=prev?mae(prev,g):null;prev=g;
const idx=i+1;
const [pb,tb]=await Promise.all([toBlob(pc,0.82),toBlob(tc,0.72)]);
await idbPut('frames',{key:`${id}|${idx}|preview`,vid:id,blob:pb});
await idbPut('frames',{key:`${id}|${idx}|thumb`,vid:id,blob:tb});
frames.push({idx,t:Math.round(t*10)/10,diff,pw,ph});
const done=i+1,elapsed=(performance.now()-t0)/1000,eta=done?elapsed/done*(total-done):0;
setP(done/total,`抽帧 ${done}/${total} · 剩约 ${fmtEta(eta)}`);
}
const meta={id,name:file.name,size:file.size,duration:dur,interval,frames,threshold:10,overrides:{},createdAt:Date.now()};
await idbPut('videos',meta);
URL.revokeObjectURL(url);
pendingFile=null;
await loadVideos();await openVideo(id);
}catch(e){
URL.revokeObjectURL(url);
const box=$('.box');if(box){box.innerHTML=`<h2>处理失败</h2><p class="err">${e.message||e}</p>`;const again=el('button');again.textContent='重新选择';again.onclick=()=>$('#fileInput').click();box.appendChild(again);}
}
}
// ====================== 保留判定 / toolbar ======================
function autoKeep(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
function keptOf(f){return (f.idx in overrides)?overrides[f.idx]:autoKeep(f);}
function shown(){return onlyKept?cur.frames.filter(keptOf):cur.frames;}
let saveTimer=null;
function persistState(immediate){ // 把 threshold/overrides 存回 IDB
if(!cur)return;cur.threshold=threshold;cur.overrides=overrides;
clearTimeout(saveTimer);
if(immediate)idbPut('videos',cur).catch(()=>{}); // 离散动作(点选/重置)立即存
else saveTimer=setTimeout(()=>idbPut('videos',cur).catch(()=>{}),400); // 拖阈值防抖
}
function toggleFrame(f){
const next=!keptOf(f);
if(next===autoKeep(f))delete overrides[f.idx];else overrides[f.idx]=next;
updateStats();persistState(true);
if(onlyKept)renderGrid();
else{const card=document.querySelector(`.card[data-idx="${f.idx}"]`);if(card)paintCard(card,f);}
}
function paintCard(card,f){
const k=keptOf(f),pinned=(f.idx in overrides);
card.classList.toggle('keep',k);card.classList.toggle('dim',!k);card.classList.toggle('pinned',pinned);
const pin=card.querySelector('.pin');if(pin){pin.className='pin '+(k?'use':'discard');pin.textContent=k?'✓ 手动保留':'✕ 手动弃用';}
}
function buildToolbar(){
const t=el('div','toolbar');
const g1=el('div','grp');g1.innerHTML=`<label>差异阈值</label>`;
const sl=el('input');sl.type='range';sl.min='0';sl.max='100';sl.step='0.5';sl.value=threshold;sl.style.width='200px';
const val=el('span','thval');val.textContent=threshold;
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();persistState();if(onlyKept){page=0;renderGrid();}};
g1.append(sl,val);
const g2=el('label','switch');
const cb=el('input');cb.type='checkbox';cb.checked=onlyKept;
cb.onchange=()=>{onlyKept=cb.checked;page=0;renderGrid();};
g2.append(cb,document.createTextNode('仅显示保留帧'));
const gz=el('div','grp');gz.innerHTML=`<label>缩略图</label>`;
const zs=el('input');zs.type='range';zs.min='110';zs.max='420';zs.step='10';zs.value=thumbW;zs.style.width='120px';zs.title='缩放缩略图';
zs.oninput=()=>{thumbW=+zs.value;localStorage.setItem('v2s_thumbw',thumbW);applyThumbW();};
gz.append(zs);
const g3=el('div','grp');g3.innerHTML=`<span class="stat" id="stat"></span>`;
const g4=el('div','grp');g4.style.marginLeft='auto';
const exp=el('button','sm');exp.textContent='⬇ 导出 PDF';exp.onclick=exportPdf;
const rs=el('button','ghost sm');rs.textContent='重置手动';rs.title='清除所有手动 保留/弃用';
rs.onclick=()=>{overrides={};updateStats();persistState(true);onlyKept?renderGrid():applyClasses();};
const ist=el('span','stat');ist.textContent=`间隔 ${cur.interval}s`;
g4.append(exp,rs,ist);
const g5=el('div','grp pager');g5.id='pager';
t.append(g1,g2,gz,g3,g4,g5);
setTimeout(()=>{updateStats();renderPager();},0);
return t;
}
function updateStats(){
const total=cur.frames.length,kept=cur.frames.filter(keptOf).length,man=Object.keys(overrides).length;
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total} 帧`+(man?` <span style="opacity:.7">· 手动 ${man}</span>`:'');
}
// ====================== 网格 ======================
function revokeGridUrls(){gridUrls.forEach(u=>URL.revokeObjectURL(u));gridUrls=[];}
function renderGrid(){
const c=$('#content');if(!c)return;revokeGridUrls();c.innerHTML='';
const list=shown(),pages=Math.max(1,Math.ceil(list.length/pageSize));
if(page>=pages)page=pages-1;
const slice=list.slice(page*pageSize,(page+1)*pageSize);
const grid=el('div','grid');
for(const f of slice){
const card=el('div','card');card.dataset.idx=f.idx;card.title='点击切换 保留/弃用';
const pin=el('span','pin');
const img=el('img');img.alt='';img.loading='lazy';
idbGet('frames',`${curId}|${f.idx}|thumb`).then(rec=>{if(rec&&rec.blob){const u=URL.createObjectURL(rec.blob);gridUrls.push(u);img.src=u;}}).catch(()=>{});
const cap=el('div','cap');
cap.innerHTML=`<span>${fmtT(f.t)}</span><span class="d">${f.diff===null||f.diff===undefined?'—':'Δ'+f.diff}</span>`;
card.append(pin,img,cap);
paintCard(card,f);
card.onclick=()=>toggleFrame(f);
bindHover(card,f);
grid.appendChild(card);
}
c.appendChild(grid);
if(!slice.length){const e=el('div');e.style.cssText='color:var(--mut);text-align:center;padding:40px';e.textContent='没有符合的帧';c.appendChild(e);}
renderPager();
}
function renderPager(){
const box=$('#pager');if(!box)return;box.innerHTML='';
const list=shown(),pages=Math.max(1,Math.ceil(list.length/pageSize));
if(pages<=1)return;
const mk=(label,p,cls='')=>{const b=el('span','pg '+cls);b.textContent=label;b.onclick=()=>{page=p;renderGrid();$('#content').scrollTop=0;};return b;};
box.appendChild(mk('‹',Math.max(0,page-1)));
const win=[];for(let i=0;i<pages;i++){if(i<2||i>=pages-2||Math.abs(i-page)<=1)win.push(i);}
let last=-1;
for(const i of win){if(i-last>1){const d=el('span');d.textContent='…';d.style.color='var(--mut)';box.appendChild(d);}box.appendChild(mk(i+1,i,i===page?'cur':''));last=i;}
box.appendChild(mk('›',Math.min(pages-1,page+1)));
}
function applyClasses(){document.querySelectorAll('.card').forEach(card=>{const f=cur.frames.find(x=>x.idx===+card.dataset.idx);if(f)paintCard(card,f);});}
// ====================== 悬停大图 ======================
const pv=$('#preview'),pvImg=$('#previewImg'),pvCap=$('#previewCap');
let hoverIdx=null,hoverUrl=null;
function clearHoverUrl(){if(hoverUrl){URL.revokeObjectURL(hoverUrl);hoverUrl=null;}}
function bindHover(card,f){
card.onmouseenter=async()=>{
hoverIdx=f.idx;
pvCap.textContent=`${fmtT(f.t)} · Δ${f.diff===null||f.diff===undefined?'起始帧':f.diff}`;
const rec=await idbGet('frames',`${curId}|${f.idx}|preview`).catch(()=>null);
if(!rec||hoverIdx!==f.idx)return;
clearHoverUrl();hoverUrl=URL.createObjectURL(rec.blob);
pvImg.onload=()=>{if(hoverIdx!==f.idx)return;positionPreview(card);pv.style.display='block';};
pvImg.src=hoverUrl;
};
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';clearHoverUrl();};
}
function positionPreview(card){
const r=card.getBoundingClientRect(),pw=pv.offsetWidth||560,ph=pv.offsetHeight||360;
let x=r.right+12;if(x+pw>innerWidth)x=r.left-pw-12;if(x<8)x=Math.max(8,(innerWidth-pw)/2);
let y=r.top+r.height/2-ph/2;y=Math.min(Math.max(8,y),innerHeight-ph-8);
pv.style.left=x+'px';pv.style.top=y+'px';
}
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
// ====================== PDF 导出(客户端 jsPDF)======================
let exporting=false;
const blobToDataURL=b=>new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=()=>rej(r.error);r.readAsDataURL(b);});
async function exportPdf(){
if(exporting)return;
const list=cur.frames.filter(keptOf);
if(!list.length){alert('没有要导出的帧 —— 调低阈值或手动保留几帧再试');return;}
exporting=true;
const mask=$('#expmask'),bar=$('#ebar'),msg=$('#emsg');
bar.style.width='0%';msg.textContent=`生成 PDF(${list.length} 帧)…`;mask.classList.add('on');
try{
const { jsPDF }=window.jspdf;let doc=null;
for(let i=0;i<list.length;i++){
const f=list[i];
const rec=await idbGet('frames',`${curId}|${f.idx}|preview`);
if(!rec||!rec.blob)continue;
const durl=await blobToDataURL(rec.blob); // base64,不解码像素
const w=f.pw||1280,h=f.ph||720,o=w>=h?'l':'p';
if(!doc)doc=new jsPDF({orientation:o,unit:'px',format:[w,h],compress:true});
else doc.addPage([w,h],o);
doc.addImage(durl,'JPEG',0,0,w,h);
bar.style.width=Math.round((i+1)/list.length*100)+'%';msg.textContent=`生成 PDF ${i+1}/${list.length}`;
await new Promise(r=>setTimeout(r)); // 让出事件循环,避免卡 UI
}
if(!doc)throw new Error('没有可用的帧');
doc.save((cur.name||'slides').replace(/\.[^.]+$/,'')+'.pdf');
msg.textContent='完成,开始下载';await new Promise(r=>setTimeout(r,400));
}catch(e){alert(e.message||'导出失败');}
finally{exporting=false;mask.classList.remove('on');}
}
// ====================== 启动 ======================
(async()=>{
try{await loadVideos();}catch(e){console.error(e);}
render();
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-video2slides
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: video2slides
namespace: cube-video2slides
labels:
app: video2slides
spec:
replicas: 1
selector:
matchLabels:
app: video2slides
template:
metadata:
labels:
app: video2slides
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: video2slides
image: registry.famzheng.me/mochi/video2slides:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
# 纯静态文件服务,几乎不吃资源
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: video2slides
namespace: cube-video2slides
spec:
selector:
app: video2slides
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: video2slides
namespace: cube-video2slides
spec:
ingressClassName: traefik
rules:
- host: video2slides.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: video2slides
port:
number: 80
+14
View File
@@ -0,0 +1,14 @@
//! video2slides.famzheng.me — 长视频抽帧、逐帧差异、拖阈值挑关键画面/幻灯片。
//!
//! **纯客户端 app**:视频不上传,浏览器用 `<video>` + canvas 原生解码抽帧,
//! 逐帧差异、缩略图、PDF 导出全在前端,结果存 IndexedDB(每浏览器各自持久化)。
//!
//! 所以这里没有任何业务后端 —— 这个进程只是个静态文件服务器,把前端发出去。
//! `cube_core::base` 已经包含 healthz + 静态目录 + SPA fallback。
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("VIDEO2SLIDES_DIST_DIR").unwrap_or_else(|_| "/dist".into());
cube_core::serve(cube_core::base(dist), 8080).await
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "webgl"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "webgl.famzheng.me — 说需求,gemma 现场生成可交互 WebGL 小程序(给儿子玩)"
[dependencies]
cube-core = { path = "../../crates/cube-core" }
tokio = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { workspace = true }
futures = { workspace = true }
tokio-stream = { workspace = true }
+6
View File
@@ -0,0 +1,6 @@
# webgl — webgl.famzheng.me
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/webgl /webgl
COPY apps/webgl/frontend/dist /dist
EXPOSE 8080
ENTRYPOINT ["/webgl"]
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGL Studio — 说一句,现场生成</title>
<meta name="description" content="说需求,AI 现场生成可交互的 WebGL 小程序 · webgl.famzheng.me" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "webgl-studio",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0b0d14"/>
<g fill="none" stroke-width="3.4" stroke-linejoin="round" stroke-linecap="round">
<path d="M32 8 L54 20 L54 44 L32 56 L10 44 L10 20 Z" stroke="#7c3aed"/>
<path d="M32 8 L32 32 M32 32 L54 20 M32 32 L10 20" stroke="#06b6d4"/>
<path d="M32 32 L32 56" stroke="#34d399"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 412 B

+533
View File
@@ -0,0 +1,533 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { extractHtml } from './lib/extract'
interface Msg {
role: 'user' | 'assistant'
content: string
}
const EXAMPLES = [
'一个会转的彩色立方体,鼠标拖动可以转',
'满屏的星空粒子,慢慢往前飞',
'能用鼠标在画布上画发光线条',
'一片随风起伏的彩虹色波浪',
'点一下屏幕就放一个会弹跳的小球',
'一个跟着鼠标转的霓虹甜甜圈',
]
const EXAMPLES_EN = [
'A colorful 3D cube you can drag to spin',
'A field of stars flying past, like hyperspace',
'Draw glowing neon lines with the mouse',
'Bouncing balls that spawn where you tap',
'A rainbow torus that follows the cursor',
'Ripples spreading out wherever you click',
]
const messages = ref<Msg[]>([])
const input = ref('')
const busy = ref(false)
const error = ref<string | null>(null)
// 生成计时器(让等待期一眼看出在工作,尤其 gemma 首字延迟那十几秒)
const genSeconds = ref(0)
let genTimer: number | undefined
function startTimer() {
genSeconds.value = 0
const t0 = Date.now()
genTimer = window.setInterval(() => {
genSeconds.value = Math.floor((Date.now() - t0) / 1000)
}, 250)
}
function stopTimer() {
if (genTimer !== undefined) {
clearInterval(genTimer)
genTimer = undefined
}
}
// 流式累积的原始文本(代码 tab 实时显示),以及结束后提取的可运行 HTML
const code = ref('')
const runningHtml = ref('')
const tab = ref<'player' | 'code'>('player')
const iframeKey = ref(0)
const threadEl = ref<HTMLElement | null>(null)
const codeEl = ref<HTMLElement | null>(null)
const iframeEl = ref<HTMLIFrameElement | null>(null)
const canSend = computed(() => !busy.value && input.value.trim().length > 0)
const hasProgram = computed(() => runningHtml.value.length > 0)
const lineCount = computed(() => (code.value ? code.value.split('\n').length : 0))
// 还没吐出第一个字符(gemma 首字延迟 / thinking 阶段)—— 最容易被误以为坏了
const thinking = computed(() => busy.value && code.value.length === 0)
const genStatus = computed(() =>
thinking.value ? `正在思考…(${genSeconds.value}s)` : `正在写代码…(${genSeconds.value}s · ${lineCount.value} 行)`,
)
watch([messages, busy], async () => {
await nextTick()
threadEl.value?.scrollTo({ top: threadEl.value.scrollHeight, behavior: 'smooth' })
}, { deep: true })
// 流式时让代码视图自动滚到底,像在“看它写代码”
watch(code, async () => {
if (tab.value !== 'code') return
await nextTick()
codeEl.value?.scrollTo({ top: codeEl.value.scrollHeight })
})
function useExample(ex: string) {
if (busy.value) return
input.value = ex
send()
}
async function send() {
if (!canSend.value) return
const prompt = input.value.trim()
input.value = ''
error.value = null
messages.value.push({ role: 'user', content: prompt })
busy.value = true
startTimer()
code.value = ''
tab.value = 'code' // 切到代码 tab 看它一行行生成
const payload: { prompt: string; current_code?: string } = { prompt }
if (runningHtml.value) payload.current_code = runningHtml.value
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok || !res.body) {
throw new Error((await res.text().catch(() => '')) || `HTTP ${res.status}`)
}
await consumeSse(res.body)
} catch (e) {
const msg = (e as Error).message
error.value = msg
messages.value.push({ role: 'assistant', content: `出错了:${msg}` })
} finally {
busy.value = false
stopTimer()
}
}
/** 读 fetch 流,解析 SSE 帧(event/data),驱动 code / runningHtml。 */
async function consumeSse(body: ReadableStream<Uint8Array>) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let sep: number
while ((sep = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, sep)
buf = buf.slice(sep + 2)
handleFrame(frame)
}
}
}
function handleFrame(frame: string) {
let event = 'message'
const dataLines: string[] = []
for (const raw of frame.split('\n')) {
const line = raw.replace(/\r$/, '')
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''))
}
const data = dataLines.join('\n')
if (event === 'error') {
let msg = data
try { msg = JSON.parse(data).message ?? data } catch { /* keep raw */ }
error.value = msg
messages.value.push({ role: 'assistant', content: `出错了:${msg}` })
return
}
if (event === 'done') {
finishGeneration()
return
}
// 普通文本片段
try {
const t = JSON.parse(data).t
if (typeof t === 'string') code.value += t
} catch { /* 忽略坏帧 */ }
}
function finishGeneration() {
const html = extractHtml(code.value)
if (!html) {
error.value = '生成的内容里没找到可运行的 HTML,再换个说法试试?'
messages.value.push({ role: 'assistant', content: error.value })
return
}
runningHtml.value = html
iframeKey.value++
tab.value = 'player'
messages.value.push({ role: 'assistant', content: '✓ 生成好了,已经在运行 — 想改就接着说。' })
}
function rerun() {
iframeKey.value++
}
function goFullscreen() {
iframeEl.value?.requestFullscreen?.()
}
async function copyCode() {
try {
await navigator.clipboard.writeText(code.value)
} catch { /* 不支持就算了 */ }
}
function downloadHtml() {
const blob = new Blob([runningHtml.value || code.value], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'webgl-program.html'
a.click()
URL.revokeObjectURL(url)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
</script>
<template>
<div class="layout">
<!-- 左侧:聊天 说需求 -->
<aside class="sidebar">
<header class="brand">
<span class="logo" />
<div>
<strong>WebGL Studio</strong>
<small>说一句,AI 现场生成</small>
</div>
</header>
<div class="thread" ref="threadEl">
<div v-if="messages.length === 0" class="welcome">
<p>想做个什么小程序?用大白话说出来就行 👇</p>
<div class="chips">
<button v-for="ex in EXAMPLES" :key="ex" class="chip" :disabled="busy" @click="useExample(ex)">
{{ ex }}
</button>
</div>
<p class="grouplabel">English demos</p>
<div class="chips">
<button v-for="ex in EXAMPLES_EN" :key="ex" class="chip en" :disabled="busy" @click="useExample(ex)">
{{ ex }}
</button>
</div>
</div>
<div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
{{ m.content }}
</div>
<div v-if="busy" class="bubble assistant typing">
<span /><span /><span /> {{ genStatus }}
</div>
</div>
<footer class="composer">
<textarea
v-model="input"
:disabled="busy"
@keydown="onKeydown"
rows="2"
:placeholder="hasProgram ? '接着说想怎么改…(Enter 发送)' : '描述你想要的程序…(Enter 发送)'"
/>
<button class="send" :disabled="!canSend" @click="send">{{ hasProgram ? '改' : '生成' }}</button>
</footer>
</aside>
<!-- 右侧:主区 tabs -->
<main class="main">
<nav class="tabs">
<button :class="{ active: tab === 'player' }" @click="tab = 'player'">▶ 运行</button>
<button :class="{ active: tab === 'code' }" @click="tab = 'code'">
&lt;/&gt; 代码<span v-if="lineCount" class="count">{{ lineCount }} 行</span>
</button>
<div class="spacer" />
<template v-if="tab === 'player' && hasProgram">
<button class="ghost" @click="rerun" title="重新运行">↻ 重跑</button>
<button class="ghost" @click="goFullscreen" title="全屏">⛶ 全屏</button>
</template>
<template v-else-if="tab === 'code' && code">
<button class="ghost" @click="copyCode">⧉ 复制</button>
<button class="ghost" @click="downloadHtml">↓ 下载</button>
</template>
</nav>
<section class="stage">
<!-- Player -->
<div v-show="tab === 'player'" class="player">
<iframe
v-if="hasProgram"
:key="iframeKey"
ref="iframeEl"
:srcdoc="runningHtml"
sandbox="allow-scripts allow-pointer-lock"
allow="fullscreen; accelerometer; gyroscope; xr-spatial-tracking"
title="生成的 WebGL 程序"
/>
<div v-else class="placeholder">
<template v-if="busy">
<div class="spinner" />
<p class="status">{{ genStatus }}</p>
<p class="sub">写完会自动在这里跑起来 · 切到 <b>&lt;/&gt; 代码</b> 看 AI 实时写 →</p>
</template>
<template v-else>
<div class="big">▶</div>
<p>左边说个需求,生成好的程序会在这里运行。</p>
</template>
</div>
</div>
<!-- Code -->
<div v-show="tab === 'code'" class="codewrap" ref="codeEl">
<pre v-if="code" class="code"><code>{{ code }}</code><span v-if="busy" class="caret" /></pre>
<div v-else class="placeholder small">
<template v-if="busy">
<div class="spinner" />
<p class="status">{{ genStatus }}</p>
</template>
<p v-else>这里会实时显示 AI 写的代码。</p>
</div>
</div>
</section>
<div v-if="error" class="errorbar">{{ error }}</div>
</main>
</div>
</template>
<style scoped>
.layout {
display: grid;
grid-template-columns: 360px 1fr;
height: 100vh;
height: 100dvh;
}
/* ---- sidebar ---- */
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-soft);
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.logo {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 14px rgba(124, 58, 237, 0.5);
}
.brand strong { display: block; font-size: 1rem; }
.brand small { color: var(--fg-dim); font-size: 0.78rem; }
.thread {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.welcome { color: var(--fg-dim); font-size: 0.9rem; }
.welcome p { margin-bottom: 12px; }
.grouplabel {
margin-top: 16px;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
opacity: 0.75;
}
.chips { display: flex; flex-direction: column; gap: 8px; }
.chip.en { font-style: italic; }
.chip {
text-align: left;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
color: var(--fg);
border-radius: 10px;
padding: 9px 12px;
font-size: 0.86rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.chip:hover:not(:disabled) { background: rgba(124, 58, 237, 0.16); border-color: var(--accent); transform: translateX(2px); }
.chip:disabled { opacity: 0.5; cursor: default; }
.bubble {
max-width: 92%;
padding: 9px 12px;
border-radius: 12px;
line-height: 1.45;
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
}
.bubble.user { align-self: flex-end; background: linear-gradient(135deg, var(--accent), #4f46e5); color: #fff; }
.bubble.assistant { align-self: flex-start; background: rgba(255, 255, 255, 0.07); border: 1px solid var(--border); }
.bubble.typing { display: inline-flex; align-items: center; gap: 5px; color: var(--fg-dim); }
.bubble.typing span {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent-2); animation: bounce 1.2s infinite;
}
.bubble.typing span:nth-child(2) { animation-delay: 0.15s; }
.bubble.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce { 0%,60%,100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-5px); opacity: 1; } }
.composer {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--border);
align-items: flex-end;
}
.composer textarea {
flex: 1;
resize: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
border-radius: 10px;
padding: 9px 11px;
color: inherit;
font: inherit;
font-size: 0.9rem;
line-height: 1.4;
}
.composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.send {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; border: none; border-radius: 10px;
padding: 11px 18px; font-weight: 700; cursor: pointer; white-space: nowrap;
}
.send:disabled { background: rgba(255, 255, 255, 0.1); color: var(--fg-dim); cursor: not-allowed; }
/* ---- main ---- */
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.tabs > button {
background: transparent;
border: 1px solid transparent;
color: var(--fg-dim);
padding: 7px 14px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.tabs > button.active { background: rgba(255, 255, 255, 0.07); color: var(--fg); border-color: var(--border); }
.tabs .count {
margin-left: 6px;
font-size: 0.72rem;
color: var(--fg-dim);
font-weight: 500;
}
.spacer { flex: 1; }
.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--fg-dim);
padding: 6px 11px;
border-radius: 8px;
font-size: 0.82rem;
cursor: pointer;
}
.ghost:hover { color: var(--fg); border-color: var(--accent); }
.stage { flex: 1; min-height: 0; position: relative; }
.player, .codewrap { position: absolute; inset: 0; }
.player iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
.codewrap { overflow: auto; background: #0a0c12; }
.code {
margin: 0;
padding: 16px 18px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.55;
color: #c8d3e8;
white-space: pre-wrap;
word-break: break-word;
tab-size: 2;
}
.caret {
display: inline-block;
width: 8px; height: 1.05em;
vertical-align: text-bottom;
background: var(--accent-2);
animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; color: var(--fg-dim); text-align: center; padding: 24px;
}
.placeholder .big { font-size: 3.2rem; opacity: 0.35; }
.placeholder.small { position: static; height: 100%; }
.placeholder .status { color: var(--fg); font-size: 1rem; font-weight: 600; }
.placeholder .sub { font-size: 0.82rem; opacity: 0.8; }
.placeholder .sub b { color: var(--fg); }
.spinner {
width: 38px;
height: 38px;
border-radius: 50%;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-right-color: var(--accent-2);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.errorbar {
padding: 9px 14px;
background: rgba(251, 113, 133, 0.14);
border-top: 1px solid var(--rose);
color: var(--rose);
font-size: 0.85rem;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.sidebar { order: 2; border-right: none; border-top: 1px solid var(--border); max-height: 46vh; }
.main { order: 1; }
}
</style>
+36
View File
@@ -0,0 +1,36 @@
/**
* 从 LLM 生成的原始文本里抠出可运行的 HTML 文档。
*
* gemma 大多数时候会乖乖只吐 HTML,但偶尔会:
* - 用 ```html ... ``` 围栏包起来
* - 在前面/后面加一两句寒暄
* 这里尽量稳地把中间那坨 HTML 提出来。提不出来就返回空串(让调用方报错)。
*/
export function extractHtml(raw: string): string {
if (!raw) return ''
let s = raw.trim()
// 1. 优先取 markdown 代码围栏里的内容
const fence = s.match(/```(?:html|HTML)?\s*\n([\s\S]*?)```/)
if (fence && fence[1].trim()) {
s = fence[1].trim()
} else if (s.startsWith('```')) {
// 围栏没闭合(流式被截断 / 只有开头)—— 去掉开头那行围栏
s = s.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/```\s*$/, '').trim()
}
// 2. 从第一个 <!doctype 或 <html 开始截(丢掉前面的寒暄)
const startMatch = s.match(/<!doctype html>|<html[\s>]/i)
if (startMatch && startMatch.index !== undefined && startMatch.index > 0) {
s = s.slice(startMatch.index)
}
// 3. 截到最后一个 </html> 结束(丢掉后面的寒暄)
const endIdx = s.toLowerCase().lastIndexOf('</html>')
if (endIdx !== -1) {
s = s.slice(0, endIdx + '</html>'.length)
}
// 兜底:至少得像个 HTML(有标签),否则当作没提到
return /<[a-z!][\s\S]*>/i.test(s) ? s.trim() : ''
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+31
View File
@@ -0,0 +1,31 @@
:root {
--bg: #0b0d14;
--bg-soft: #11141d;
--fg: #e6e8ee;
--fg-dim: #8b93a7;
--accent: #7c3aed;
--accent-2: #06b6d4;
--border: #1f2433;
--green: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, 'Helvetica Neue', sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #app {
height: 100%;
}
body {
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite-env.d.ts"]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
target: 'es2020',
},
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})
+92
View File
@@ -0,0 +1,92 @@
apiVersion: v1
kind: Namespace
metadata:
name: cube-webgl
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: webgl
namespace: cube-webgl
labels:
app: webgl
spec:
replicas: 1
selector:
matchLabels:
app: webgl
template:
metadata:
labels:
app: webgl
spec:
imagePullSecrets:
- name: registry-creds
containers:
- name: webgl
image: registry.famzheng.me/mochi/webgl:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
# secret `llm-credentials`(含 LLM_API_TOKEN)由 kubectl 手工创建,
# 不进 git。值跟 cube portal 的 chat-credentials 同一个 gemma token。
envFrom:
- secretRef:
name: llm-credentials
env:
- name: LLM_GATEWAY
value: "http://3.135.65.204:8848/v1"
- name: LLM_MODEL
value: "gemma-4-31b-it"
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 200m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: webgl
namespace: cube-webgl
spec:
selector:
app: webgl
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webgl
namespace: cube-webgl
spec:
ingressClassName: traefik
rules:
- host: webgl.famzheng.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: webgl
port:
number: 80
+242
View File
@@ -0,0 +1,242 @@
//! `/api/generate` — 浏览器需求 → LLM gateway → SSE 流式回吐生成的 HTML。
//!
//! 上游是 OpenAI 兼容的 gateway(gemma-4-31b-it)。我们开 `stream: true`,逐块解析
//! 上游的 `data: {...}` SSE 帧,把 delta.content 里的文本片段重新封装成我们自己的
//! 干净 SSE 事件发给前端:
//! - `data: {"t": "<片段>"}` —— 一段生成文本
//! - `event: done` `data: {}` —— 正常结束
//! - `event: error` `data: {"message": "..."}` —— 出错
//!
//! 前端把所有 `t` 拼起来就是完整 HTML,结束后剥掉可能的 ```html fence 丢进 iframe。
use std::convert::Infallible;
use std::sync::Arc;
use axum::{
extract::State,
response::sse::{Event, KeepAlive, Sse},
Json,
};
use futures::StreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio_stream::wrappers::ReceiverStream;
#[derive(Clone)]
pub struct Config {
pub gateway: String, // http://3.135.65.204:8848/v1
pub llm_token: String, // Bearer for LLM gateway
pub llm_model: String, // gemma-4-31b-it
pub max_tokens: u32,
}
impl Config {
pub fn from_env() -> Self {
Self {
gateway: env_or("LLM_GATEWAY", "http://3.135.65.204:8848/v1"),
llm_token: env_or("LLM_API_TOKEN", ""),
llm_model: env_or("LLM_MODEL", "gemma-4-31b-it"),
max_tokens: env_or("LLM_MAX_TOKENS", "8192")
.parse()
.unwrap_or(8192),
}
}
}
fn env_or(key: &str, fallback: &str) -> String {
std::env::var(key).unwrap_or_else(|_| fallback.to_string())
}
#[derive(Deserialize)]
pub struct GenerateRequest {
/// 用户这次说的需求 / 修改要求。
pub prompt: String,
/// 可选:当前程序的完整代码。带上它 = 在现有程序上迭代修改,否则 = 从零生成。
#[serde(default)]
pub current_code: Option<String>,
}
/// 系统提示词 —— 逼 gemma 吐一个自包含、纯原生 WebGL、能在沙箱 iframe 离线跑的 HTML。
pub fn system_prompt() -> String {
"你是一个 WebGL 创意编程助手,专门为一个小朋友生成**可交互的图形小程序**。\n\
用户用自然语言描述想要什么,你直接输出一个完整、独立、能立刻运行的 HTML 文档。\n\
\n\
硬性要求:\n\
1. 只输出**一个完整的 HTML 文档**,从 `<!doctype html>` 开始到 `</html>` 结束。\n\
2. 全部代码内联:CSS 写在 `<style>`,JS 写在 `<script>`。**绝对不要**引用任何外部\n\
资源 / CDN / 库(不准用 three.js、不准 import、不准 <script src=...>)——必须用\n\
**原生 WebGL / WebGL2** 手写。程序要能在没有网络的沙箱 iframe 里直接跑。\n\
3. 用一个铺满窗口的 `<canvas>`,背景默认深色,body/html 设 margin:0、overflow:hidden。\n\
监听 resize 让 canvas 跟着窗口和 devicePixelRatio 变。\n\
4. **一定要可交互**:鼠标 / 触摸 / 键盘至少支持一种,让小朋友能玩起来\n\
(拖动旋转、点击生成、按键变色…随主题而定)。视觉要鲜艳、好玩、有动画。\n\
5. 代码要稳:着色器编译、program link 失败时不要整页崩,能力所及地容错。\n\
6. 适当写注释(中文或英文都行),因为小朋友会看代码学习。\n\
\n\
只输出 HTML 代码本身,不要任何解释性文字,不要 markdown 代码围栏。"
.to_string()
}
pub async fn handle(
State(cfg): State<Arc<Config>>,
Json(req): Json<GenerateRequest>,
) -> Sse<ReceiverStream<Result<Event, Infallible>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(64);
tokio::spawn(async move {
if let Err(msg) = stream_generation(&cfg, &req, &tx).await {
let _ = tx
.send(Ok(Event::default()
.event("error")
.data(json!({ "message": msg }).to_string())))
.await;
}
});
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
}
/// 拼用户 prompt:从零生成 vs 在现有代码上改。
fn user_content(req: &GenerateRequest) -> String {
match req.current_code.as_deref().map(str::trim) {
Some(code) if !code.is_empty() => format!(
"这是当前程序的完整代码:\n\n```html\n{code}\n```\n\n\
请在它的基础上做如下修改,并重新输出**完整**的 HTML 文档:\n{}",
req.prompt.trim(),
code = code,
),
_ => req.prompt.trim().to_string(),
}
}
async fn stream_generation(
cfg: &Config,
req: &GenerateRequest,
tx: &tokio::sync::mpsc::Sender<Result<Event, Infallible>>,
) -> Result<(), String> {
let body = json!({
"model": cfg.llm_model,
"messages": [
{ "role": "system", "content": system_prompt() },
{ "role": "user", "content": user_content(req) },
],
"stream": true,
"temperature": 0.8,
"max_tokens": cfg.max_tokens,
});
let endpoint = format!("{}/chat/completions", cfg.gateway.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
.post(&endpoint)
.bearer_auth(&cfg.llm_token)
.json(&body)
.send()
.await
.map_err(|e| format!("调用 LLM 失败:{e}"))?;
let status = resp.status();
if !status.is_success() {
let txt = resp.text().await.unwrap_or_default();
return Err(format!("LLM 返回 {status}:{}", txt.chars().take(300).collect::<String>()));
}
// 逐块读上游 SSE,按行解析 `data:` 帧。
let mut stream = resp.bytes_stream();
let mut buf = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读 LLM 流出错:{e}"))?;
buf.push_str(&String::from_utf8_lossy(&chunk));
// 处理所有完整行(以 \n 结尾的)。
while let Some(nl) = buf.find('\n') {
let line: String = buf.drain(..=nl).collect();
let line = line.trim_end_matches(['\r', '\n']);
let payload = match line.strip_prefix("data:") {
Some(p) => p.trim_start(),
None => continue, // 空行 / 注释 / 其它字段,忽略
};
if payload == "[DONE]" {
let _ = tx.send(Ok(Event::default().event("done").data("{}"))).await;
return Ok(());
}
if let Some(text) = parse_delta(payload) {
if !text.is_empty() {
let ev = Event::default().data(json!({ "t": text }).to_string());
if tx.send(Ok(ev)).await.is_err() {
return Ok(()); // 客户端断开,安静退出
}
}
}
}
}
// 上游没发 [DONE] 就结束了,也算正常收尾。
let _ = tx.send(Ok(Event::default().event("done").data("{}"))).await;
Ok(())
}
/// 从一帧 OpenAI streaming JSON 里抠出 `choices[0].delta.content`。
/// 解析不出来就返回 None(容错:坏帧不该让整条流挂掉)。
fn parse_delta(payload: &str) -> Option<String> {
let v: Value = serde_json::from_str(payload).ok()?;
v.pointer("/choices/0/delta/content")
.and_then(Value::as_str)
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_prompt_forbids_external_libs() {
let p = system_prompt();
assert!(p.contains("WebGL"));
assert!(p.contains("CDN"));
assert!(p.contains("<!doctype html>"));
}
#[test]
fn parse_delta_extracts_content() {
let frame = r#"{"choices":[{"delta":{"content":"<canvas>"}}]}"#;
assert_eq!(parse_delta(frame).as_deref(), Some("<canvas>"));
}
#[test]
fn parse_delta_handles_role_only_frame() {
// 首帧通常只有 role,没有 content
let frame = r#"{"choices":[{"delta":{"role":"assistant"}}]}"#;
assert_eq!(parse_delta(frame), None);
}
#[test]
fn parse_delta_handles_garbage() {
assert_eq!(parse_delta("not json"), None);
assert_eq!(parse_delta("[DONE]"), None);
}
#[test]
fn user_content_plain_when_no_code() {
let req = GenerateRequest { prompt: " 一个会转的立方体 ".into(), current_code: None };
assert_eq!(user_content(&req), "一个会转的立方体");
}
#[test]
fn user_content_embeds_existing_code_for_iteration() {
let req = GenerateRequest {
prompt: "让它转快点".into(),
current_code: Some("<!doctype html><html></html>".into()),
};
let c = user_content(&req);
assert!(c.contains("当前程序的完整代码"));
assert!(c.contains("<!doctype html>"));
assert!(c.contains("让它转快点"));
}
#[test]
fn user_content_ignores_blank_code() {
let req = GenerateRequest { prompt: "画个圆".into(), current_code: Some(" ".into()) };
assert_eq!(user_content(&req), "画个圆");
}
}
+26
View File
@@ -0,0 +1,26 @@
//! webgl.famzheng.me — 说需求 → gemma 现场生成可交互 WebGL 小程序。
//!
//! - 静态 SPA + SPA fallback 由 cube-core::base 处理。
//! - `POST /api/generate` 把需求 + 系统提示词喂给 LLM gateway,**SSE 流式**把
//! 生成的 HTML 一段段回吐给前端(前端边收边显示代码,写完丢进沙箱 iframe 跑)。
//!
//! 完全无状态:没有 DB、没有会话存储。要迭代修改就让前端把当前代码当 context
//! 一起发回来。
mod generate;
use std::sync::Arc;
#[tokio::main]
async fn main() -> std::io::Result<()> {
cube_core::init_tracing();
let dist = std::env::var("WEBGL_DIST_DIR").unwrap_or_else(|_| "/dist".into());
let cfg = Arc::new(generate::Config::from_env());
let api = axum::Router::new()
.route("/generate", axum::routing::post(generate::handle))
.with_state(cfg);
let app = cube_core::base(dist).nest("/api", api);
cube_core::serve(app, 8080).await
}