Compare commits
11
Commits
0b87085ca9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef20c8c54c | ||
|
|
a0253e118f | ||
|
|
ef29561b28 | ||
|
|
b36e30ecbf | ||
|
|
25a28ea092 | ||
|
|
bb65c4c415 | ||
|
|
4ee9b6ce78 | ||
|
|
8224766926 | ||
|
|
bde57065d1 | ||
|
|
4336cde189 | ||
|
|
cf360f0193 |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
**/node_modules
|
||||||
|
**/.env*
|
||||||
|
**/config.yaml
|
||||||
|
**/data
|
||||||
|
**/*.bundle
|
||||||
|
.claude
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Local development agreement
|
||||||
|
|
||||||
|
- Source lives locally under ~/projects/fzc-src/cube; fzc is the deployment target, not an interactive development workspace.
|
||||||
|
- Use the root Makefile for routine deployment. Preserve existing data, credentials, ingress, service names and schedule.
|
||||||
|
- Never bulk-deploy, trigger real notifications, or import production data merely to verify a migration.
|
||||||
|
- Original unrelated dirty files belong to the user; preserve them and review before committing/publishing.
|
||||||
|
- Do not commit local config files, caches, login images, backup credentials or runtime records.
|
||||||
|
- Read README.md for the exact deployment boundary. Other projects/hosts are outside this repository's scope.
|
||||||
Generated
+22
@@ -1770,6 +1770,14 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "video2slides"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cube-core",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1882,6 +1890,20 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webgl"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"cube-core",
|
||||||
|
"futures",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ members = [
|
|||||||
"apps/notes",
|
"apps/notes",
|
||||||
"apps/llm-proxy",
|
"apps/llm-proxy",
|
||||||
"apps/write",
|
"apps/write",
|
||||||
|
"apps/webgl",
|
||||||
|
"apps/video2slides",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
.SHELLFLAGS := -eu -o pipefail -c
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
DEPLOY_HOST ?= fzc
|
||||||
|
APP ?= cube
|
||||||
|
APPS := cube articulate karaoke notes simpleasm video2slides webgl werewolf llm-proxy
|
||||||
|
NAMESPACE = $(if $(filter llm-proxy,$(APP)),llm-proxy,cube-$(APP))
|
||||||
|
RELEASE_TAG := $(shell git rev-parse --short=12 HEAD)-$(shell date -u +%Y%m%d%H%M%S)
|
||||||
|
IMAGE ?= cube-local-$(APP):$(RELEASE_TAG)
|
||||||
|
.PHONY: help guard build deploy deploy-write
|
||||||
|
help:
|
||||||
|
@echo 'make deploy APP=<$(APPS)>; make deploy-write'
|
||||||
|
guard:
|
||||||
|
@case ' $(APPS) ' in *' $(APP) '*) ;; *) echo 'Unknown/decommissioned app'; exit 1;; esac
|
||||||
|
@test -z "$$(git status --porcelain)" || { echo 'Review/commit source changes first'; exit 1; }
|
||||||
|
build: guard
|
||||||
|
cargo test -p $(APP)
|
||||||
|
CC_x86_64_unknown_linux_musl=musl-gcc cargo build --release --target x86_64-unknown-linux-musl -p $(APP)
|
||||||
|
@if [ -f apps/$(APP)/frontend/package.json ]; then cd apps/$(APP)/frontend && npm ci && npm run build; fi
|
||||||
|
docker build -f apps/$(APP)/Dockerfile -t $(IMAGE) .
|
||||||
|
deploy: guard
|
||||||
|
$(MAKE) build APP=$(APP) IMAGE=$(IMAGE)
|
||||||
|
docker save $(IMAGE) | ssh $(DEPLOY_HOST) 'sudo k3s ctr -n k8s.io images import -'
|
||||||
|
python3 scripts/release_remote.py --host $(DEPLOY_HOST) --namespace $(NAMESPACE) --deployment $(APP) --container $(APP) --image $(IMAGE)
|
||||||
|
deploy-write:
|
||||||
|
$(MAKE) -C apps/write deploy HOST=$(DEPLOY_HOST)
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# cube
|
# cube
|
||||||
|
|
||||||
|
## 本地开发与发布(2026-09-21)
|
||||||
|
|
||||||
|
源码迁至本机 `~/projects/fzc-src/cube`。使用 `make deploy APP=<应用名>` 在本机测试/构建,再通过 SSH fzc 更新单个现有工作负载;`make deploy-write` 沿用 user systemd 方式。旧 CI 配置保留,不在这次迁移中批量重发各应用。
|
||||||
|
|
||||||
|
部署脚本已经做命令及语法检查;迁移本身不重启现有服务。
|
||||||
|
|
||||||
Fam 的小 app 平台。
|
Fam 的小 app 平台。
|
||||||
|
|
||||||
## 是什么
|
## 是什么
|
||||||
|
|||||||
@@ -68,5 +68,19 @@
|
|||||||
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。",
|
"description": "录音 → ASR 转写 → LLM 生成会议纪要。Sidebar + content;passphrase 鉴权。",
|
||||||
"url": "https://notes.famzheng.me",
|
"url": "https://notes.famzheng.me",
|
||||||
"status": "live"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -587,6 +587,8 @@ async function runInspire() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// chord —— 单 mode(只抓 yopu 默认数字级数版)
|
// chord —— 单 mode(只抓 yopu 默认数字级数版)
|
||||||
|
// 'chord' 是内部 state key / tab 名;发给后端 API 的 mode 必须是 'functional'(数字级数版)
|
||||||
|
const CHORD_API_MODE = 'functional'
|
||||||
const chordStates = ref({ chord: 'idle' })
|
const chordStates = ref({ chord: 'idle' })
|
||||||
const chordErrors = ref({ chord: '' })
|
const chordErrors = ref({ chord: '' })
|
||||||
const chordPollTimers = { chord: null }
|
const chordPollTimers = { chord: null }
|
||||||
@@ -989,7 +991,7 @@ async function startChordFetch(mode) {
|
|||||||
chordStates.value = { ...chordStates.value, [mode]: 'pending' }
|
chordStates.value = { ...chordStates.value, [mode]: 'pending' }
|
||||||
chordErrors.value = { ...chordErrors.value, [mode]: '' }
|
chordErrors.value = { ...chordErrors.value, [mode]: '' }
|
||||||
try {
|
try {
|
||||||
const r = await chordFetch(selectedId.value, mode)
|
const r = await chordFetch(selectedId.value, CHORD_API_MODE)
|
||||||
if (r.status === 'completed') {
|
if (r.status === 'completed') {
|
||||||
await reloadPiece()
|
await reloadPiece()
|
||||||
chordStates.value = { ...chordStates.value, [mode]: 'completed' }
|
chordStates.value = { ...chordStates.value, [mode]: 'completed' }
|
||||||
@@ -1014,7 +1016,7 @@ async function pollChord(mode) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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' }
|
chordStates.value = { ...chordStates.value, [mode]: r.status || 'pending' }
|
||||||
chordErrors.value = { ...chordErrors.value, [mode]: r.error || '' }
|
chordErrors.value = { ...chordErrors.value, [mode]: r.error || '' }
|
||||||
if (r.status === 'completed') {
|
if (r.status === 'completed') {
|
||||||
|
|||||||
@@ -1397,7 +1397,8 @@ struct ChordModeQuery {
|
|||||||
fn chord_mode_to_role(mode: &str) -> &'static str {
|
fn chord_mode_to_role(mode: &str) -> &'static str {
|
||||||
match mode {
|
match mode {
|
||||||
"letters" => "chord_letters",
|
"letters" => "chord_letters",
|
||||||
_ => "chord_functional",
|
// 单 mode 简化后,数字级数版统一用 role 'chord',与手动上传 + 前端展示一致
|
||||||
|
_ => "chord",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
data/
|
||||||
|
target/
|
||||||
@@ -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 }
|
||||||
@@ -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"]
|
||||||
@@ -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)。
|
||||||
@@ -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>
|
||||||
+398
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
Generated
+1500
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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'">
|
||||||
|
</> 代码<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></> 代码</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>
|
||||||
@@ -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() : ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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), "画个圆");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Local-built image release to an existing fzc workload; never provisions infrastructure."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
def run(args, capture=False):
|
||||||
|
return subprocess.run(args, check=True, text=True, capture_output=capture).stdout
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--host', default='fzc')
|
||||||
|
parser.add_argument('--namespace', required=True)
|
||||||
|
parser.add_argument('--deployment', required=True)
|
||||||
|
parser.add_argument('--container', required=True)
|
||||||
|
parser.add_argument('--image', required=True)
|
||||||
|
parser.add_argument('--cron', action='append', default=[], help='name:container')
|
||||||
|
parser.add_argument('--health')
|
||||||
|
args=parser.parse_args()
|
||||||
|
def kube(*parts, capture=False):
|
||||||
|
return run(['ssh',args.host,shlex.join(['kubectl','-n',args.namespace,*parts])],capture)
|
||||||
|
targets=[('deployment',args.deployment,args.container)]+[('cronjob',*c.split(':')) for c in args.cron]
|
||||||
|
previous=[]
|
||||||
|
for kind,name,container in targets:
|
||||||
|
obj=json.loads(kube('get',kind,name,'-o','json',capture=True))
|
||||||
|
spec=obj['spec']['template']['spec'] if kind=='deployment' else obj['spec']['jobTemplate']['spec']['template']['spec']
|
||||||
|
image=next(c['image'] for c in spec['containers'] if c['name']==container)
|
||||||
|
previous.append((kind,name,container,image))
|
||||||
|
changed=[]
|
||||||
|
try:
|
||||||
|
for kind,name,container,image in previous:
|
||||||
|
changed.append((kind,name,container,image))
|
||||||
|
kube('set','image',kind+'/'+name,container+'='+args.image)
|
||||||
|
if kind=='deployment': kube('rollout','status',kind+'/'+name,'--timeout=180s')
|
||||||
|
if args.health:
|
||||||
|
with urllib.request.urlopen(args.health,timeout=20) as response:
|
||||||
|
assert response.status == 200
|
||||||
|
except Exception:
|
||||||
|
for kind,name,container,image in reversed(changed):
|
||||||
|
kube('set','image',kind+'/'+name,container+'='+image)
|
||||||
|
kube('rollout','status','deployment/'+args.deployment,'--timeout=180s')
|
||||||
|
raise
|
||||||
|
print('Released',args.image,'to',args.host,args.namespace, '; previous images:',previous)
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
Reference in New Issue
Block a user