From 64db0930fad7e3756c05835601dafbd66e884661 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 12 May 2026 18:35:34 +0800 Subject: [PATCH] auto-save 2026-05-12 18:35 (~3) --- .memory/worklog.json | 7 + web/app/page.tsx | 6 +- web/components/dashboard.tsx | 678 +++++++++++++++++++---------------- 3 files changed, 372 insertions(+), 319 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 37adf31..9074d6d 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -174,6 +174,13 @@ "message": "auto-save 2026-05-12 17:50 (~1)", "hash": "440164e", "files_changed": 1 + }, + { + "ts": "2026-05-12T18:29:59+08:00", + "type": "commit", + "message": "auto-save 2026-05-12 18:29 (+1, ~1)", + "hash": "aa5ad08", + "files_changed": 2 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index 95f108a..1127ad3 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -240,12 +240,12 @@ export default function Home() { - {/* 上区:看板(60vh)— 10 列映射底部节点 */} -
+ {/* 上区:折叠看板 — 默认仅一条 tile 栏,点击 tile 才展开 */} +
- {/* 下区:紧凑 DAG 节点流图 */} + {/* 下区:紧凑 DAG 节点流图(撑满剩余高度) */}
= { input: "linear-gradient(135deg, #6366f1, #a855f7)", @@ -19,30 +18,16 @@ const TYPE_GRAD: Record = { type ColState = "pending" | "running" | "done" | "failed" const STATE_DOT: Record = { - pending: "bg-white/20", - running: "bg-violet-400 animate-pulse", - done: "bg-emerald-400", - failed: "bg-red-400", + pending: "bg-white/25", + running: "bg-violet-300 shadow-[0_0_8px_rgba(167,139,250,0.8)] animate-pulse", + done: "bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.7)]", + failed: "bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)]", } -function ColumnHeader({ type, title, label, state }: { type: ColType; title: string; label: string; state: ColState }) { - return ( -
-
- {title} - -
-
- {label} -
-
- ) -} - -function MiniCard({ children, className = "", onClick }: { children: React.ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void }) { +function MiniCard({ children, className = "", onClick }: { children: ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void }) { return (
{children} @@ -59,6 +44,7 @@ export function Dashboard({ data }: Props) { const [url, setUrl] = useState("") const [videoT, setVideoT] = useState(0) const [addingFrame, setAddingFrame] = useState(false) + const [expanded, setExpanded] = useState>(new Set()) const fileRef = useRef(null) const videoRef = useRef(null) @@ -72,7 +58,6 @@ export function Dashboard({ data }: Props) { const hasZh = job?.transcript.some((s) => s.zh) ?? false const isFailed = job?.status === "failed" - /* 每列状态 */ const colState: Record = { input: !job ? "pending" : "done", download: !job ? "pending" : isDownloading ? "running" : hasVideo ? "done" : isFailed && job.progress < 30 ? "failed" : "pending", @@ -86,326 +71,387 @@ export function Dashboard({ data }: Props) { compose: "pending", } - return ( -
-
+ /* 每列摘要 = tile 副标题 */ + const colSummary: Record = { + input: job ? (job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接") : "等待", + download: hasVideo && job ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : isDownloading ? "下载中…" : "—", + split: hasFrames ? "wav 已生成" : isSplitting ? "拆轨中…" : "—", + keyframe: hasFrames ? `${data.selectedFrames.size}/${job!.frames.length} 选用` : "—", + asr: hasTranscript ? `${job!.transcript.length} 段` : "—", + translate: hasZh ? `${job!.transcript.filter((s) => s.zh).length} 段` : "—", + rewrite: "占位", + imagegen: data.selectedFrames.size > 0 ? `${data.selectedFrames.size} 张待生` : "占位", + videogen: "占位", + compose: "占位", + } - {/* 1. Input */} -
- -
- - setUrl(e.target.value)} - placeholder="粘贴 TikTok 链接" - disabled={isDownloading || data.submitting} - className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-white/60 dark:bg-black/40 border border-black/10 dark:border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40" - /> -
- - - { - const f = e.target.files?.[0] - if (f) data.onUploadFile(f) - e.target.value = "" - }} - /> + const TILES: Array<{ key: string; title: string; type: ColType; icon: ReactNode; step: number }> = [ + { key: "input", title: "输入", type: "input", icon: , step: 1 }, + { key: "download", title: "下载", type: "process", icon: , step: 2 }, + { key: "split", title: "拆分", type: "process", icon: , step: 3 }, + { key: "keyframe", title: "关键帧", type: "ai", icon: , step: 4 }, + { key: "asr", title: "转录", type: "ai", icon: , step: 5 }, + { key: "translate", title: "翻译", type: "ai", icon: , step: 6 }, + { key: "rewrite", title: "改写", type: "ai", icon: , step: 7 }, + { key: "imagegen", title: "生图", type: "ai", icon: , step: 8 }, + { key: "videogen", title: "生视频", type: "ai", icon: , step: 9 }, + { key: "compose", title: "合成", type: "output", icon: , step: 10 }, + ] + + // 单选展开:toggle 同一 key = 收起;点其他 key = 切换 + const toggleTile = (key: string) => setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key]))) + const closeTile = (_key: string) => setExpanded(new Set()) + + return ( +
+ {/* Tile Bar — 一直显示 */} +
+ {TILES.map((t) => { + const state = colState[t.key] + const isOpen = expanded.has(t.key) + return ( + - - )} - {job && ( - -
来源
-
- {job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url} +
+ {colSummary[t.key]} +
+ + ) + })} +
+ + {/* 展开面板 — 多卡同时展开,按 step 顺序排列 */} + {expanded.size > 0 && ( +
+
+ 展开详情 + +
+
+ {TILES.filter((t) => expanded.has(t.key)).map((t) => ( +
+
+
+ {String(t.step).padStart(2, "0")} + {t.icon} + {t.title} + {colSummary[t.key]} +
+
- - )} +
+ {renderSection(t.key)} +
+
+ ))}
+ )} +
+ ) - {/* 2. Download */} -
- -
- -
- {job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"} + function renderSection(key: string): ReactNode { + return ( + <> + + {/* ---- Input ---- */} + {key === "input" && ( +
+ +
链接 / 上传
+ setUrl(e.target.value)} + placeholder="粘贴 TikTok 链接" + disabled={isDownloading || data.submitting} + className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40" + /> +
+ + + { + const f = e.target.files?.[0] + if (f) data.onUploadFile(f) + e.target.value = "" + }} + /> +
+
+ {hasVideo && ( + +
下一步
+ + {job && ( +
+ {job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url} +
+ )} +
+ )}
- - {hasVideo && job && ( - <> - + )} + + {/* ---- Download ---- */} + {key === "download" && hasVideo && job && ( +
+ +
+ +
元数据
+
+
分辨率
{job.width}×{job.height}
+
时长
{job.duration.toFixed(1)}s
+
来源
{job.url.startsWith("upload://") ? "上传" : "yt-dlp"}
+
+
+
+
+ )} + {key === "download" && !hasVideo && ( +
{isDownloading ? "yt-dlp 下载中…" : "等待提交"}
+ )} + + {/* ---- Split ---- */} + {key === "split" && ( +
+ +
视频流
+
→ 关键帧抽取
+
ffmpeg fast seek + Laplacian 评分
+
+ +
音频流
+
→ ASR (16kHz mono wav)
+
ffmpeg -vn -ac 1 -ar 16000
+
+
+ )} + + {/* ---- Keyframe ---- */} + {key === "keyframe" && ( +
+ {hasVideo && job && ( + + + + )} + {!hasFrames ? ( +
等待解析后抽取(默认 5 张)
+ ) : ( +
+ {job!.frames.map((f) => { + const isSel = data.selectedFrames.has(f.index) + return ( + + +
+ 分镜 {f.index + 1} + +
+
+ ) + })} +
+ )} +
+ )} + + {/* ---- ASR / Translate ---- */} + {(key === "asr" || key === "translate") && ( + !hasTranscript ? ( +
+ {colState.asr === "running" ? "Gemini 转录中…" : "等待关键帧抽取"} +
+ ) : ( +
+ {job!.transcript.map((s) => ( + +
+ {s.start.toFixed(1)}s → {s.end.toFixed(1)}s +
+
+ EN{s.en} +
+
+ ZH{s.zh || 翻译中…} +
+
+ ))} +
+ ) + )} + + {/* ---- Rewrite ---- */} + {key === "rewrite" && ( +
+ +
产品信息
+