diff --git a/.memory/worklog.json b/.memory/worklog.json index 9f360a5..423c012 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,26 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "7a31e86", - "message": "auto-save 2026-05-13 01:24 (~1)", - "ts": "2026-05-13T01:25:10+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "473e24c", - "message": "auto-save 2026-05-13 01:30 (~1)", - "ts": "2026-05-13T01:31:04+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "3009c0a", - "message": "auto-save 2026-05-13 01:36 (~1)", - "ts": "2026-05-13T01:36:58+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "ab6f035", @@ -3313,6 +3292,25 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 10:25 (~13)", "files_changed": 4 + }, + { + "ts": "2026-05-14T10:31:25+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 10:31 (~4)", + "hash": "1ebe11f", + "files_changed": 4 + }, + { + "ts": "2026-05-14T02:36:09Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 10:31 (~4)", + "files_changed": 3 + }, + { + "ts": "2026-05-14T02:38:38Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:31 (~4)", + "files_changed": 5 } ] } diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 93ec2b3..babcc52 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -570,7 +570,7 @@

前端核心

- + @@ -819,7 +819,7 @@ SubjectAsset { - + @@ -837,7 +837,7 @@ SubjectAsset { - + @@ -914,6 +914,30 @@ SubjectAsset {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-14 · 音频结果改为改前/改后对照展示

+ Audio + UI +
+
+

问题:音频识别成功后只显示改写文案,用户看不到它和原音频之间的变化关系,难以判断“是不是把参考视频转成我们自己的话”。

+

改动:AudioNode 增加轻量对照摘要:改前显示原音频识别/翻译预览,改后显示 SKG 口播;侧栏 Rewrite 面板改为完整审核视图,先列原音频逐段 ASR/翻译,再列改写稿、产品卖点依据和 MiniMax 配音播放器。

+

影响:web/components/nodes/index.tsxweb/components/dashboard.tsxdocs/source-analysis.html

+
+
+
+
+

2026-05-14 · 多视频工作流状态按 job 隔离

+ Canvas + Job State +
+
+

问题:同时上传多个视频后,前端把已选关键帧和关键帧详情面板作为全局状态保存;切换 active 视频会清空选中帧,或者让详情面板指向另一个视频的同序号帧,容易误以为生图/自动化被终止或串任务。

+

改动:web/app/page.tsxselectedFramesexpandedFrame 改为按 jobId 存储。切换视频只改变当前视图,不清空其他视频的选择;重新抽帧、删帧、手动加帧只清理或更新对应 job。异步生图、生视频、产品融合返回后按返回的 job.id 写回 jobs[],不会落到切换后的 active job。轮询条件也把 audio_script.status=rewriting 纳入运行态,保证音频改写/配音阶段切换视频后仍继续刷新。

+

影响:web/app/page.tsxdocs/source-analysis.html。后端轮询本来已经覆盖所有运行中的 job,这轮主要修正前端 UI 工作上下文。

+
+

2026-05-14 · 生视频接入 SKG 豆包网关

diff --git a/web/app/page.tsx b/web/app/page.tsx index 8068f3b..e66b627 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -103,8 +103,13 @@ export default function Home() { const [frameTargets, setFrameTargets] = useState>({}) const [frameCounts, setFrameCounts] = useState>({}) const [frameQualities, setFrameQualities] = useState>({}) - const [selectedFrames, setSelectedFrames] = useState>(new Set()) - const [expandedFrame, setExpandedFrame] = useState(null) + const [selectedFramesByJob, setSelectedFramesByJob] = useState>({}) + const [expandedFrameByJob, setExpandedFrameByJob] = useState>({}) + const selectedFrames = useMemo( + () => new Set(activeJobId ? selectedFramesByJob[activeJobId] ?? [] : []), + [activeJobId, selectedFramesByJob], + ) + const expandedFrame = activeJobId ? expandedFrameByJob[activeJobId] ?? null : null const [framePanelScale, setFramePanelScale] = useState(1) const [framePanelDock, setFramePanelDock] = useState("left") const framePanelPinned = framePanelDock !== "canvas" @@ -112,28 +117,32 @@ export default function Home() { const [videoPanelScale, setVideoPanelScale] = useState(1) const [videoPanelDock, setVideoPanelDock] = useState("left") const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) - const [storyboardFrame, setStoryboardFrame] = useState(null) - const [workbenchOpen, setWorkbenchOpen] = useState(false) const [clipboard, setClipboard] = useState(null) const flowRef = useRef(null) const lastVideoPanelFocusKey = useRef("") - // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active - const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => { + const setSelectedFramesForJob = useCallback((jobId: string, updater: Set | ((prev: Set) => Set)) => { + setSelectedFramesByJob((prev) => { + const current = new Set(prev[jobId] ?? []) + const next = typeof updater === "function" ? updater(current) : updater + return { ...prev, [jobId]: [...next].sort((a, b) => a - b) } + }) + }, []) + + const clearWorkflowStateForJob = useCallback((jobId: string) => { + setSelectedFramesByJob((prev) => ({ ...prev, [jobId]: [] })) + setExpandedFrameByJob((prev) => ({ ...prev, [jobId]: null })) + }, []) + + const updateJobInList = useCallback((updated: Job) => { setJobs((prev) => { - const current = prev.find((j) => j.id === activeJobId) ?? null - const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater - if (!next) return prev - const idx = prev.findIndex((j) => j.id === next.id) - if (idx < 0) { - setActiveJobId(next.id) - return [...prev, next] - } + const idx = prev.findIndex((j) => j.id === updated.id) + if (idx < 0) return [...prev, updated] const arr = [...prev] - arr[idx] = next + arr[idx] = updated return arr }) - }, [activeJobId]) + }, []) // 新增 job + 设为 active const addJob = useCallback((j: Job) => { @@ -143,13 +152,11 @@ export default function Home() { const handleSwitchJob = useCallback((id: string) => { setActiveJobId(id) - setSelectedFrames(new Set()) }, []) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { setSubmitting(true) - setSelectedFrames(new Set()) try { const created = await createJob(url) addJob(created) @@ -163,7 +170,6 @@ export default function Home() { const handleUpload = useCallback(async (file: File) => { setSubmitting(true) - setSelectedFrames(new Set()) try { toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) const created = await uploadJob(file) @@ -185,7 +191,7 @@ export default function Home() { const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace") setActiveJobId(jobId) setAnalyzing(true) - if (mode === "replace") setSelectedFrames(new Set()) + if (mode === "replace") clearWorkflowStateForJob(jobId) try { await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality) toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`) @@ -200,7 +206,7 @@ export default function Home() { } finally { setAnalyzing(false) } - }, [jobs, frameCounts, frameQualities, frameTargets]) + }, [jobs, frameCounts, frameQualities, frameTargets, clearWorkflowStateForJob]) const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => { if (!job) return @@ -222,13 +228,13 @@ export default function Home() { const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => { try { const updated = await addManualFrame(jobId, t) - setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item)) - setActiveJobId(updated.id) + updateJobInList(updated) + setActiveJobId((prev) => prev ?? updated.id) toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`) } catch (e) { toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e))) } - }, []) + }, [updateJobInList]) const handleAddManualFrame = useCallback(async (t: number) => { if (!job) return @@ -247,18 +253,43 @@ export default function Home() { }, []) const handleToggleFrame = useCallback((idx: number) => { - setSelectedFrames((prev) => { + if (!activeJobId) return + setSelectedFramesForJob(activeJobId, (prev) => { const next = new Set(prev) if (next.has(idx)) next.delete(idx) else next.add(idx) return next }) - }, []) + }, [activeJobId, setSelectedFramesForJob]) const handleOpenFramePanel = useCallback((idx: number) => { + if (!activeJobId) return if (expandedFrame === null) setFramePanelDock("left") - setExpandedFrame(idx) - }, [expandedFrame]) + setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: idx })) + }, [activeJobId, expandedFrame]) + + const handleCloseExpandedFrame = useCallback(() => { + if (!activeJobId) return + setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: null })) + }, [activeJobId]) + + const handleOpenStoryboard = useCallback((idx: number) => { + if (!activeJobId) return + setSelectedFramesForJob(activeJobId, (prev) => { + const next = new Set(prev) + next.add(idx) + return next + }) + }, [activeJobId, setSelectedFramesForJob]) + + const handleOpenWorkbench = useCallback((idx?: number) => { + if (!activeJobId || typeof idx !== "number") return + setSelectedFramesForJob(activeJobId, (prev) => { + const next = new Set(prev) + next.add(idx) + return next + }) + }, [activeJobId, setSelectedFramesForJob]) const handleFramePanelScaleChange = useCallback((scale: number) => { setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2))))) @@ -268,21 +299,20 @@ export default function Home() { const wasActive = activeJobId === jobId try { const updated = await deleteFrame(jobId, idx) - setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item)) - setActiveJobId(updated.id) - setSelectedFrames((prev) => { - if (!wasActive) return new Set() + updateJobInList(updated) + setSelectedFramesForJob(jobId, (prev) => { if (!prev.has(idx)) return prev const next = new Set(prev) next.delete(idx) return next }) - if (!wasActive || expandedFrame === idx) setExpandedFrame(null) + setExpandedFrameByJob((prev) => prev[jobId] === idx ? { ...prev, [jobId]: null } : prev) + if (wasActive) setActiveJobId(updated.id) toast.success(`分镜 ${idx + 1} 已删除`) } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } - }, [activeJobId, expandedFrame]) + }, [activeJobId, setSelectedFramesForJob, updateJobInList]) const handleDeleteFrame = useCallback(async (idx: number) => { if (!activeJobId) return @@ -293,23 +323,23 @@ export default function Home() { if (!activeJobId) return try { const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId) - setJob(updated) + updateJobInList(updated) toast.success("生成图已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } - }, [activeJobId, setJob]) + }, [activeJobId, updateJobInList]) const handleDeleteVideo = useCallback(async (videoId: string) => { if (!activeJobId) return try { const updated = await deleteGeneratedVideo(activeJobId, videoId) - setJob(updated) + updateJobInList(updated) toast.success("视频任务已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } - }, [activeJobId, setJob]) + }, [activeJobId, updateJobInList]) const handleDeleteJob = useCallback(async (jobId: string) => { try { @@ -321,13 +351,17 @@ export default function Home() { if (activeJobId === jobId) { const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null setActiveJobId(fallback?.id ?? null) - setSelectedFrames(new Set()) - setExpandedFrame(null) - setStoryboardFrame(null) - setWorkbenchOpen(false) } return next }) + setSelectedFramesByJob((prev) => { + const { [jobId]: _removed, ...rest } = prev + return rest + }) + setExpandedFrameByJob((prev) => { + const { [jobId]: _removed, ...rest } = prev + return rest + }) toast.success("输入视频已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) @@ -338,12 +372,12 @@ export default function Home() { if (!activeJobId) return try { const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId) - setJob(updated) + updateJobInList(updated) toast.success("元素提取图已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } - }, [activeJobId, setJob]) + }, [activeJobId, updateJobInList]) const handleCopyImage = useCallback((ref: ImageRef) => { setClipboard(ref) @@ -439,13 +473,13 @@ export default function Home() { model, size: "720x1280", }) - setJob(updated) + updateJobInList(updated) void navigator.clipboard?.writeText(prompt).catch(() => {}) toast.success("视频任务已进入 Video Gen 节点") } catch (e) { toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e))) } - }, [job, selectedFrames, setJob]) + }, [job, selectedFrames, updateJobInList]) const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => { if (!job) return @@ -495,13 +529,13 @@ export default function Home() { model: "seedance", size: "720x1280", }) - setJob(updated) + updateJobInList(updated) void navigator.clipboard?.writeText(prompt).catch(() => {}) toast.success("产品融合视频已进入 Video Gen 队列") } catch (e) { toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e))) } - }, [job, setJob]) + }, [job, updateJobInList]) // 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾) useEffect(() => { @@ -540,23 +574,27 @@ export default function Home() { window.history.replaceState({}, "", url.toString()) }, [jobs.length]) - // 恢复已保存的分镜选择:刷新页面后,已有 storyboard 的帧仍应出现在顶部编排栏。 + // 恢复已保存的分镜选择:每个视频自己的 storyboard 帧仍保留在自己的编排上下文里。 useEffect(() => { - if (!job || job.frames.length === 0) return - const persisted = job.frames.filter((f) => !!f.storyboard).map((f) => f.index) - if (persisted.length === 0) return - setSelectedFrames((prev) => { + if (jobs.length === 0) return + setSelectedFramesByJob((prev) => { let changed = false - const next = new Set(prev) - for (const idx of persisted) { - if (!next.has(idx)) { - next.add(idx) - changed = true + const nextByJob = { ...prev } + for (const item of jobs) { + const persisted = item.frames.filter((f) => !!f.storyboard).map((f) => f.index) + if (persisted.length === 0) continue + const next = new Set(nextByJob[item.id] ?? []) + for (const idx of persisted) { + if (!next.has(idx)) { + next.add(idx) + changed = true + } } + nextByJob[item.id] = [...next].sort((a, b) => a - b) } - return changed ? next : prev + return changed ? nextByJob : prev }) - }, [job?.id, job?.frames]) + }, [jobs]) // 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。 const prevStatusRef = useRef(null) @@ -572,7 +610,8 @@ export default function Home() { const runningIds = jobs .filter((item) => { const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress") - return runningVideo || !TERMINAL.includes(item.status) + const runningAudio = item.audio_script?.status === "rewriting" + return runningVideo || runningAudio || !TERMINAL.includes(item.status) }) .map((item) => item.id) @@ -593,7 +632,7 @@ export default function Home() { }, [ job?.id, job?.status, - jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"), + jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"), ]) const [pinnedNodes, setPinnedNodes] = useState>(() => new Set(loadNodePins())) @@ -631,12 +670,12 @@ export default function Home() { onFrameCountChange: handleFrameCountChange, onFrameQualityChange: handleFrameQualityChange, onToggleFrame: handleToggleFrame, - onExpandFrame: setExpandedFrame, + onExpandFrame: handleOpenFramePanel, onOpenFramePanel: handleOpenFramePanel, onFramePanelScaleChange: handleFramePanelScaleChange, onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"), onFramePanelDockChange: setFramePanelDock, - onCloseExpandedFrame: () => setExpandedFrame(null), + onCloseExpandedFrame: handleCloseExpandedFrame, onAddManualFrame: handleAddManualFrame, onAddManualFrameForJob: handleAddManualFrameForJob, onOpenVideoPanel: handleOpenVideoPanel, @@ -644,24 +683,21 @@ export default function Home() { onVideoPanelScaleChange: handleVideoPanelScaleChange, onVideoPanelDockChange: setVideoPanelDock, onSwitchJob: handleSwitchJob, - onJobUpdate: setJob as any, + onJobUpdate: updateJobInList, onDeleteJob: handleDeleteJob, onDeleteFrame: handleDeleteFrame, onDeleteFrameForJob: handleDeleteFrameForJob, onDeleteGenerated: handleDeleteGenerated, onDeleteVideo: handleDeleteVideo, onDeleteCutout: handleDeleteCutout, - onOpenStoryboard: (idx: number) => setStoryboardFrame(idx), - onOpenWorkbench: (idx?: number) => { - if (typeof idx === "number") setStoryboardFrame(idx) - setWorkbenchOpen(true) - }, + onOpenStoryboard: handleOpenStoryboard, + onOpenWorkbench: handleOpenWorkbench, clipboard, onCopyImage: handleCopyImage, onGenerateProductFusionVideo: handleGenerateProductFusionVideo, pinnedNodes, onToggleNodePin: handleToggleNodePin, - }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, clipboard, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin]) + }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, pinnedNodes, handleToggleNodePin]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const savedSizes = useMemo(() => loadNodeSizes(), []) diff --git a/web/components/dashboard.tsx b/web/components/dashboard.tsx index 161c1d3..4b324b7 100644 --- a/web/components/dashboard.tsx +++ b/web/components/dashboard.tsx @@ -156,6 +156,7 @@ export const Dashboard = forwardRef(function Dashboard({ const hasZh = job?.transcript.some((s) => s.zh) ?? false const hasAudioRewrite = !!job?.audio_script?.rewritten_text?.trim() const isAudioRewriting = job?.audio_script?.status === "rewriting" + const audioCompareRows = job?.transcript.slice(0, 8) ?? [] const isFailed = job?.status === "failed" const colState: Record = { @@ -594,23 +595,50 @@ export const Dashboard = forwardRef(function Dashboard({ {/* ---- Rewrite — Kanban ---- */} {key === "rewrite" && ( <> - -
- {job?.audio_script?.product_brief || "等待音频转写完成后,按默认 SKG 放松产品卖点生成口播。"} -
+ + {audioCompareRows.length > 0 ? ( +
+ {audioCompareRows.map((s) => ( +
+
+ {s.start.toFixed(1)}s → {s.end.toFixed(1)}s +
+
+ {s.zh || 中文翻译中…} +
+ {s.en && ( +
+ {s.en} +
+ )} +
+ ))} +
+ ) : ( +
音频识别完成后,这里显示原始 ASR 和中文翻译。
+ )} +
参考视频原话,不直接用于成片
- + + {job?.audio_script?.rewritten_text ? ( -
+
{job.audio_script.rewritten_text}
) : (
- {isAudioRewriting ? "正在生成 SKG 口播文案…" : "转录完成后自动生成 SKG 口播文案"} + {isAudioRewriting ? "正在把原音频转成 SKG 口播文案…" : "转录完成后自动生成 SKG 口播文案"}
)} -
ASR + 翻译 + SKG 卖点转化
+
用于后续 TTS、字幕和视频生成 prompt
+ + +
+ {job?.audio_script?.product_brief || "等待音频转写完成后,按默认 SKG 放松产品卖点生成口播。"} +
+
+ {job?.audio_script?.voice_url ? (
- {rewrittenText && ( -
- {rewrittenText} + {(originalPreview || rewrittenText) && ( +
+ {originalPreview && ( +
+
改前 · 原音频
+
{originalPreview}
+
+ )} + {rewrittenText && ( +
+
改后 · SKG 口播
+
{rewrittenText}
+
+ )}
)} {voiceUrl && (
web/app/page.tsx产品工作台主状态:jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。
web/app/page.tsx产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。
web/components/nodes/index.tsxDAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。
web/components/lightbox.tsx关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。
web/components/product-library-picker.tsxSKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 asset
输入 Input创建/上传任务,显示视频就绪;每个视频缩略图上方都有绑定自己的自动抽帧快捷工具条,默认只露出目标和抽帧按钮,张数/自动精度收进设置;也可在视频抽帧侧边面板内自动抽帧。多个视频抽帧可先后入队。创建/上传任务,显示视频就绪;每个视频缩略图上方都有绑定自己的自动抽帧快捷工具条,默认只露出目标和抽帧按钮,张数/自动精度收进设置;也可在视频抽帧侧边面板内自动抽帧。多个视频抽帧可先后入队,切换 active 视频不会清空其他视频已选帧或关闭它们的异步生成回写。 不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。 page.tsxInputNodeVideoFramePanelNodeapi/main.py
Audio / ASR / Rewrite独立声音文案轨:从 audio.wav 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。主画布的 AudioNode 只展示模型链路、改写稿和配音播放器。独立声音文案轨:从 audio.wav 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。主画布的 AudioNode 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示,侧栏 Rewrite 展开后显示完整逐段 ASR/翻译、改写稿、产品依据和配音播放器。 不要阻断视觉素材管线。 AudioNodeASRNodeTranslateNodeRewriteNodepipeline_transcribeAudioScript