diff --git a/.memory/worklog.json b/.memory/worklog.json index 9380cd9..579df6c 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -2016,6 +2016,19 @@ "message": "auto-save 2026-05-13 17:39 (~2)", "hash": "358badf", "files_changed": 2 + }, + { + "ts": "2026-05-13T17:45:37+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 17:45 (~1)", + "hash": "3bfb827", + "files_changed": 1 + }, + { + "ts": "2026-05-13T09:49:28Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-13 17:45 (~1)", + "files_changed": 3 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index 1d3c67b..c148b7e 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -9,14 +9,14 @@ import { import { Toaster, toast } from "sonner" import { InputNode, KeyframeNode, ASRNode, - TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode, + TranslateNode, RewriteNode, StoryboardNode, VideoGenNode, ComposeNode, type NodeData, } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" import { Dashboard, type DashboardHandle } from "@/components/dashboard" import { StoryboardBar } from "@/components/storyboard-bar" import { StoryboardWorkbench } from "@/components/storyboard-workbench" -import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, type Job, type ImageRef } from "@/lib/api" +import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job, type ImageRef } from "@/lib/api" import { VideoLightbox } from "@/components/video-lightbox" const NODE_TYPES = { @@ -25,20 +25,20 @@ const NODE_TYPES = { asr: ASRNode, translate: TranslateNode, rewrite: RewriteNode, - imagegen: ImageGenNode, + storyboard: StoryboardNode, videogen: VideoGenNode, compose: ComposeNode, } // 合并 input + download + split 为一个节点 -// 分叉:上路 input → keyframe → imagegen → videogen ↘ -// 下路 input → asr → translate → rewrite ────→ compose +// 分叉:上路 input → keyframe → storyboard → videogen ↘ +// 下路 input → asr → translate → rewrite ──────→ storyboard / compose const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number }> = [ { id: "input", type: "input", x: 40, y: 240 }, { id: "keyframe", type: "keyframe", x: 460, y: 60 }, { id: "asr", type: "asr", x: 460, y: 440 }, { id: "translate", type: "translate", x: 840, y: 440 }, - { id: "imagegen", type: "imagegen", x: 880, y: 60 }, + { id: "storyboard", type: "storyboard", x: 880, y: 60 }, { id: "rewrite", type: "rewrite", x: 1220, y: 440 }, { id: "videogen", type: "videogen", x: 1260, y: 60 }, { id: "compose", type: "compose", x: 1640, y: 240 }, @@ -49,9 +49,9 @@ const EDGES_RAW: Array<[string, string]> = [ ["input", "asr"], ["asr", "translate"], ["translate", "rewrite"], - ["keyframe", "imagegen"], - ["rewrite", "imagegen"], - ["imagegen", "videogen"], + ["keyframe", "storyboard"], + ["rewrite", "storyboard"], + ["storyboard", "videogen"], ["videogen", "compose"], ["rewrite", "compose"], ] @@ -135,7 +135,7 @@ export default function Home() { setSelectedFrames(new Set()) try { await analyzeJob(job.id, 5) - toast.info("开始解析:拆轨 → 抽帧 → ASR → 翻译") + toast.info("开始解析:拆轨 → 抽帧。声音文案轨单独处理") // 乐观更新本地状态,让轮询 useEffect 重新启动 setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev) } catch (e) { @@ -199,23 +199,6 @@ export default function Home() { toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`) }, []) - const handlePushToStoryboard = useCallback(async (payload: { kind: "keyframe" | "cutout"; frameIdx: number; elementId?: string; cutoutId?: string; label?: string }) => { - if (!activeJobId) return - try { - const updated = await pushStoryboardImage(activeJobId, { - kind: payload.kind, - frame_idx: payload.frameIdx, - element_id: payload.elementId, - cutout_id: payload.cutoutId, - label: payload.label, - }) - setJob(updated) - toast.success("已推送到分镜头编排") - } catch (e) { - toast.error("推送失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId, setJob]) - // URL ?job=xxx,yyy 自动恢复多个 job useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -240,6 +223,24 @@ export default function Home() { window.history.replaceState({}, "", url.toString()) }, [jobs.length]) + // 恢复已保存的分镜选择:刷新页面后,已有 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) => { + let changed = false + const next = new Set(prev) + for (const idx of persisted) { + if (!next.has(idx)) { + next.add(idx) + changed = true + } + } + return changed ? next : prev + }) + }, [job?.id, job?.frames]) + // 轮询 Job(downloaded / transcribed / failed 三态停止) const prevStatusRef = useRef(null) useEffect(() => { @@ -250,7 +251,7 @@ export default function Home() { } prevStatusRef.current = job.status - const TERMINAL: Job["status"][] = ["downloaded", "transcribed", "failed"] + const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] if (TERMINAL.includes(job.status)) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } return @@ -286,9 +287,9 @@ export default function Home() { onDeleteFrame: handleDeleteFrame, onDeleteGenerated: handleDeleteGenerated, onOpenStoryboard: (idx: number) => setStoryboardFrame(idx), - onPushToStoryboard: handlePushToStoryboard, + onOpenWorkbench: () => setWorkbenchOpen(true), onCopyImage: handleCopyImage, - }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handlePushToStoryboard, handleCopyImage]) + }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const [nodes, setNodes, onNodesChange] = useNodesState( @@ -318,6 +319,8 @@ export default function Home() { keyframe: !!job && job.frames.length > 0, asr: !!job && job.transcript.length > 0, translate: !!job && (job.transcript.some((s) => s.zh) ?? false), + rewrite: !!job && (job.transcript.some((s) => s.zh) ?? false), + storyboard: selectedFrames.size > 0, } setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] }))) }, [job, setEdges]) diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 54f9b22..71e7657 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -4,10 +4,10 @@ import { createPortal } from "react-dom" import { type NodeProps } from "@xyflow/react" import { Link2, Upload, Download, Scissors, Image as ImageIcon, - Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X, LayoutGrid, + Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, } from "lucide-react" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" -import { type Job, type ImageRef, frameUrl, effectiveFrameUrl, videoUrl, generatedImageUrl, cutoutUrl, hasCutout, representativeCutoutUrl } from "@/lib/api" +import { type Job, type ImageRef, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl } from "@/lib/api" export interface NodeData { job: Job | null // 当前 active job @@ -31,7 +31,7 @@ export interface NodeData { onDeleteFrame?: (idx: number) => void // 删整张关键帧 onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图 onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板 - onPushToStoryboard?: (payload: { kind: "keyframe" | "cutout"; frameIdx: number; elementId?: string; cutoutId?: string; label?: string }) => void + onOpenWorkbench?: () => void // 打开全屏分镜编排工作台 onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排工作台插槽) } @@ -85,7 +85,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an // 是否已下载 → 显示视频 + 解析按钮 const hasVideo = !!job?.video_url const isDownloading = job?.status === "downloading" || job?.status === "created" - const isAnalyzing = !!job && ["splitting", "frames_extracted", "transcribing"].includes(job.status) + const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status) const isDone = job?.status === "transcribed" const hasFrames = (job?.frames.length ?? 0) > 0 const inputLocked = isDownloading || d.submitting @@ -466,9 +466,9 @@ export function KeyframeNode({ data, selected }: any) { } - title="关键帧 · 清洗 + 提取" - subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`} + icon={} + title="镜头拆解 · 元素提取" + subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`} width={KEYFRAME_WIDTH} selected={selected} > @@ -485,7 +485,7 @@ export function KeyframeNode({ data, selected }: any) { 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图
- 点缩略图 → 清洗水印 / 提取元素 → 抠图给「分镜头编排」用 + 点缩略图 → 清洗水印 / 提取可借鉴元素 → 改造成 SKG 画面素材 ) @@ -509,8 +509,8 @@ export function ASRNode({ data, selected }: any) { } - title="转录 · ASR" - subtitle="STEP 5 · Gemini" + title="声音文案 · ASR" + subtitle="STEP 3 · 可选文案轨" selected={selected} >
@@ -550,8 +550,8 @@ export function TranslateNode({ data, selected }: any) { } - title="翻译 · Translate" - subtitle="STEP 6 · EN → ZH" + title="翻译理解 · Translate" + subtitle="STEP 4 · EN → ZH" selected={selected} >
@@ -576,12 +576,12 @@ export function RewriteNode({ selected }: any) { } - title="文案改写 · Rewrite" - subtitle="STEP 7 · 接产品信息" + title="产品文案 · Rewrite" + subtitle="STEP 5 · 接 SKG 卖点" selected={selected} >