From 94afd6d644f295a7cf67bb7725d60df4387bdc79 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 12 May 2026 17:06:43 +0800 Subject: [PATCH] auto-save 2026-05-12 17:06 (+1, ~3) --- .memory/worklog.json | 7 +++ web/app/page.tsx | 36 +++++++++++++ web/components/lightbox.tsx | 97 ++++++++++++++++++++++++++++++++++ web/components/nodes/index.tsx | 76 ++++++++++++++++++-------- 4 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 web/components/lightbox.tsx diff --git a/.memory/worklog.json b/.memory/worklog.json index e667b33..4982249 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -104,6 +104,13 @@ "message": "auto-save 2026-05-12 16:55 (~4)", "hash": "345391d", "files_changed": 4 + }, + { + "ts": "2026-05-12T17:01:09+08:00", + "type": "commit", + "message": "auto-save 2026-05-12 17:00 (~3)", + "hash": "4138bea", + "files_changed": 3 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index d52daa8..5f8a286 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -14,6 +14,7 @@ import { } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" import { analyzeJob, createJob, getJob, uploadJob, type Job } from "@/lib/api" +import { FrameLightbox } from "@/components/lightbox" const NODE_TYPES = { input: InputNode, @@ -64,6 +65,7 @@ export default function Home() { const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [selectedFrames, setSelectedFrames] = useState>(new Set()) + const [expandedFrame, setExpandedFrame] = useState(null) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { @@ -102,6 +104,8 @@ export default function Home() { try { await analyzeJob(job.id, 5) toast.info("开始解析:拆轨 → 抽帧 → ASR → 翻译") + // 乐观更新本地状态,让轮询 useEffect 重新启动 + setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev) } catch (e) { toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -118,6 +122,24 @@ export default function Home() { }) }, []) + // URL ?job=xxx 自动恢复 job state + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const id = params.get("job") + if (id && !job) { + getJob(id).then(setJob).catch(() => toast.error(`找不到 job ${id}`)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 写回 URL(不刷新页面) + useEffect(() => { + if (!job) return + const url = new URL(window.location.href) + url.searchParams.set("job", job.id) + window.history.replaceState({}, "", url.toString()) + }, [job?.id]) + // 轮询 Job(downloaded / transcribed / failed 三态停止) useEffect(() => { if (!job) return @@ -144,6 +166,7 @@ export default function Home() { onUploadFile: handleUpload, onAnalyze: handleAnalyze, onToggleFrame: handleToggleFrame, + onExpandFrame: setExpandedFrame, }), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) @@ -231,6 +254,19 @@ export default function Home() { + + {/* Lightbox 看大图 */} + {job && ( + setExpandedFrame(null)} + onChange={setExpandedFrame} + onToggleSelect={handleToggleFrame} + /> + )} ) diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx new file mode 100644 index 0000000..8fc4dd0 --- /dev/null +++ b/web/components/lightbox.tsx @@ -0,0 +1,97 @@ +"use client" +import { useEffect } from "react" +import { X, ChevronLeft, ChevronRight, Check } from "lucide-react" +import { frameUrl, type KeyFrame } from "@/lib/api" + +interface Props { + jobId: string + frames: KeyFrame[] + activeIndex: number | null + selected: Set + onClose: () => void + onChange: (idx: number) => void + onToggleSelect: (idx: number) => void +} + +export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect }: Props) { + useEffect(() => { + if (activeIndex === null) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + if (e.key === "ArrowLeft" && activeIndex > 0) onChange(activeIndex - 1) + if (e.key === "ArrowRight" && activeIndex < frames.length - 1) onChange(activeIndex + 1) + if (e.key === " " || e.key === "Enter") { + e.preventDefault() + onToggleSelect(activeIndex) + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [activeIndex, frames.length, onClose, onChange, onToggleSelect]) + + if (activeIndex === null || !frames[activeIndex]) return null + const f = frames[activeIndex] + const isSelected = selected.has(f.index) + + return ( +
+ {/* 关闭 */} + + + {/* 左右切换 */} + {activeIndex > 0 && ( + + )} + {activeIndex < frames.length - 1 && ( + + )} + + {/* 大图 */} +
e.stopPropagation()} className="flex flex-col items-center gap-4 max-w-[92vw] max-h-[92vh]"> + {`frame +
+
+ {String(f.index + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")} + · + {f.timestamp.toFixed(2)}s +
+ +
+
←/→ 切换 · Space 选用 · ESC 关闭
+
+
+ ) +} diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 83edd8f..c848a9d 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -6,7 +6,7 @@ import { Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, } from "lucide-react" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" -import { type Job, videoUrl } from "@/lib/api" +import { type Job, frameUrl, videoUrl } from "@/lib/api" export interface NodeData { job: Job | null @@ -17,6 +17,7 @@ export interface NodeData { onUploadFile: (file: File) => void onAnalyze: () => void onToggleFrame: (idx: number) => void + onExpandFrame: (idx: number) => void } /* ---- 状态映射工具 ---- */ @@ -211,51 +212,82 @@ export function SplitNode({ data, selected }: any) { } /* ============================================================ - 4. KeyframeNode — 缩略图网格 + 多选 + 4. KeyframeNode — 缩略图横排浮在节点上方,点击展开 lightbox ============================================================ */ +const KEYFRAME_WIDTH = 360 +const THUMB_W = 64 +const THUMB_GAP = 6 + export function KeyframeNode({ data, selected }: any) { const d: NodeData = data const st = keyframeStatus(d.job) + const frames = d.job?.frames ?? [] + const jobId = d.job?.id + return ( - } - title="关键帧 · Keyframes" - subtitle={`STEP 4 · ${d.selectedFrames.size}/${d.job?.frames.length || 5}`} - width={360} - selected={selected} - > - {d.job?.frames.length ? ( -
- {d.job.frames.map((f) => { +
+ {/* 缩略图浮条(节点上方) */} + {frames.length > 0 && jobId && ( +
+ {frames.map((f) => { const isSel = d.selectedFrames.has(f.index) return ( ) })}
- ) : ( -
等待解析后抽取(默认 5 张)
)} - + + } + title="关键帧 · Keyframes" + subtitle={`STEP 4 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`} + width={KEYFRAME_WIDTH} + selected={selected} + > + {frames.length > 0 ? ( +
+ 自动抽取 {frames.length} 张 · + 点击上方缩略图展开 +
+ + 空格 / 选用此帧 按钮可勾选;选中的传入「生图」节点 + +
+ ) : ( +
+ 等待解析后抽取(默认 5 张,可在 API 配置 KEYFRAME_COUNT 改) +
+ )} +
+
) }