"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTheme } from "next-themes" import { ReactFlow, Background, BackgroundVariant, Controls, useNodesState, useEdgesState, type Node, type Edge, } from "@xyflow/react" import { Toaster, toast } from "sonner" import { InputNode, VisualLabNode, AudioNode, ComposeNode, KeyframePanelNode, VideoFramePanelNode, type CanvasPanelDock, type NodeData, } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" import { AdRecreationBoard } from "@/components/ad-recreation-board" import { addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset, formatJobError, retryJobDownload, type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, } from "@/lib/api" import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target" type FlowNodeData = NodeData & Record type StudioFlowNode = Node const NODE_TYPES = { input: InputNode, visual: VisualLabNode, audio: AudioNode, compose: ComposeNode, keyframePanel: KeyframePanelNode, videoFramePanel: VideoFramePanelNode, } const KEYFRAME_PANEL_ID = "keyframe-detail-panel" const VIDEO_FRAME_PANEL_ID = "video-frame-panel" const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID]) const DIRECT_VIDEO_GENERATION_PAUSED = true const FRAME_TARGET_LABELS: Record = { random_subject: "人物随机", transparent_human: "透明骨架人", balanced: "综合关键帧", subject: "清晰主体", transition: "转场变化", expression: "表情瞬间", motion: "动作峰值", } const FRAME_QUALITY_LABELS: Record = { auto: "自动", fast: "快速", accurate: "精细", ultra: "极准", } const DEFAULT_PRODUCT_LIBRARY_IDS = [ "desktop-skg-product-angle-01", "desktop-skg-product-angle-02", "desktop-skg-product-angle-03", "desktop-skg-product-angle-04", ] const VIDEO_READY_STATUSES: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] const PRODUCT_FUSION_WEARING_PROMPT = [ "Product placement must be physically correct:", "The SKG device is a rigid opaque white U-shaped neck massager, not a soft scarf, necklace, cable, collar, sticker, implant, or transparent body part.", "It must stay OUTSIDE the transparent skin shell. It should rest on the external surface around the back of the neck and upper shoulders, like a wearable collar-shaped device.", "The curved bridge sits behind the neck. The two open arms come forward along the left and right sides of the neck, above the collarbones and near the upper trapezius.", "The inner metal massage pads face the back/side of the neck. The outer glossy plastic shell remains visible and opaque.", "Hands may hold the two ends while putting it on, then release or lightly press the side control button. Hands must not hide the device silhouette.", "The product should occlude the transparent skin where it is in front, cast a small contact shadow, and keep realistic perspective and scale.", "Keep a tiny readable separation between product edge and skeleton/skin whenever needed so it never looks embedded.", ].join("\n") const PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT = [ "Product identity is strict:", "The four SKG product reference images are real product photographs, not concept art and not style inspiration. Treat them as the immutable physical object to insert into the shot.", "The four SKG product reference images are the single source of truth for the object. Preserve the same white U-shaped body, rounded arms, inner massage pads/nodes, side buttons, seams, glossy plastic material, thickness, proportions, and viewing angles.", "Use visual compositing behavior: place the real product object onto the character externally, then match lighting, shadow, scale, and perspective around it. Do not redraw a new product from memory.", "Do not redesign, stylize, simplify, melt, inflate, shrink, recolor, add logos/text/screens/wires/extra parts, or turn it into a generic neckband/headphone/medical brace.", "If the product and character conflict, prioritize preserving the product shape and place it externally on the neck rather than merging it into the character.", ].join("\n") const PRODUCT_FUSION_NEGATIVE_PROMPT = [ "Hard negatives for product fusion:", "no product passing through the neck, no product inside the transparent body, no x-ray blending, no transparent product, no product becoming bones or skin, no product fused with spine/ribs/throat, no clipping through shoulders, no floating device, no melted device, no deformed U-shape, no wrong body part, no necklace/scarf/headphones/brace, no random replacement product.", ].join("\n") function storyboardNeedsProduct(scene: StoryboardScene) { if (scene.needs_product === false) return false if (scene.needs_product === true) return true const text = `${scene.visual_mode ?? ""} ${scene.product ?? ""} ${scene.product_placement ?? ""}`.toLowerCase() return !/(不出现产品|不露产品|无需产品|不需要产品|无产品|no product|environment|person_only)/.test(text) } function storyboardNeedsSubject(scene: StoryboardScene) { if (scene.needs_subject === false) return false if (scene.needs_subject === true) return true const text = `${scene.visual_mode ?? ""} ${scene.subject ?? ""}`.toLowerCase() return !/(不需要人物|无人物|不出现人物|no person|product_only|environment)/.test(text) } // 合并 input + download + split 为一个节点 // 分叉:上路 input → visual lab ↘ // 下路 input → audio ──────────────────────────→ 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: "visual", type: "visual", x: 460, y: 60, w: 620 }, { id: "audio", type: "audio", x: 460, y: 440, w: 320 }, { id: "compose", type: "compose", x: 1160, 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", "visual"], ["input", "audio"], ["visual", "compose"], ["audio", "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 [frameTargets, setFrameTargets] = useState>({}) const [frameCounts, setFrameCounts] = useState>({}) const [frameQualities, setFrameQualities] = useState>({}) 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" const [videoPanelJobId, setVideoPanelJobId] = useState(null) const [videoPanelScale, setVideoPanelScale] = useState(1) const [videoPanelDock, setVideoPanelDock] = useState("left") const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) const [clipboard, setClipboard] = useState(null) const [productionJobIds, setProductionJobIds] = useState>(new Set()) const [planningJobIds, setPlanningJobIds] = useState>(new Set()) const [defaultProductRefsByJob, setDefaultProductRefsByJob] = useState>({}) const autoTriggeredRef = useRef>(new Set()) const flowRef = useRef(null) const lastVideoPanelFocusKey = useRef("") 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 idx = prev.findIndex((j) => j.id === updated.id) if (idx < 0) return [...prev, updated] const arr = [...prev] arr[idx] = updated return arr }) }, []) // 新增 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) }, []) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { setSubmitting(true) try { const created = await createJob(url) addJob(created) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) return created } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) return undefined } finally { setSubmitting(false) } }, [addJob]) const handleUpload = useCallback(async (file: File) => { setSubmitting(true) try { toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) const created = await uploadJob(file) addJob(created) setProductionJobIds((prev) => new Set(prev).add(created.id)) toast.success(`已上传 ${created.id.slice(0, 8)},视频就绪后自动跑音频和抽帧`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, [addJob]) const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => { const targetJob = jobs.find((item) => item.id === jobId) if (!targetJob) return const frameTarget = frameTargets[jobId] ?? "random_subject" const frameCount = frameCounts[jobId] ?? 6 const frameQuality = frameQualities[jobId] ?? "auto" const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace") setActiveJobId(jobId) setAnalyzing(true) 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} 张`) setJobs((prev) => prev.map((item) => item.id === jobId ? { ...item, status: "splitting", message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]}…`, progress: 30, } : item)) } catch (e) { toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) } finally { setAnalyzing(false) } }, [jobs, frameCounts, frameQualities, frameTargets, clearWorkflowStateForJob]) const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => { if (!job) return await handleAnalyzeJob(job.id, options) }, [job?.id, handleAnalyzeJob]) const handleFrameTargetChange = useCallback((jobId: string, target: FrameExtractTarget) => { setFrameTargets((prev) => ({ ...prev, [jobId]: target })) }, []) const handleFrameCountChange = useCallback((jobId: string, count: number) => { setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) })) }, []) const handleFrameQualityChange = useCallback((jobId: string, quality: FrameExtractQuality) => { setFrameQualities((prev) => ({ ...prev, [jobId]: quality })) }, []) const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => { try { const updated = await addManualFrame(jobId, t) 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 await handleAddManualFrameForJob(job.id, t) }, [job?.id, handleAddManualFrameForJob]) const handleOpenVideoPanel = useCallback((jobId: string) => { setActiveJobId(jobId) if (!videoPanelJobId) setVideoPanelDock("left") setVideoPanelJobId(jobId) setVideoPanelOpenTick((n) => n + 1) }, [videoPanelJobId]) const handleVideoPanelScaleChange = useCallback((scale: number) => { setVideoPanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2))))) }, []) const handleToggleFrame = useCallback((idx: number) => { 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") 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))))) }, []) const handleDeleteFrameForJob = useCallback(async (jobId: string, idx: number) => { const wasActive = activeJobId === jobId try { const updated = await deleteFrame(jobId, idx) updateJobInList(updated) setSelectedFramesForJob(jobId, (prev) => { if (!prev.has(idx)) return prev const next = new Set(prev) next.delete(idx) return next }) 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, setSelectedFramesForJob, updateJobInList]) const handleDeleteFrame = useCallback(async (idx: number) => { if (!activeJobId) return await handleDeleteFrameForJob(activeJobId, idx) }, [activeJobId, handleDeleteFrameForJob]) const handleDeleteGenerated = useCallback(async (frameIdx: number, genId: string) => { if (!activeJobId) return try { const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId) updateJobInList(updated) toast.success("生成图已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, updateJobInList]) const handleDeleteVideo = useCallback(async (videoId: string) => { if (!activeJobId) return try { const updated = await deleteGeneratedVideo(activeJobId, videoId) updateJobInList(updated) toast.success("视频任务已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, updateJobInList]) const handleDeleteJob = useCallback(async (jobId: string) => { try { await deleteJob(jobId) setVideoPanelJobId((prev) => prev === jobId ? null : prev) setJobs((prev) => { const idx = prev.findIndex((x) => x.id === jobId) const next = prev.filter((x) => x.id !== jobId) if (activeJobId === jobId) { const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null setActiveJobId(fallback?.id ?? null) } 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))) } }, [activeJobId]) const handleDeleteCutout = useCallback(async (frameIdx: number, elementId: string, cutoutId: string) => { if (!activeJobId) return try { const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId) updateJobInList(updated) toast.success("元素提取图已删除") } catch (e) { toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, updateJobInList]) const handleCopyImage = useCallback((ref: ImageRef) => { setClipboard(ref) toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`) }, []) const handleTranscribeAudio = useCallback(async (jobId?: string, options?: { silent?: boolean }) => { const targetId = jobId ?? activeJobId if (!targetId) return const target = jobs.find((item) => item.id === targetId) if (!target) return if (!target.video_url) { if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频") return } if (target.status === "transcribing" || target.audio_script?.status === "rewriting") { if (!options?.silent) toast.info("音频正在处理中") return } try { const updated = await triggerTranscribe(targetId) updateJobInList(updated) if (!options?.silent) toast.success("已开始提取音频") } catch (e) { if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e))) } }, [activeJobId, jobs, updateJobInList]) const startProductionLanesForJob = useCallback(async (target: Job) => { const videoReady = !!target.video_url && VIDEO_READY_STATUSES.includes(target.status) if (!videoReady) return const audioKey = `${target.id}:audio` const hasAudioResult = !!target.audio_script?.source_text || target.transcript.length > 0 const audioRunning = target.status === "transcribing" || target.audio_script?.status === "rewriting" if (!hasAudioResult && !audioRunning && !autoTriggeredRef.current.has(audioKey)) { autoTriggeredRef.current.add(audioKey) try { const updated = await triggerTranscribe(target.id) updateJobInList(updated) toast.info("音频路已启动:字幕、讲话人、节奏和背景音同步解析") } catch (e) { autoTriggeredRef.current.delete(audioKey) toast.error("音频解析启动失败:" + (e instanceof Error ? e.message : String(e))) } } const visualKey = `${target.id}:visual` const hasVisualResult = target.frames.length > 0 const visualRunning = target.status === "splitting" if (!hasVisualResult && !visualRunning && !autoTriggeredRef.current.has(visualKey)) { autoTriggeredRef.current.add(visualKey) const frameTarget = frameTargets[target.id] ?? "random_subject" const frameCount = frameCounts[target.id] ?? 6 const frameQuality = frameQualities[target.id] ?? "accurate" try { const updated = await analyzeJob(target.id, frameCount, frameTarget, "replace", frameQuality) updateJobInList(updated) toast.info(`视觉路已启动:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张参考帧`) } catch (e) { autoTriggeredRef.current.delete(visualKey) toast.error("视觉抽帧启动失败:" + (e instanceof Error ? e.message : String(e))) } } }, [frameCounts, frameQualities, frameTargets, updateJobInList]) const ensureDefaultProductRefs = useCallback(async (jobId: string) => { const cached = defaultProductRefsByJob[jobId] if (cached?.length >= 4) return cached.slice(0, 4) const refs = await Promise.all(DEFAULT_PRODUCT_LIBRARY_IDS.map((id) => copyProductLibraryAsset(jobId, id))) setDefaultProductRefsByJob((prev) => ({ ...prev, [jobId]: refs })) return refs }, [defaultProductRefsByJob]) const buildPlannedScene = useCallback((targetJob: Job, frame: KeyFrame, order: number): StoryboardScene => { const frames = [...targetJob.frames].sort((a, b) => a.timestamp - b.timestamp) const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null const totalDuration = Math.max(targetJob.duration || 0, frames.length * 5, 5) const duration = Math.max(3.5, Math.min(7.5, totalDuration / Math.max(frames.length, 1))) const audioLine = targetJob.audio_script?.rewritten_text?.trim() || targetJob.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ") || "按原视频说话节奏生成 SKG 产品口播。" const sceneText = frame.description?.scene?.trim() || `参考原视频第 ${order + 1} 个关键画面,建立一个可复刻的信息流广告分镜。` const objectText = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、") return { duration: Number(duration.toFixed(1)), first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` }, last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null, subject: objectText ? `优先保留并改造这些可选关键元素:${objectText}。` : "保留原视频里最能驱动剧情的主体动作和镜头关系。", scene: `${sceneText}\n音频节奏依据:${audioLine.slice(0, 220)}`, product: "把这一镜改成 SKG 颈部/肩颈按摩仪的信息流广告表达。默认使用 SKG 四张真实产品角度图作为产品真源,产品必须外置佩戴在肩颈位置,不要变成其他物体。", action: frame.description?.style ? `沿用原画面的镜头节奏和 ${frame.description.style},动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。` : "沿用原视频的讲话/动作节奏,动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。", reference_ids: [], } }, []) const handlePlanStoryboardJob = useCallback(async (jobId: string) => { if (planningJobIds.has(jobId)) return const initial = jobs.find((item) => item.id === jobId) if (!initial || initial.frames.length === 0) return setPlanningJobIds((prev) => new Set(prev).add(jobId)) try { let latest = initial const frames = [...latest.frames].sort((a, b) => a.timestamp - b.timestamp) toast.info(`开始扫描关键元素 · ${frames.length} 个分镜`) for (let order = 0; order < frames.length; order += 1) { const frame = frames[order] let currentFrame = latest.frames.find((item) => item.index === frame.index) ?? frame if (!currentFrame.description) { latest = await describeFrame(jobId, frame.index) updateJobInList(latest) currentFrame = latest.frames.find((item) => item.index === frame.index) ?? currentFrame } if (!currentFrame.storyboard) { const planned = buildPlannedScene(latest, currentFrame, order) latest = await updateStoryboard(jobId, frame.index, planned) updateJobInList(latest) } } toast.success("关键元素扫描和分镜初稿已生成") } catch (e) { toast.error("分镜规划失败:" + (e instanceof Error ? e.message : String(e))) } finally { setPlanningJobIds((prev) => { const next = new Set(prev) next.delete(jobId) return next }) } }, [buildPlannedScene, jobs, planningJobIds, updateJobInList]) const handleStartProduction = useCallback(async (inputUrl?: string) => { const trimmed = inputUrl?.trim() const created = trimmed ? await handleSubmit(trimmed) : undefined let target = created ?? job if (!target) { toast.info("先粘贴视频链接或选择一个素材任务") return } if (!created && target.status === "failed") { autoTriggeredRef.current.delete(`${target.id}:audio`) autoTriggeredRef.current.delete(`${target.id}:visual`) } if (!created && target.status === "failed" && !target.video_url) { try { target = await retryJobDownload(target.id) updateJobInList(target) toast.info("已重新提交下载;下载完成后会自动跑音频文案路和视觉抽帧路") } catch (e) { toast.error("重新下载失败:" + (e instanceof Error ? e.message : String(e))) return } } setProductionJobIds((prev) => new Set(prev).add(target.id)) if (target.video_url) toast.success("已进入并行素材分析:音频文案路和视觉抽帧路会同步推进") else toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路") void startProductionLanesForJob(target) }, [handleSubmit, job, startProductionLanesForJob, updateJobInList]) useEffect(() => { if (productionJobIds.size === 0) return for (const item of jobs) { if (!productionJobIds.has(item.id)) continue const videoReady = !!item.video_url && VIDEO_READY_STATUSES.includes(item.status) if (!videoReady) continue void startProductionLanesForJob(item) } }, [jobs, productionJobIds, startProductionLanesForJob]) const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => { if (DIRECT_VIDEO_GENERATION_PAUSED) { toast.info("视频生成调用已暂停:先生成并审核每条分镜的首帧/尾帧,再开放单条提交") return } 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.size === 0 || 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 needsProduct = storyboardNeedsProduct(scene) const needsSubject = storyboardNeedsSubject(scene) let productRefs = needsProduct ? (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) : [] if (needsProduct && productRefs.length === 0) { try { productRefs = await ensureDefaultProductRefs(job.id) } catch (e) { toast.error("默认 SKG 产品图准备失败:" + (e instanceof Error ? e.message : String(e))) return } } const subjectRefs: ImageRef[] = needsSubject ? (frame.elements ?? []) .flatMap((element) => element.subject_assets ?? []) .slice(0, 6) .map((asset) => ({ kind: "asset", frame_idx: frameIdx, element_id: asset.id, cutout_id: asset.id, label: asset.label, })) : [] const primarySubjectRef = needsSubject ? (subjectRefs[0] ?? firstRef) : null 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 产品短视频广告。`, needsProduct ? productNature : "本条分镜规划为非产品主镜头:可以只拍人物状态、场景过渡、情绪停点或节奏承接。不要硬插 SKG 产品、白底产品图、包装或任何随机商品。", needsProduct && productRefs.length ? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。` : needsProduct ? "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。" : "本条不传产品参考图;如首尾帧里出现竞品、包装或非 SKG 商品,应弱化、移除或作为模糊背景,不要替换成 SKG 产品。", needsProduct ? "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。" : "首帧和尾帧用于控制画面起止、构图、场景和动作方向;本条没有产品任务,不要因为广告语而自动添加产品。", "使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。", "生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。", "如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。", "时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。", `镜头类型:${scene.visual_mode ?? "未标注"};需要人物=${needsSubject ? "是" : "否"};需要产品=${needsProduct ? "是" : "否"}。`, scene.first_frame_plan ? `首帧规划:${scene.first_frame_plan}` : "", scene.last_frame_plan ? `尾帧规划:${scene.last_frame_plan}` : "", scene.product_placement ? `产品出现方式:${scene.product_placement}` : "", needsSubject ? TRANSPARENT_HUMAN_VIDEO_PROMPT : "本条不传人物主体参考图;如果画面需要人物,只能作为背景、手部局部或模糊生活方式元素,不要生成主角式透明骨架人。", `主体改造:${subjectDirection}`, needsProduct ? `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。` : `产品处理:${productDirection} 本条不需要露出 SKG 产品,不要硬插产品、包装、瓶罐、医疗器械或随机商品。`, `场景改造:${sceneDirection}`, `连续动作和镜头:${actionDirection}`, `首帧:${labelOf(firstRef, "当前分镜关键帧")}`, `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, needsProduct ? `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}` : "SKG 产品参考:本条不使用产品参考图。", needsSubject ? (subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。") : "关键元素 6 视图参考:本条不使用人物主体参考图。", sourceScene, sourceStyle, sourceObjects, needsProduct ? "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。" : "", needsProduct ? "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。" : "", needsSubject || needsProduct ? "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。" : "节奏要求:作为过渡镜头时只负责情绪、空间和节奏承接,不承诺疗效,不强行展示使用动作。", needsProduct ? "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。" : "运动要求:动作幅度小而连续,速度均匀,构图从首帧自然过渡到尾帧,不突然添加人物或产品。", "商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。", "禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。", TRANSPARENT_HUMAN_NEGATIVE_PROMPT, ].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: primarySubjectRef, subject_images: subjectRefs, scene_image: null, product_image: needsProduct ? (productRefs[0] ?? null) : null, action_image: null, source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null, model, size: "720x1280", }) updateJobInList(updated) void navigator.clipboard?.writeText(prompt).catch(() => {}) toast.success("视频任务已进入候选片段") } catch (e) { toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e))) } }, [ensureDefaultProductRefs, job, selectedFrames, updateJobInList]) const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => { if (DIRECT_VIDEO_GENERATION_PAUSED) { toast.info("视频生成调用已暂停:当前只做首尾帧和素材规划") return } if (!job) return const frame = job.frames.find((f) => f.index === frameIdx) if (!frame) return const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 4) as ImageRef[] const subjectRefs = (shot.subject_images ?? []).filter(Boolean).slice(0, 7) as ImageRef[] const primarySubject = shot.subject_image ?? subjectRefs[0] ?? null if (!primarySubject || subjectRefs.length < 1 || productRefs.length < 4 || !shot.action_text?.trim()) { toast.error("产品融合镜头缺少内置角色、固定产品图或描述词") return } const duration = shot.duration && shot.duration > 0 ? shot.duration : 5 const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback try { const prompt = [ `产品融合镜头ID:${shot.id || `shot-${frameIdx + 1}`}`, `竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 参考图生视频。`, "没有首帧和尾帧:请根据内置人物角色参考图、固定 SKG 产品图、场景/使用/享受描述直接生成完整视频。", `人物角色:${shot.character_name || "透明骨架人"}。必须保持同一透明/半透明人体外壳、干净白色骨架、体型比例、服装风格和非恐怖广告气质。`, `人物参考图:${subjectRefs.map((ref, index) => `角色图${index + 1}=${labelOf(ref, "透明骨架人参考")}`).join(";")}。`, `产品角度图 1:${labelOf(productRefs[0], "SKG 产品正面/主视角")}。`, `产品角度图 2:${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}。`, `产品角度图 3:${labelOf(productRefs[2], "SKG 产品背面/细节视角")}。`, `产品角度图 4:${labelOf(productRefs[3], "SKG 产品补充/底部或佩戴视角")}。`, PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT, PRODUCT_FUSION_WEARING_PROMPT, "Camera direction: follow the scene description, but always stage the product as a separate external wearable object. Show the hands placing or adjusting the device on the outside of the neck; do not imply the product is merged with the transparent body.", `场景/使用/享受描述:${shot.action_text.trim()}`, TRANSPARENT_HUMAN_VIDEO_PROMPT, "Fusion rule: this is product placement, not body fusion. The product is an opaque physical device worn outside the body with believable contact shadow, occlusion, scale, and perspective.", "连续性:镜头必须完整连贯,中间不要跳切,不换角色,不换产品,不突然改变场景。", "场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。", "商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。", PRODUCT_FUSION_NEGATIVE_PROMPT, "禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、透明衣服但非透明身体。", TRANSPARENT_HUMAN_NEGATIVE_PROMPT, ].join("\n") const updated = await generateStoryboardVideo(job.id, frameIdx, { prompt, duration, first_image: null, last_image: null, product_images: productRefs, subject_image: null, subject_images: subjectRefs, scene_image: null, product_image: productRefs[0] ?? null, action_image: null, source_ref: null, model: "seedance", size: "720x1280", }) updateJobInList(updated) void navigator.clipboard?.writeText(prompt).catch(() => {}) toast.success("产品融合视频已进入 Video Gen 队列") } catch (e) { toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e))) } }, [job, updateJobInList]) // 启动恢复: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(() => { const url = new URL(window.location.href) if (jobs.length > 0) { url.searchParams.set("job", jobs.map((j) => j.id).join(",")) } else { url.searchParams.delete("job") } window.history.replaceState({}, "", url.toString()) }, [jobs.length]) // 恢复已保存的分镜选择:每个视频自己的 storyboard 帧仍保留在自己的编排上下文里。 useEffect(() => { if (jobs.length === 0) return setSelectedFramesByJob((prev) => { let changed = false 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 ? nextByJob : prev }) }, [jobs]) // 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。 const prevStatusRef = useRef(null) useEffect(() => { if (jobs.length === 0) return // 状态切到 downloaded 时提示用户点解析(仅一次) if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") { toast.info("视频已下载,音频解析会自动开始;也可以在右侧手动重试", { duration: 6000 }) } if (job?.status === "failed" && prevStatusRef.current !== "failed") { toast.error(formatJobError(job.error) || "任务失败", { duration: 10000 }) } prevStatusRef.current = job?.status ?? null const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] const runningIds = jobs .filter((item) => { const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress") const runningAudio = item.audio_script?.status === "rewriting" return runningVideo || runningAudio || !TERMINAL.includes(item.status) }) .map((item) => item.id) if (runningIds.length === 0) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } return } pollRef.current = setInterval(async () => { try { const latestJobs = await Promise.all(runningIds.map((id) => getJob(id).catch(() => null))) const byId = new Map(latestJobs.filter((item): item is Job => !!item).map((item) => [item.id, item])) if (byId.size > 0) { setJobs((prev) => prev.map((item) => byId.get(item.id) ?? item)) } } catch { /* silent */ } }, 1500) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [ job?.id, job?.status, 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())) 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, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, onSubmitUrl: handleSubmit, onStartProduction: handleStartProduction, onUploadFile: handleUpload, onAnalyze: handleAnalyze, onAnalyzeJob: handleAnalyzeJob, onFrameTargetChange: handleFrameTargetChange, onFrameCountChange: handleFrameCountChange, onFrameQualityChange: handleFrameQualityChange, onToggleFrame: handleToggleFrame, onExpandFrame: handleOpenFramePanel, onOpenFramePanel: handleOpenFramePanel, onFramePanelScaleChange: handleFramePanelScaleChange, onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"), onFramePanelDockChange: setFramePanelDock, onCloseExpandedFrame: handleCloseExpandedFrame, onAddManualFrame: handleAddManualFrame, onAddManualFrameForJob: handleAddManualFrameForJob, onOpenVideoPanel: handleOpenVideoPanel, onCloseVideoPanel: () => setVideoPanelJobId(null), onVideoPanelScaleChange: handleVideoPanelScaleChange, onVideoPanelDockChange: setVideoPanelDock, onSwitchJob: handleSwitchJob, onJobUpdate: updateJobInList, onDeleteJob: handleDeleteJob, onDeleteFrame: handleDeleteFrame, onDeleteFrameForJob: handleDeleteFrameForJob, onDeleteGenerated: handleDeleteGenerated, onDeleteVideo: handleDeleteVideo, onDeleteCutout: handleDeleteCutout, onOpenStoryboard: handleOpenStoryboard, onOpenWorkbench: handleOpenWorkbench, clipboard, onCopyImage: handleCopyImage, onGenerateProductFusionVideo: handleGenerateProductFusionVideo, onTranscribeAudio: handleTranscribeAudio, pinnedNodes, onToggleNodePin: handleToggleNodePin, }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleStartProduction, 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, handleTranscribeAudio, pinnedNodes, handleToggleNodePin]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const savedSizes = useMemo(() => loadNodeSizes(), []) const [nodes, setNodes] = 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 as FlowNodeData, 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) => FLOATING_PANEL_IDS.has(n.id) ? n : { ...n, draggable: !pinnedNodes.has(n.id) }, )) }, [pinnedNodes, setNodes]) // 首次挂载、所有节点都被 ReactFlow 测量到后,自动整理一次(用户偏好:每次刷新自动归位) const initialLayoutDone = useRef(false) // 自动排版:保留每个节点的当前宽高(用户为方便看而调过的尺寸),只重新计算 position // 让卡片按管线列分组、列间和列内留出统一间距,不重叠。 const handleResetLayout = useCallback(() => { const zoom = flowRef.current?.getZoom?.() ?? 1 const measure = (id: string): { w: number; h: number } => { const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null if (!el) return { w: 320, h: 220 } const r = el.getBoundingClientRect() return { w: r.width / zoom, h: r.height / zoom } } // 按管线列分组(顶 → 底):图层 1 输入 → 5 合成 const COLUMNS: string[][] = [ ["input"], ["visual", "audio"], ["compose"], ] const GAP_X = 80 const GAP_Y = 56 const START_X = 40 const START_Y = 60 const positions: Record = {} let cursorX = START_X for (const col of COLUMNS) { let cursorY = START_Y let colMaxW = 0 for (const id of col) { const { w, h } = measure(id) positions[id] = { x: cursorX, y: cursorY } cursorY += h + GAP_Y if (w > colMaxW) colMaxW = w } cursorX += colMaxW + GAP_X } setNodes((prev) => prev.map((n) => { if (FLOATING_PANEL_IDS.has(n.id)) return n const p = positions[n.id] if (!p) return n return { ...n, position: { x: p.x, y: p.y } } })) setTimeout(() => flowRef.current?.fitView?.({ padding: 0.12, duration: 400 }), 30) toast.success("已自动排版 · 保留每个节点的尺寸") }, [setNodes]) // 首次:等所有节点都被 ReactFlow 测量到(n.measured 出现)后自动排版一次,避免叠在一起 useEffect(() => { if (initialLayoutDone.current) return const main = nodes.filter((n) => !FLOATING_PANEL_IDS.has(n.id)) if (main.length === 0) return const allMeasured = main.every((n) => { const m = (n as any).measured as { width?: number; height?: number } | undefined return m && typeof m.width === "number" && typeof m.height === "number" && m.height > 0 }) if (!allMeasured) return initialLayoutDone.current = true setTimeout(() => handleResetLayout(), 80) }, [nodes, handleResetLayout]) // 持久化每个节点宽 / 高到 localStorage(KeyframePanelNode 自己管尺寸,不写回) useEffect(() => { const sizes: Record = {} for (const n of nodes) { if (FLOATING_PANEL_IDS.has(n.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 [, setEdges] = 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 as FlowNodeData }))) }, [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 visualNode = prev.find((n) => n.id === "visual") const inputNode = prev.find((n) => n.id === "input") const defaultPosition = { x: (inputNode?.position.x ?? 40) - 820, y: (visualNode?.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 as FlowNodeData, draggable: !framePanelPinned, dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", } : n, ) } shouldFocusNewPanel = true return [ ...prev, { id: KEYFRAME_PANEL_ID, type: "keyframePanel", position: defaultPosition, data: nodeData as FlowNodeData, draggable: !framePanelPinned, dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", selectable: true, }, ] }) if (shouldFocusNewPanel && !framePanelPinned) { window.setTimeout(() => { flowRef.current?.fitView?.({ nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "visual" }], padding: 0.18, duration: 260, }) }, 0) } }, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes]) // 视频抽帧面板也是独立 ReactFlow 节点:默认在 Input 附近打开,可拖动;吸附后走 portal 固定到屏幕边缘。 useEffect(() => { const panelJob = videoPanelJobId ? jobs.find((j) => j.id === videoPanelJobId) ?? null : null if (!panelJob?.video_url) { setNodes((prev) => prev.filter((n) => n.id !== VIDEO_FRAME_PANEL_ID)) return } const focusKey = `${videoPanelJobId}:${videoPanelOpenTick}:${videoPanelDock}` let panelWasCreated = false setNodes((prev) => { const inputNode = prev.find((n) => n.id === "input") const defaultPosition = { x: inputNode?.position.x ?? 40, y: (inputNode?.position.y ?? 240) - 650, } const exists = prev.some((n) => n.id === VIDEO_FRAME_PANEL_ID) if (exists) { return prev.map((n) => n.id === VIDEO_FRAME_PANEL_ID ? { ...n, data: nodeData as FlowNodeData, draggable: videoPanelDock === "canvas", dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined, } : n, ) } panelWasCreated = true return [ ...prev, { id: VIDEO_FRAME_PANEL_ID, type: "videoFramePanel", position: defaultPosition, data: nodeData as FlowNodeData, draggable: videoPanelDock === "canvas", dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined, selectable: true, }, ] }) if (videoPanelDock === "canvas" && (panelWasCreated || lastVideoPanelFocusKey.current !== focusKey)) { lastVideoPanelFocusKey.current = focusKey window.setTimeout(() => { flowRef.current?.fitView?.({ nodes: [{ id: VIDEO_FRAME_PANEL_ID }, { id: "input" }], padding: 0.18, duration: 260, }) }, 0) } }, [videoPanelJobId, videoPanelOpenTick, videoPanelDock, jobs, nodeData, setNodes]) // 边的 animated 状态跟 Job 进度联动 useEffect(() => { const doneOf: Record = { input: !!job?.video_url, visual: !!job && (job.frames.length > 0 || (job.generated_videos?.length ?? 0) > 0), asr: !!job && job.transcript.length > 0, translate: !!job && (job.transcript.some((s) => s.zh) ?? false), rewrite: !!job && !!job.audio_script?.rewritten_text, } setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] }))) }, [job, setEdges]) return ( <>
) }