"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTheme } from "next-themes" import { ReactFlow, Background, BackgroundVariant, Controls, MiniMap, useNodesState, useEdgesState, type Node, type Edge, } from "@xyflow/react" import { Toaster, toast } from "sonner" import { InputNode, KeyframeNode, ASRNode, TranslateNode, RewriteNode, StoryboardNode, VideoGenNode, ComposeNode, KeyframePanelNode, type NodeData, } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" import { StoryboardBar } from "@/components/storyboard-bar" import { StoryboardWorkbench } from "@/components/storyboard-workbench" import { addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteFrame, deleteGeneratedImage, deleteGeneratedVideo, generateStoryboardVideo, type Job, type ImageRef, type StoryboardScene, } from "@/lib/api" import { VideoLightbox } from "@/components/video-lightbox" const NODE_TYPES = { input: InputNode, keyframe: KeyframeNode, asr: ASRNode, translate: TranslateNode, rewrite: RewriteNode, storyboard: StoryboardNode, videogen: VideoGenNode, compose: ComposeNode, keyframePanel: KeyframePanelNode, } const KEYFRAME_PANEL_ID = "keyframe-detail-panel" // 合并 input + download + split 为一个节点 // 分叉:上路 input → keyframe → storyboard → videogen ↘ // 下路 input → asr → translate → rewrite ──────→ storyboard / compose const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number; w: number }> = [ { id: "input", type: "input", x: 40, y: 240, w: 320 }, { id: "keyframe", type: "keyframe", x: 460, y: 60, w: 360 }, { id: "asr", type: "asr", x: 460, y: 440, w: 320 }, { id: "translate", type: "translate", x: 840, y: 440, w: 320 }, { id: "storyboard", type: "storyboard", x: 880, y: 60, w: 360 }, { id: "rewrite", type: "rewrite", x: 1220, y: 440, w: 320 }, { id: "videogen", type: "videogen", x: 1260, y: 60, w: 280 }, { id: "compose", type: "compose", x: 1640, y: 240, w: 320 }, ] const NODE_SIZES_KEY = "skg-tk:node-sizes:v2" type NodeSize = { w?: number; h?: number } function loadNodeSizes(): Record { if (typeof window === "undefined") return {} try { const raw = window.localStorage.getItem(NODE_SIZES_KEY) return raw ? JSON.parse(raw) : {} } catch { return {} } } const NODE_PINS_KEY = "skg-tk:node-pins:v1" function loadNodePins(): string[] { if (typeof window === "undefined") return [] try { const raw = window.localStorage.getItem(NODE_PINS_KEY) return raw ? JSON.parse(raw) : [] } catch { return [] } } const EDGES_RAW: Array<[string, string]> = [ ["input", "keyframe"], ["input", "asr"], ["asr", "translate"], ["translate", "rewrite"], ["keyframe", "storyboard"], ["rewrite", "storyboard"], ["storyboard", "videogen"], ["videogen", "compose"], ["rewrite", "compose"], ] export default function Home() { const { resolvedTheme } = useTheme() const [jobs, setJobs] = useState([]) const [activeJobId, setActiveJobId] = useState(null) const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId]) const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) const [framePanelScale, setFramePanelScale] = useState(1) const [framePanelPinned, setFramePanelPinned] = useState(false) const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) const [storyboardFrame, setStoryboardFrame] = useState(null) const [workbenchOpen, setWorkbenchOpen] = useState(false) const [clipboard, setClipboard] = useState(null) const flowRef = useRef(null) // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => { 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 arr = [...prev] arr[idx] = next return arr }) }, [activeJobId]) // 新增 job + 设为 active const addJob = useCallback((j: Job) => { setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j]) setActiveJobId(j.id) }, []) 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) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, [addJob]) 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) addJob(created) toast.success(`已上传 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, [addJob]) const handleAnalyze = useCallback(async () => { if (!job) return setAnalyzing(true) setSelectedFrames(new Set()) try { await analyzeJob(job.id, 5) toast.info("开始解析:拆轨 → 抽帧。声音文案轨单独处理") // 乐观更新本地状态,让轮询 useEffect 重新启动 setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev) } catch (e) { toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) } finally { setAnalyzing(false) } }, [job?.id]) const handleAddManualFrame = useCallback(async (t: number) => { if (!job) return try { const updated = await addManualFrame(job.id, t) setJob(updated) toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`) } catch (e) { toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e))) } }, [job?.id]) const handleToggleFrame = useCallback((idx: number) => { setSelectedFrames((prev) => { const next = new Set(prev) if (next.has(idx)) next.delete(idx) else next.add(idx) return next }) }, []) const handleOpenFramePanel = useCallback((idx: number) => { setExpandedFrame(idx) }, []) const handleFramePanelScaleChange = useCallback((scale: number) => { setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2))))) }, []) const handleDeleteFrame = useCallback(async (idx: number) => { if (!activeJobId) return try { const updated = await deleteFrame(activeJobId, idx) setJob(updated) setSelectedFrames((prev) => { if (!prev.has(idx)) return prev const next = new Set(prev) next.delete(idx) return next }) if (expandedFrame === idx) setExpandedFrame(null) toast.success(`分镜 ${idx + 1} 已删除`) } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, expandedFrame, setJob]) const handleDeleteGenerated = useCallback(async (frameIdx: number, genId: string) => { if (!activeJobId) return try { const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId) setJob(updated) toast.success("生成图已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, setJob]) const handleDeleteVideo = useCallback(async (videoId: string) => { if (!activeJobId) return try { const updated = await deleteGeneratedVideo(activeJobId, videoId) setJob(updated) toast.success("视频任务已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, setJob]) const handleCopyImage = useCallback((ref: ImageRef) => { setClipboard(ref) toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`) }, []) const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => { if (!job) return const frame = job.frames.find((f) => f.index === frameIdx) if (!frame) return const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback const keyframeRef: ImageRef = { kind: "keyframe", frame_idx: frameIdx, label: `分镜 ${frameIdx + 1} 首帧`, } const orderedSelected = job.frames .filter((f) => selectedFrames.has(f.index)) .sort((a, b) => a.timestamp - b.timestamp) const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null const defaultLastRef: ImageRef | null = nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` } : null const firstRef = scene.first_image ?? keyframeRef const lastRef = scene.last_image ?? defaultLastRef const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) const duration = scene.duration && scene.duration > 0 ? scene.duration : 5 const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : "" const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : "" const sourceObjects = frame.description?.objects?.length ? `参考元素:${frame.description.objects.slice(0, 6).map((o) => o.name).join("、")}` : "" const subjectDirection = scene.subject?.trim() || "保留首尾帧里的主体位置、手部动作关系和镜头调度;如果参考主体是人形骷髅、透明骨骼人或卡通骨骼角色,可以保留为视觉隐喻,让它正确佩戴 SKG 颈部按摩仪后状态变好。不要做恐怖、血腥或严肃医疗治疗画面。" const productDirection = scene.product?.trim() || "以已上传 SKG 产品图为唯一产品真源,把参考视频或首尾帧里的任何非 SKG 产品替换成该产品;产品轮廓、颜色、材质、按键/接口/包装比例必须稳定,不要变成其他物体。" const sceneDirection = scene.scene?.trim() || "借鉴参考画面的构图、可信感和空间层次,但改造成适合 SKG 产品广告的现代家居、办公或零售场景。" const actionDirection = scene.action?.trim() || "按首帧到尾帧做平滑过渡,动作连续自然,镜头运动稳定,最后准确停在尾帧意图。" const productNature = [ "产品性质:这是 SKG 白色 U 形可穿戴颈部/肩颈按摩仪,不是药品、护肤品、饮料、瓶罐、医疗器械镜头道具或普通项链。", "正确使用方式:产品应佩戴/环绕在人的脖子和肩颈位置,U 形机身落在肩颈两侧,内侧金属按摩触点贴合后颈或肩颈肌肉区域。", "可表现的交互:手拿起产品、展开并戴到脖子上、轻按侧边圆形按键/控制区、轻微调整贴合位置、闭眼放松、肩颈从紧绷变舒展。", "效果表达:使用后状态变好,表现为颈肩放松、姿态更自然、表情舒缓、精神恢复;如果主体是人形骷髅,可以通过放下揉脖子的手、抬头、肩颈舒展、表情/动作变轻松来表现改善。不要表现治愈疾病、骨骼修复、皮肤祛痘或夸张医疗效果。", ].join("\n") const prompt = [ `竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`, productNature, productRefs.length ? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。` : "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。", "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。", "使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。", "生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。", "如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。", "时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。", `主体改造:${subjectDirection}`, `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`, `场景改造:${sceneDirection}`, `连续动作和镜头:${actionDirection}`, `首帧:${labelOf(firstRef, "当前分镜关键帧")}`, `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`, sourceScene, sourceStyle, sourceObjects, "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。", "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。", "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。", "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。", "商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。", "禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。", ].join("\n") try { toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`) const sourceUrl = job.url?.trim() const updated = await generateStoryboardVideo(job.id, frameIdx, { prompt, duration, first_image: firstRef, last_image: lastRef, product_images: productRefs, subject_image: firstRef, scene_image: null, product_image: productRefs[0] ?? null, action_image: null, source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null, model, size: "720x1280", }) setJob(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]) // 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾) useEffect(() => { const params = new URLSearchParams(window.location.search) const ids = (params.get("job") ?? "").split(",").filter(Boolean) const restore = async () => { let targetIds = ids if (targetIds.length === 0) { try { const list = await listJobs() targetIds = list.map((s) => s.id).reverse() } catch { return } } if (targetIds.length === 0) return const results = await Promise.all(targetIds.map((id) => getJob(id).catch(() => null))) const valid = results.filter((j): j is Job => !!j) if (valid.length > 0) { setJobs(valid) setActiveJobId(valid[valid.length - 1].id) } } void restore() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 写回 URL(所有 jobs id 用 , 分隔) useEffect(() => { if (jobs.length === 0) return const url = new URL(window.location.href) url.searchParams.set("job", jobs.map((j) => j.id).join(",")) 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(() => { if (!job) return // 状态切到 downloaded 时提示用户点解析(仅一次) if (job.status === "downloaded" && prevStatusRef.current !== "downloaded") { toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 }) } prevStatusRef.current = job.status const runningVideo = !!job.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress") const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] if (TERMINAL.includes(job.status) && !runningVideo) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } return } pollRef.current = setInterval(async () => { try { const latest = await getJob(job.id) setJob(latest) } catch { /* silent */ } }, 1500) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [job?.id, job?.status, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")]) const [pinnedNodes, setPinnedNodes] = useState>(() => new Set(loadNodePins())) const handleToggleNodePin = useCallback((id: string) => { setPinnedNodes((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) try { window.localStorage.setItem(NODE_PINS_KEY, JSON.stringify([...next])) } catch {} return next }) }, []) const nodeData: NodeData = useMemo(() => ({ job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, onSubmitUrl: handleSubmit, onUploadFile: handleUpload, onAnalyze: handleAnalyze, onToggleFrame: handleToggleFrame, onExpandFrame: setExpandedFrame, onOpenFramePanel: handleOpenFramePanel, onFramePanelScaleChange: handleFramePanelScaleChange, onFramePanelPinnedChange: setFramePanelPinned, onCloseExpandedFrame: () => setExpandedFrame(null), onAddManualFrame: handleAddManualFrame, onOpenVideoLightbox: () => setVideoLightboxOpen(true), onSwitchJob: handleSwitchJob, onJobUpdate: setJob as any, onDeleteFrame: handleDeleteFrame, onDeleteGenerated: handleDeleteGenerated, onDeleteVideo: handleDeleteVideo, onOpenStoryboard: (idx: number) => setStoryboardFrame(idx), onOpenWorkbench: (idx?: number) => { if (typeof idx === "number") setStoryboardFrame(idx) setWorkbenchOpen(true) }, onCopyImage: handleCopyImage, pinnedNodes, onToggleNodePin: handleToggleNodePin, }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleCopyImage, pinnedNodes, handleToggleNodePin]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const savedSizes = useMemo(() => loadNodeSizes(), []) const [nodes, setNodes, onNodesChange] = useNodesState( LAYOUT.map((n) => { const s = savedSizes[n.id] ?? {} const w = s.w ?? n.w const h = s.h const isPinned = pinnedNodes.has(n.id) return { id: n.id, type: n.type, position: { x: n.x, y: n.y }, data: nodeData, draggable: !isPinned, width: w, ...(typeof h === "number" ? { height: h } : {}), style: { width: w, ...(typeof h === "number" ? { height: h } : {}) }, } }), ) // pinned 变化时同步每个节点 draggable useEffect(() => { setNodes((prev) => prev.map((n) => n.id === KEYFRAME_PANEL_ID ? n : { ...n, draggable: !pinnedNodes.has(n.id) }, )) }, [pinnedNodes, setNodes]) // 持久化每个节点宽 / 高到 localStorage(KeyframePanelNode 自己管尺寸,不写回) useEffect(() => { const sizes: Record = {} for (const n of nodes) { if (n.id === KEYFRAME_PANEL_ID) continue const w = typeof n.width === "number" ? Math.round(n.width) : undefined const h = typeof n.height === "number" ? Math.round(n.height) : undefined if (w === undefined && h === undefined) continue sizes[n.id] = { ...(w !== undefined ? { w } : {}), ...(h !== undefined ? { h } : {}) } } try { window.localStorage.setItem(NODE_SIZES_KEY, JSON.stringify(sizes)) } catch {} }, [nodes]) const [edges, setEdges, onEdgesChange] = useEdgesState( EDGES_RAW.map(([from, to], i) => ({ id: `e${i}`, source: from, target: to, animated: false, type: "default", })), ) // Job 数据变化时只更新节点 data 不动 position useEffect(() => { setNodes((prev) => prev.map((n) => ({ ...n, data: nodeData }))) }, [nodeData, setNodes]) // 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。 // 已打开时点击其他关键帧只切换内容,不移动用户拖好的面板位置。 useEffect(() => { if (!job || expandedFrame === null) { setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID)) return } let shouldFocusNewPanel = false setNodes((prev) => { const keyframeNode = prev.find((n) => n.id === "keyframe") const inputNode = prev.find((n) => n.id === "input") const defaultPosition = { x: (inputNode?.position.x ?? 40) - 820, y: (keyframeNode?.position.y ?? 60), } const exists = prev.some((n) => n.id === KEYFRAME_PANEL_ID) if (exists) { return prev.map((n) => n.id === KEYFRAME_PANEL_ID ? { ...n, data: nodeData, draggable: !framePanelPinned, dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", } : n, ) } shouldFocusNewPanel = true return [ ...prev, { id: KEYFRAME_PANEL_ID, type: "keyframePanel", position: defaultPosition, data: nodeData, draggable: !framePanelPinned, dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", selectable: true, }, ] }) if (shouldFocusNewPanel) { window.setTimeout(() => { flowRef.current?.fitView?.({ nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "keyframe" }], padding: 0.18, duration: 260, }) }, 0) } }, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes]) // 边的 animated 状态跟 Job 进度联动 useEffect(() => { const doneOf: Record = { input: !!job?.video_url, 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]) return ( <>
{/* 主题切换 — 左下角 Controls 上方(错开) */}
{/* 右区:顶部 storyboard bar + DAG 节点流图 */}
{ if (typeof idx === "number") setStoryboardFrame(idx) setWorkbenchOpen(true) }} onCloseWorkbench={() => setWorkbenchOpen(false)} /> setWorkbenchOpen(false)} onJobUpdate={setJob as any} clipboard={clipboard} focusedFrame={storyboardFrame} onGenerateVideo={handleQuickGenerateVideo} />
{ flowRef.current = instance }} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} nodeTypes={NODE_TYPES} colorMode={resolvedTheme === "light" ? "light" : "dark"} fitView fitViewOptions={{ padding: 0.12 }} minZoom={0.2} maxZoom={1.5} proOptions={{ hideAttribution: true }} >
{/* Video lightbox — InputNode 缩略图点击进入 */} setVideoLightboxOpen(false)} onAddFrame={handleAddManualFrame} />
) }