"use client" import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, } from "react" import { createPortal } from "react-dom" import { type NodeProps, useReactFlow } from "@xyflow/react" import { Link2, Upload, Download, Scissors, Image as ImageIcon, Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Maximize2, Copy, Trash2, Move, PanelLeft, PanelRight, PanelBottom, ChevronLeft, ChevronRight, SlidersHorizontal, CheckCircle2, AlertTriangle, Sparkles, Package, PlayCircle, RotateCcw, } from "lucide-react" import { toast } from "sonner" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { HoverPreview } from "./hover-preview" import { type Job, type ImageRef, type ProductFusionShot, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, } from "@/lib/api" import { FrameLightbox } from "@/components/lightbox" export type CanvasPanelDock = "canvas" | "left" | "right" | "bottom" export interface NodeData { job: Job | null // 当前 active job jobs: Job[] // 所有 job 列表 activeJobId: string | null submitting: boolean analyzing: boolean frameTargets: Record frameCounts: Record frameQualities: Record selectedFrames: Set expandedFrame: number | null framePanelScale?: number framePanelPinned?: boolean framePanelDock?: CanvasPanelDock videoPanelJobId?: string | null videoPanelScale?: number videoPanelDock?: CanvasPanelDock onSubmitUrl: (url: string) => Promise | Job | void onStartProduction?: (url?: string) => Promise | void onUploadFile: (file: File) => void onAnalyze: (options?: { mode?: FrameExtractMode }) => void onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void onFrameCountChange: (jobId: string, count: number) => void onFrameQualityChange: (jobId: string, quality: FrameExtractQuality) => void onToggleFrame: (idx: number) => void onExpandFrame: (idx: number) => void onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板 onFramePanelScaleChange?: (scale: number) => void onFramePanelPinnedChange?: (pinned: boolean) => void onFramePanelDockChange?: (dock: CanvasPanelDock) => void onCloseExpandedFrame: () => void onAddManualFrame: (t: number) => void onAddManualFrameForJob?: (jobId: string, t: number) => Promise | Job | void onOpenVideoPanel?: (jobId: string) => void onCloseVideoPanel?: () => void onVideoPanelScaleChange?: (scale: number) => void onVideoPanelDockChange?: (dock: CanvasPanelDock) => void onSwitchJob: (id: string) => void onJobUpdate: (j: Job) => void onDeleteJob?: (id: string) => void onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开 onDeleteFrame?: (idx: number) => void // 删整张关键帧 onDeleteFrameForJob?: (jobId: string, idx: number) => Promise | void onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图 onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务 onDeleteCutout?: (frameIdx: number, elementId: string, cutoutId: string) => void // 删元素提取图 onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板 onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板 clipboard?: ImageRef | null onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽) onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise | void onTranscribeAudio?: (jobId?: string) => Promise | void onOpenAudioStrip?: (jobId?: string) => void pinnedNodes?: Set // 已钉住的节点 id 集合 — 钉住后位置 + 尺寸锁定 onToggleNodePin?: (id: string) => void } /* ---- 状态映射工具 ---- */ function inputStatus(job: Job | null): NodeStatus { if (!job) return "pending" return "done" } function downloadStatus(job: Job | null): NodeStatus { if (!job) return "pending" if (job.status === "failed" && job.progress < 30) return "failed" if (job.status === "downloading") return "running" if (job.video_url) return "done" return "pending" } function splitStatus(job: Job | null): NodeStatus { if (!job || !job.video_url) return "pending" if (job.status === "failed" && job.progress >= 20 && job.progress < 50) return "failed" if (job.status === "splitting") return "running" if (["frames_extracted", "transcribing", "transcribed"].includes(job.status)) return "done" return "pending" } function keyframeStatus(job: Job | null): NodeStatus { if (!job) return "pending" if (job.status === "failed" && job.progress >= 50 && job.progress < 70) return "failed" if (job.frames.length === 0 && job.status === "splitting") return "running" if (job.frames.length > 0) return "done" return "pending" } function asrStatus(job: Job | null): NodeStatus { if (!job) return "pending" if (job.status === "transcribing") return "running" if (job.transcript.length > 0) return "done" if (job.status === "failed" && job.progress >= 70) return "failed" return "pending" } type PreviewAnchor = { id: T; x: number; y: number } type ScrollRailState = { visible: boolean leftPct: number widthPct: number now: number max: number } function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)) } const THUMBNAIL_HEIGHT = 192 const FLOATING_PANEL_EDGE_INSET = 8 const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [ { value: "transparent_human", label: "透明骨架人", hint: "本地算力筛清晰主体,不逐帧调用 Vision" }, { value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" }, { value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" }, { value: "transition", label: "转场变化", hint: "切镜和画面变化优先" }, { value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" }, { value: "motion", label: "动作峰值", hint: "动作变化更明显" }, ] const FRAME_COUNT_OPTIONS = [12, 8, 5, 3] const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [ { value: "auto", label: "自动", hint: "展示友好:按电脑性能选择,最高只到精细" }, { value: "fast", label: "快速", hint: "2fps / 360px,长视频省电" }, { value: "accurate", label: "精细", hint: "8fps / 720px,M2 Max 轻松可用" }, { value: "ultra", label: "极准", hint: "12fps / 960px,本机约 3 秒扫描 1 分钟视频" }, ] function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) { if (!root) return { x: 160, y: 0 } const rootRect = root.getBoundingClientRect() const targetRect = target.getBoundingClientRect() if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 } const xRatio = (targetRect.left + targetRect.width / 2 - rootRect.left) / rootRect.width const yRatio = (targetRect.top - rootRect.top) / rootRect.height return { x: xRatio * root.clientWidth, y: yRatio * root.clientHeight, } } function ThumbnailScrollRail({ scrollRef, label = "缩略图横向滑动条", }: { scrollRef: RefObject label?: string }) { const railRef = useRef(null) const dragRef = useRef<{ pointerId: number startX: number startScrollLeft: number maxScroll: number trackRange: number } | null>(null) const [dragging, setDragging] = useState(false) const [rail, setRail] = useState({ visible: false, leftPct: 0, widthPct: 100, now: 0, max: 0, }) useEffect(() => { const el = scrollRef.current if (!el) return const update = () => { const max = Math.max(0, el.scrollWidth - el.clientWidth) const visible = max > 2 const widthPct = visible ? Math.max(26, Math.min(92, (el.clientWidth / Math.max(el.scrollWidth, 1)) * 100)) : 100 const leftPct = visible ? (el.scrollLeft / max) * (100 - widthPct) : 0 const next = { visible, leftPct: Number(leftPct.toFixed(2)), widthPct: Number(widthPct.toFixed(2)), now: Math.round(el.scrollLeft), max: Math.round(max), } setRail((prev) => ( prev.visible === next.visible && Math.abs(prev.leftPct - next.leftPct) < 0.1 && Math.abs(prev.widthPct - next.widthPct) < 0.1 && prev.now === next.now && prev.max === next.max ) ? prev : next) } const raf = window.requestAnimationFrame(update) el.addEventListener("scroll", update, { passive: true }) const resizeObserver = new ResizeObserver(update) resizeObserver.observe(el) const mutationObserver = new MutationObserver(update) mutationObserver.observe(el, { childList: true, subtree: true, attributes: true }) return () => { window.cancelAnimationFrame(raf) el.removeEventListener("scroll", update) resizeObserver.disconnect() mutationObserver.disconnect() } }, [scrollRef]) if (!rail.visible) return null const syncScrollFromPointer = (e: ReactPointerEvent) => { const drag = dragRef.current const el = scrollRef.current if (!drag || !el) return const delta = e.clientX - drag.startX el.scrollLeft = clamp( drag.startScrollLeft + (delta / drag.trackRange) * drag.maxScroll, 0, drag.maxScroll, ) } return (
{ const el = scrollRef.current const track = railRef.current if (!el || !track) return const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth) if (maxScroll <= 0) return e.preventDefault() e.stopPropagation() const rect = track.getBoundingClientRect() const thumbWidth = rect.width * (rail.widthPct / 100) const trackRange = Math.max(1, rect.width - thumbWidth) const pointerX = e.clientX - rect.left const thumbLeft = (el.scrollLeft / maxScroll) * trackRange let startScrollLeft = el.scrollLeft if (pointerX < thumbLeft || pointerX > thumbLeft + thumbWidth) { startScrollLeft = clamp(((pointerX - thumbWidth / 2) / trackRange) * maxScroll, 0, maxScroll) el.scrollLeft = startScrollLeft } dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startScrollLeft, maxScroll, trackRange, } setDragging(true) e.currentTarget.setPointerCapture(e.pointerId) }} onPointerMove={(e) => { if (dragRef.current?.pointerId !== e.pointerId) return e.preventDefault() e.stopPropagation() syncScrollFromPointer(e) }} onPointerUp={(e) => { if (dragRef.current?.pointerId !== e.pointerId) return e.preventDefault() e.stopPropagation() dragRef.current = null setDragging(false) e.currentTarget.releasePointerCapture(e.pointerId) }} onPointerCancel={(e) => { if (dragRef.current?.pointerId !== e.pointerId) return dragRef.current = null setDragging(false) }} onKeyDown={(e) => { const el = scrollRef.current if (!el) return const page = Math.max(80, el.clientWidth * 0.65) const small = Math.max(32, el.clientWidth * 0.18) let next: number | null = null if (e.key === "ArrowLeft") next = el.scrollLeft - small if (e.key === "ArrowRight") next = el.scrollLeft + small if (e.key === "PageUp") next = el.scrollLeft - page if (e.key === "PageDown") next = el.scrollLeft + page if (e.key === "Home") next = 0 if (e.key === "End") next = rail.max if (next === null) return e.preventDefault() e.stopPropagation() el.scrollTo({ left: clamp(next, 0, rail.max), behavior: "smooth" }) }} >
) } function FloatingThumbnailStrip({ children, label, toolbar, }: { children: ReactNode label?: string toolbar?: ReactNode }) { const scrollRef = useRef(null) return (
{toolbar &&
{toolbar}
}
{children}
) } function DeleteConfirmDialog({ title, description, confirmLabel = "删除", onCancel, onConfirm, }: { title: string description: string confirmLabel?: string onCancel: () => void onConfirm: () => void }) { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel() } document.addEventListener("keydown", onKeyDown) return () => document.removeEventListener("keydown", onKeyDown) }, [onCancel]) if (typeof document === "undefined") return null return createPortal(
{ e.stopPropagation() if (e.target === e.currentTarget) onCancel() }} >
e.stopPropagation()} >
{title}

{description}

, document.body, ) } function FrameExtractQuickBar({ target, count, quality, disabled, running, hasFrames, onTargetChange, onCountChange, onQualityChange, onAnalyze, }: { target: FrameExtractTarget count: number quality: FrameExtractQuality disabled: boolean running: boolean hasFrames: boolean onTargetChange: (target: FrameExtractTarget) => void onCountChange: (count: number) => void onQualityChange: (quality: FrameExtractQuality) => void onAnalyze: () => void }) { const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0] const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[0] const [settingsOpen, setSettingsOpen] = useState(false) return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
{settingsOpen && (
)}
) } /* ============================================================ 1. InputNode — TK 链接 / 上传 ============================================================ */ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) { const d: NodeData = data const [url, setUrl] = useState("") const [hoverPreviewJob, setHoverPreviewJob] = useState | null>(null) const [pinnedPreviewJob, setPinnedPreviewJob] = useState | null>(null) const [deleteJobTarget, setDeleteJobTarget] = useState(null) const rootRef = useRef(null) const fileRef = useRef(null) const job = d.job // 点击 input 节点外的任何位置 → 取消 pin(capture 阶段,避免 ReactFlow pane 拦截) useEffect(() => { if (pinnedPreviewJob === null) return const handler = (e: MouseEvent) => { const t = e.target as HTMLElement if (t.closest('.react-flow__node[data-id="input"]')) return setPinnedPreviewJob(null) } document.addEventListener("mousedown", handler, true) return () => document.removeEventListener("mousedown", handler, true) }, [pinnedPreviewJob]) // 是否已下载 → 显示视频 + 解析按钮 const hasVideo = !!job?.video_url const isDownloading = job?.status === "downloading" || job?.status === "created" const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status) const hasFrames = (job?.frames.length ?? 0) > 0 const inputLocked = isDownloading || d.submitting return (
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),每个视频上方绑定独立抽帧快捷条。 浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */} {d.jobs.length > 0 && ( {/* + 再上传一个(放在最前面) */} {[...d.jobs].reverse().map((j) => { const isActive = j.id === d.activeJobId const ready = !!j.video_url const aspectStr = ready ? `${j.width}/${j.height}` : "9/16" const thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96 const toolWidth = Math.max(148, thumbNaturalWidth) const target = d.frameTargets[j.id] ?? "transparent_human" const count = d.frameCounts[j.id] ?? 12 const quality = d.frameQualities[j.id] ?? "auto" const jHasFrames = j.frames.length > 0 const jRunning = ["splitting", "transcribing"].includes(j.status) return (
{ready ? ( d.onFrameTargetChange(j.id, next)} onCountChange={(next) => d.onFrameCountChange(j.id, next)} onQualityChange={(next) => d.onFrameQualityChange(j.id, next)} onAnalyze={() => d.onAnalyzeJob(j.id, { mode: jHasFrames ? "append" : "replace" })} /> ) : (
)}
setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} onMouseLeave={() => setHoverPreviewJob(null)} > {d.onDeleteJob && ( )}
) })} )} {deleteJobTarget && ( setDeleteJobTarget(null)} onConfirm={() => { const id = deleteJobTarget.id setDeleteJobTarget(null) d.onDeleteJob?.(id) }} /> )} {(() => { const anchor = pinnedPreviewJob ?? hoverPreviewJob if (!anchor) return null const previewJob = d.jobs.find((j) => j.id === anchor.id) if (!previewJob?.video_url) return null const aspectStr = previewJob.height ? `${previewJob.width}/${previewJob.height}` : "9/16" return ( setPinnedPreviewJob(null)} /> ) })()} } title="输入 · Input" subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"} selected={selected} hasTarget={false} pinned={d.pinnedNodes?.has("input")} onTogglePin={() => d.onToggleNodePin?.("input")} > {/* URL + 上传入口 — 一直显示(即使已有视频,也可以继续加新的) */} <> setUrl(e.target.value)} placeholder={hasVideo ? "再加一个 TK 链接" : "粘贴 TikTok 链接"} disabled={inputLocked} className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/60 dark:bg-black/40 border border-black/10 dark:border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40" />
{ const f = e.target.files?.[0] if (f) d.onUploadFile(f) e.target.value = "" }} />
{/* 已下载:仅元数据(视频缩略图浮在节点上方,点击进 lightbox) */} {hasVideo && job && ( <>
{job.width}×{job.height} · {job.duration.toFixed(1)}s {job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}
{hasFrames && (
已抽 {job.frames.length} 张 · 上方可继续追加
)} )}
) } /* ============================================================ 1b. VideoFramePanelNode — 画布内视频抽帧工作面板 ============================================================ */ export function VideoFramePanelNode({ data }: any) { const d: NodeData = data const { getZoom } = useReactFlow() const panelJob = d.videoPanelJobId ? d.jobs.find((j) => j.id === d.videoPanelJobId) ?? null : null const videoRef = useRef(null) const scale = d.videoPanelScale ?? 1 const dock = d.videoPanelDock ?? "canvas" const docked = dock !== "canvas" const [currentT, setCurrentT] = useState(0) const [adding, setAdding] = useState(false) const [deletingFrame, setDeletingFrame] = useState(null) useEffect(() => { setCurrentT(0) setAdding(false) setDeletingFrame(null) }, [panelJob?.id]) useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") d.onCloseVideoPanel?.() } window.addEventListener("keydown", onKeyDown) return () => window.removeEventListener("keydown", onKeyDown) }, [d]) if (!panelJob?.video_url) return null const panelWidth = Math.round(760 * scale) const panelHeight = Math.round(620 * scale) const bodyHeight = Math.max(500, panelHeight - 28) const duration = panelJob.duration ?? 0 const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp) const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16" const panelTarget = d.frameTargets[panelJob.id] ?? "transparent_human" const panelCount = d.frameCounts[panelJob.id] ?? 12 const panelQuality = d.frameQualities[panelJob.id] ?? "auto" const panelRunning = ["splitting", "transcribing"].includes(panelJob.status) const dockText: Record = { canvas: "画布模式", left: "吸附左侧", right: "吸附右侧", bottom: "吸附底部", } const setScale = (next: number) => { const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2)))) d.onVideoPanelScaleChange?.(clamped) } const startResize = (e: ReactPointerEvent) => { e.preventDefault() e.stopPropagation() const startX = e.clientX const startY = e.clientY const startScale = scale const zoom = docked ? 1 : getZoom() const onMove = (ev: PointerEvent) => { const dx = (ev.clientX - startX) / zoom const dy = (ev.clientY - startY) / zoom const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 620 setScale(startScale + delta) } const onUp = () => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) } window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) } const seekTo = (next: number) => { const t = clamp(next, 0, Math.max(duration, 0)) setCurrentT(t) if (videoRef.current) videoRef.current.currentTime = t } const addCurrentFrame = async () => { const t = videoRef.current?.currentTime ?? currentT setAdding(true) try { if (d.onAddManualFrameForJob) await d.onAddManualFrameForJob(panelJob.id, t) else await d.onAddManualFrame(t) } finally { setAdding(false) } } const deleteFrameFromPanel = async (idx: number) => { setDeletingFrame(idx) try { if (d.onDeleteFrameForJob) await d.onDeleteFrameForJob(panelJob.id, idx) else await d.onDeleteFrame?.(idx) } finally { setDeletingFrame(null) } } const dockButtonClass = (value: CanvasPanelDock) => `nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${ dock === value ? "bg-white text-violet-700 shadow" : "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white" }` const panel = (
视频抽帧 · Input {panelJob.width}×{panelJob.height} · {duration.toFixed(1)}s
{dockText[dock]}
e.stopPropagation()}>
0} onTargetChange={(next) => d.onFrameTargetChange(panelJob.id, next)} onCountChange={(next) => d.onFrameCountChange(panelJob.id, next)} onQualityChange={(next) => d.onFrameQualityChange(panelJob.id, next)} onAnalyze={() => d.onAnalyzeJob(panelJob.id, { mode: frames.length > 0 ? "append" : "replace" })} />
当前 {currentT.toFixed(2)}s
{frames.length} 张关键帧
seekTo(Number(e.target.value))} className="w-full accent-violet-400" aria-label="视频时间轴" />
已抽关键帧
点击缩略图定位时间
{frames.length > 0 ? (
{frames.map((f) => (
{(d.onDeleteFrameForJob || d.onDeleteFrame) && ( )}
))}
) : (
还没有关键帧,拖动时间轴后点击抽帧。
)}
) if (docked && typeof document !== "undefined") { const fixedStyle = dock === "left" ? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET } : dock === "right" ? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET } : { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" } return createPortal(
{panel}
, document.body, ) } return panel } /* ============================================================ 2. DownloadNode ============================================================ */ export function DownloadNode({ data, selected }: any) { const d: NodeData = data const st = downloadStatus(d.job) return ( } title="下载 · Download" subtitle="STEP 2 · yt-dlp" selected={selected} >
{d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
{d.job && st === "done" && (
分辨率
{d.job.width}×{d.job.height}
时长
{d.job.duration.toFixed(1)}s
)}
) } /* ============================================================ 3. SplitNode ============================================================ */ export function SplitNode({ data, selected }: any) { const d: NodeData = data return ( } title="拆分 · Split" subtitle="STEP 3 · ffmpeg" selected={selected} >
视频流
→ 关键帧
音频流
→ ASR
) } /* ============================================================ 4. KeyframeNode — 缩略图横排浮在节点上方,点击展开 lightbox ============================================================ */ const KEYFRAME_WIDTH = 360 const THUMB_W = 64 const THUMB_GAP = 6 type ElementPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number } type SceneAssetPreview = { frameIdx: number; assetId: string; label: string; src: string; width: number; height: number; risk?: string } type SubjectAssetPreview = { frameIdx: number; elementId: string; assetId: string; label: string; src: string; width: number; height: number; view: string } function collectElementCrops(job: Job | null): ElementPreview[] { return job ? job.frames.flatMap((f) => (f.elements ?? []) .filter((e) => hasCutout(e)) .map((e) => { const src = representativeCutoutUrl(job.id, f.index, e) || "" const cid = (e.cutouts && e.cutouts.length > 0) ? e.cutouts[e.cutouts.length - 1] : (e.cutout_id ?? "") return { frameIdx: f.index, elementId: e.id, name: e.name_zh, src, cid, timestamp: f.timestamp, } }) .filter((p) => p.src), ) : [] } function collectSceneAssets(job: Job | null): SceneAssetPreview[] { return job ? job.frames.flatMap((f) => (f.scene_assets ?? []).map((asset) => ({ frameIdx: f.index, assetId: asset.id, label: asset.label || `分镜 ${f.index + 1} 场景图`, src: apiAssetUrl(asset.url), width: asset.width, height: asset.height, risk: asset.quality_report?.risk, })).filter((p) => p.src), ) : [] } function collectSubjectAssets(job: Job | null): SubjectAssetPreview[] { return job ? job.frames.flatMap((f) => (f.elements ?? []).flatMap((element) => (element.subject_assets ?? []).map((asset) => ({ frameIdx: f.index, elementId: element.id, assetId: asset.id, label: asset.label || `${element.name_zh} · ${asset.view}`, src: apiAssetUrl(asset.url), width: asset.width, height: asset.height, view: asset.view, })), ).filter((p) => p.src), ) : [] } function videoModelLabel(model: string) { const m = model.toLowerCase() if (m.includes("kling")) return "Kling" if (m.includes("veo")) return "Veo 3" if (m.includes("seedance")) return "Seedance" return model || "Video" } /* ============================================================ 4. VisualLabNode — 合并镜头拆解 / 元素改造 / 生视频展示入口 ============================================================ */ export function VisualLabNode({ data, selected }: any) { const d: NodeData = data const job = d.job const frames = job?.frames ?? [] const videos = job?.generated_videos ?? [] const jobId = job?.id const aspect = job && (job.width ?? 0) > 0 && (job.height ?? 0) > 0 ? `${job.width}/${job.height}` : "9/16" const sceneAssets = collectSceneAssets(job) const subjectAssets = collectSubjectAssets(job) const cleanedCount = frames.filter((x) => x.cleaned_url).length const sceneAssetCount = sceneAssets.length const subjectAssetCount = subjectAssets.length const selectedFrameCount = frames.filter((f) => d.selectedFrames.has(f.index)).length const targetFrameCount = selectedFrameCount || frames.length const qualityRiskCount = frames.filter((f) => f.quality_report?.risk && f.quality_report.risk !== "ok").length const preparedUnits = Math.min(targetFrameCount, sceneAssetCount) + (subjectAssetCount > 0 ? 1 : 0) const totalUnits = Math.max(1, targetFrameCount + 1) const prepPct = Math.min(100, Math.round((preparedUnits / totalUnits) * 100)) const runningVideo = videos.some((v) => v.status === "queued" || v.status === "in_progress") const completedVideos = videos.filter((v) => v.status === "completed" && v.url) const failedVideo = videos.some((v) => v.status === "failed") const status: NodeStatus = runningVideo ? "running" : failedVideo ? "failed" : frames.length > 0 || subjectAssets.length > 0 || completedVideos.length > 0 ? "done" : keyframeStatus(job) type VisualPreview = | { id: string; kind: "frame"; group: string; frameIdx: number; src: string; label: string; caption: string; borderClass: string; aspect: string } | { id: string; kind: "scene"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string } | { id: string; kind: "subject"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string } | { id: string; kind: "video"; group: string; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string; aspect: string } const [hoverPreview, setHoverPreview] = useState | null>(null) const [pinnedPreview, setPinnedPreview] = useState | null>(null) const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null) const rootRef = useRef(null) const previews: VisualPreview[] = [ ...(job && jobId ? frames.map((f) => ({ id: `frame:${f.index}`, kind: "frame" as const, group: "关键帧", frameIdx: f.index, src: effectiveFrameUrl(jobId, f), label: `分镜 ${f.index + 1}`, caption: `${f.timestamp.toFixed(2)}s${f.quality_report?.risk && f.quality_report.risk !== "ok" ? " · 风险" : ""}`, borderClass: f.quality_report?.risk === "bad" ? "border-rose-300/70" : f.quality_report?.risk === "warn" ? "border-amber-300/70" : "border-orange-300/50", aspect, })) : []), ...subjectAssets.map((p) => ({ id: `subject:${p.frameIdx}:${p.assetId}`, kind: "subject" as const, group: "主体包", frameIdx: p.frameIdx, assetId: p.assetId, src: p.src, label: p.label, caption: `${p.width}×${p.height}`, borderClass: "border-violet-300/65", aspect: p.width && p.height ? `${p.width}/${p.height}` : "1/1", })), ...sceneAssets.map((p) => ({ id: `scene:${p.frameIdx}:${p.assetId}`, kind: "scene" as const, group: "场景图", frameIdx: p.frameIdx, assetId: p.assetId, src: p.src, label: p.label, caption: `${p.width}×${p.height}`, borderClass: p.risk === "bad" ? "border-rose-300/70" : p.risk === "warn" ? "border-amber-300/70" : "border-emerald-300/60", aspect: p.width && p.height ? `${p.width}/${p.height}` : aspect, })), ...videos.map((v, i) => { const videoSrc = apiAssetUrl(v.url) const posterSrc = apiAssetUrl(v.poster_url) return { id: `video:${v.id}`, kind: "video" as const, group: "视频任务", videoId: v.id, videoSrc: v.status === "completed" && videoSrc ? videoSrc : undefined, posterSrc: posterSrc || undefined, label: `视频 ${i + 1}`, caption: `${videoModelLabel(v.model)} · ${v.status}`, borderClass: v.status === "completed" ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55", aspect, } }), ] useEffect(() => { if (pinnedPreview === null) return const handler = (e: MouseEvent) => { const t = e.target as HTMLElement if (t.closest('.react-flow__node[data-id="visual"]')) return setPinnedPreview(null) } document.addEventListener("mousedown", handler, true) return () => document.removeEventListener("mousedown", handler, true) }, [pinnedPreview]) const openFirstFrame = () => { const idx = frames[0]?.index if (typeof idx === "number") (d.onOpenFramePanel ?? d.onExpandFrame)(idx) } return (
{previews.length > 0 && ( {previews.map((p) => { const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx) return (
setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} onMouseLeave={() => setHoverPreview(null)} >
{p.group}
{p.kind === "frame" && d.onCopyImage && ( )} {(p.kind === "scene" || p.kind === "subject") && d.onCopyImage && ( )} {p.kind === "video" && ( )} {p.kind === "frame" && d.onDeleteFrame && ( )} {p.kind === "video" && d.onDeleteVideo && ( )}
) })}
)} {deleteVideoTarget && ( setDeleteVideoTarget(null)} onConfirm={() => { const id = deleteVideoTarget.id setDeleteVideoTarget(null) d.onDeleteVideo?.(id) }} /> )} {(() => { const anchor = pinnedPreview ?? hoverPreview if (!anchor) return null const p = previews.find((x) => x.id === anchor.id) if (!p) return null return ( setPinnedPreview(null)} /> ) })()} } title="画面工作台 · Visual Lab" subtitle={`STEP 2-7 · ${frames.length ? `${frames.length} 帧` : "等待解析"}${videos.length ? ` · ${videos.length} 视频` : ""}`} selected={selected} pinned={d.pinnedNodes?.has("visual")} onTogglePin={() => d.onToggleNodePin?.("visual")} >
素材准备进度
{prepPct}%
{targetFrameCount} 张素材帧 {qualityRiskCount > 0 ? ( {qualityRiskCount} 个质量风险 ) : ( 无质量风险 )}
{frames.length > 0 ? ( <> {cleanedCount} 已清洗 · 统一主体 {subjectAssetCount} 张 · {sceneAssetCount} 场景图 · {targetFrameCount} 素材帧 · {completedVideos.length} 已完成 ) : ( "解析后这里变成素材准备看板:先审关键帧,再生成主体资产包和去主体场景图。" )}
) } export function KeyframeNode({ data, selected }: any) { const d: NodeData = data const st = keyframeStatus(d.job) const frames = d.job?.frames ?? [] const jobId = d.job?.id const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16" const [hoverPreviewFrame, setHoverPreviewFrame] = useState | null>(null) const [pinnedPreviewFrame, setPinnedPreviewFrame] = useState | null>(null) const rootRef = useRef(null) // 点击 keyframe 节点外的任何位置 → 取消 pin(capture 阶段,避免 ReactFlow pane 拦截) useEffect(() => { if (pinnedPreviewFrame === null) return const handler = (e: MouseEvent) => { const t = e.target as HTMLElement if (t.closest('.react-flow__node[data-id="keyframe"]')) return setPinnedPreviewFrame(null) } document.addEventListener("mousedown", handler, true) return () => document.removeEventListener("mousedown", handler, true) }, [pinnedPreviewFrame]) return (
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */} {frames.length > 0 && jobId && ( {frames.map((f) => { const isSel = d.selectedFrames.has(f.index) return (
0 ? `${d.job.width}/${d.job.height}` : "16/9", }} onMouseEnter={(e) => setHoverPreviewFrame({ id: f.index, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} onMouseLeave={() => setHoverPreviewFrame(null)} > {/* 复制按钮:常驻可见 — 复制该关键帧到剪贴板 */} {d.onCopyImage && ( )} {/* 删除按钮:常驻可见 */} {d.onDeleteFrame && ( )}
) })}
)} {(() => { const anchor = pinnedPreviewFrame ?? hoverPreviewFrame if (!anchor || !jobId) return null const frame = frames.find((f) => f.index === anchor.id) if (!frame) return null return ( setPinnedPreviewFrame(null)} /> ) })()} } title="镜头拆解 · 素材准备" subtitle={`STEP 2 · ${frames.length ? `${frames.length} 素材帧` : "等待抽取"}`} selected={selected} pinned={d.pinnedNodes?.has("keyframe")} onTogglePin={() => d.onToggleNodePin?.("keyframe")} > {frames.length > 0 ? (() => { const cleanedCount = frames.filter((x) => x.cleaned_url).length const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0) return (
自动 {frames.length} 张 {" · "} 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗 {" · "} 0 ? "text-violet-300/90 font-medium" : ""}>{elementsCount} 主体候选
点缩略图 → 清洗水印 / 准备主体资产和去主体场景图 → 改造成 SKG 画面素材
) })() : (
等待解析(默认 5 张)
)}
) } /* ============================================================ 4b. KeyframePanelNode — 画布内可移动详情面板 ============================================================ */ export function KeyframePanelNode({ data }: any) { const d: NodeData = data const { getZoom } = useReactFlow() const panelRef = useRef(null) const scale = d.framePanelScale ?? 1 const dock = d.framePanelDock ?? (d.framePanelPinned ? "left" : "canvas") const docked = dock !== "canvas" if (!d.job || d.expandedFrame === null) return null const active = d.job.frames.find((f) => f.index === d.expandedFrame) const arrayPos = active ? d.job.frames.findIndex((f) => f.index === active.index) : -1 const prevFrame = arrayPos > 0 ? d.job.frames[arrayPos - 1] : null const nextFrame = arrayPos >= 0 && arrayPos < d.job.frames.length - 1 ? d.job.frames[arrayPos + 1] : null const panelWidth = Math.round(760 * scale) const panelHeight = Math.round(746 * scale) const bodyHeight = Math.max(520, panelHeight - 27) const setScale = (next: number) => { const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2)))) d.onFramePanelScaleChange?.(clamped) } const dockText: Record = { canvas: "画布模式", left: "吸附左侧", right: "吸附右侧", bottom: "吸附底部", } const dockButtonClass = (value: CanvasPanelDock) => `nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${ dock === value ? "bg-white text-violet-700 shadow" : "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white" }` const startResize = (e: React.PointerEvent) => { e.preventDefault() e.stopPropagation() const startX = e.clientX const startY = e.clientY const startScale = scale const zoom = docked ? 1 : getZoom() const onMove = (ev: PointerEvent) => { const dx = (ev.clientX - startX) / zoom const dy = (ev.clientY - startY) / zoom const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 746 setScale(startScale + delta) } const onUp = () => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) } window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) } const panel = (
关键帧素材准备 {active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
{arrayPos >= 0 ? `${String(arrayPos + 1).padStart(2, "0")} / ${String(d.job.frames.length).padStart(2, "0")}` : ""}
{dockText[dock]}
e.stopPropagation()}>
) if (docked && typeof document !== "undefined") { const fixedStyle = dock === "left" ? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET } : dock === "right" ? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET } : { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" } return createPortal(
{panel}
, document.body, ) } return panel } /* ============================================================ 5. ASRNode — 音频转写 ============================================================ */ export function ASRNode({ data, selected }: any) { const d: NodeData = data return ( } title="声音文案 · ASR" subtitle="STEP 3 · 可选文案轨" selected={selected} pinned={d.pinnedNodes?.has("asr")} onTogglePin={() => d.onToggleNodePin?.("asr")} >
OpenAI-compatible ASR · 原语言带时间戳分段
{d.job && d.job.transcript.length > 0 && (
{d.job.transcript.slice(0, 3).map((s) => (
{s.start.toFixed(1)}s {s.en.slice(0, 60)} {s.en.length > 60 && "…"}
))} {d.job.transcript.length > 3 && (
还有 {d.job.transcript.length - 3} 段…
)}
)}
) } /* ============================================================ 6. TranslateNode ============================================================ */ export function TranslateNode({ data, selected }: any) { const d: NodeData = data const hasZh = d.job?.transcript.some((s) => s.zh) ?? false const st: NodeStatus = !d.job ? "pending" : d.job.status === "transcribing" ? "running" : hasZh ? "done" : d.job.status === "failed" ? "failed" : "pending" return ( } title="翻译理解 · Translate" subtitle="STEP 4 · EN → ZH" selected={selected} pinned={d.pinnedNodes?.has("translate")} onTogglePin={() => d.onToggleNodePin?.("translate")} >
中文翻译 · 段落级 · 实时输出
{hasZh && d.job && (
{d.job.transcript.slice(0, 3).map((s) => (
{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}
))}
)}
) } /* ============================================================ 7. RewriteNode ============================================================ */ export function RewriteNode({ data, selected }: any) { const d: NodeData = data const rewrittenText = d.job?.audio_script?.rewritten_text?.trim() ?? "" return ( } title="产品文案 · Rewrite" subtitle="STEP 5 · 接 SKG 卖点" selected={selected} pinned={d.pinnedNodes?.has("rewrite")} onTogglePin={() => d.onToggleNodePin?.("rewrite")} > {rewrittenText ? (
{rewrittenText}
) : (
转录完成后自动接 SKG 卖点改写。
)}
{d.job?.audio_script?.rewrite_model || "AUDIO_REWRITE_MODEL"}
) } /* ============================================================ 5b. AudioNode — 合并 ASR + 翻译 + 改写 + Azure OpenAI 配音 ============================================================ */ export function AudioNode({ data, selected }: any) { const d: NodeData = data const job = d.job const transcript = job?.transcript ?? [] const audioScript = job?.audio_script const rewrittenText = audioScript?.rewritten_text?.trim() ?? "" const voiceUrl = apiAssetUrl(audioScript?.voice_url) const hasASR = transcript.length > 0 const isRewriting = audioScript?.status === "rewriting" const hasVideo = !!job?.video_url const isAudioBusy = !!job && (job.status === "transcribing" || isRewriting) const audioButtonDisabled = !job || !hasVideo || isAudioBusy const audioButtonLabel = !hasVideo ? "等待视频就绪" : isAudioBusy ? "正在提取音频" : hasASR || rewrittenText ? "重新提取音频" : "提取音频" const originalPreview = transcript .slice(0, 2) .map((s) => (s.zh || s.en).trim()) .filter(Boolean) .join(" ") const status: NodeStatus = !job ? "pending" : job.status === "transcribing" || isRewriting ? "running" : rewrittenText || hasASR ? "done" : "pending" return ( } title="音频处理 · Audio" subtitle={hasASR ? `STEP 3 · ${transcript.length} 段` : "STEP 3"} selected={selected} pinned={d.pinnedNodes?.has("audio")} onTogglePin={() => d.onToggleNodePin?.("audio")} >
{ if (job?.video_url) d.onOpenAudioStrip?.(job.id) }} >
音轨 → 取时长/节奏 → SKG 英文产品口播 → Azure OpenAI 英文配音
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "Azure OpenAI TTS"}
{job && ( )} {(originalPreview || rewrittenText) && (
{originalPreview && (
改前 · 原音频
{originalPreview}
)} {rewrittenText && (
改后 · SKG Product VO
{rewrittenText}
)}
)} {voiceUrl &&
Azure OpenAI English voice ready · 底部音频条播放
} {isRewriting && (
正在按原音频时长生成英文产品口播和配音…
)} {audioScript?.error && rewrittenText && !voiceUrl && (
配音待生成:{audioScript.error}
)}
) } /* ============================================================ 6. StoryboardNode — 元素改造 + 分镜编排入口 ============================================================ */ const IMAGEGEN_WIDTH = 360 export function StoryboardNode({ data, selected }: any) { const d: NodeData = data const job = d?.job const [hoverPreviewCutout, setHoverPreviewCutout] = useState | null>(null) const [pinnedPreviewCutout, setPinnedPreviewCutout] = useState | null>(null) const rootRef = useRef(null) // 点击 storyboard 节点外 → 取消 pin useEffect(() => { if (pinnedPreviewCutout === null) return const handler = (e: MouseEvent) => { const t = e.target as HTMLElement if (t.closest('.react-flow__node[data-id="storyboard"]')) return setPinnedPreviewCutout(null) } document.addEventListener("mousedown", handler, true) return () => document.removeEventListener("mousedown", handler, true) }, [pinnedPreviewCutout]) // 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材) type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number } const elementCrops: ElPreview[] = job ? job.frames.flatMap((f) => (f.elements ?? []) .filter((e) => hasCutout(e)) .map((e) => { const src = representativeCutoutUrl(job.id, f.index, e) || "" const cid = (e.cutouts && e.cutouts.length > 0) ? e.cutouts[e.cutouts.length - 1] : (e.cutout_id ?? "") return { frameIdx: f.index, elementId: e.id, name: e.name_zh, src, cid, timestamp: f.timestamp, } }) .filter((p) => p.src), ) : [] const totalElements = elementCrops.length const storyboardCount = job?.frames.filter((f) => d.selectedFrames.has(f.index)).length ?? 0 const status: NodeStatus = !job ? "pending" : storyboardCount > 0 || totalElements > 0 ? "done" : "pending" const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16" return (
{/* 节点上方:所有元素 crop 图(编排输入素材)— 视觉类节点统一样板:单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */} {elementCrops.length > 0 && job && ( {elementCrops.map((p) => { const key = `${p.frameIdx}_${p.elementId}` return (
setHoverPreviewCutout({ id: key, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} onMouseLeave={() => setHoverPreviewCutout(null)} > {/* 左上:复制 */} {d.onCopyImage && ( )} {/* 右上:删除 */} {d.onDeleteCutout && ( )}
) })}
)} {(() => { const anchor = pinnedPreviewCutout ?? hoverPreviewCutout if (!anchor) return null const crop = elementCrops.find((p) => `${p.frameIdx}_${p.elementId}` === anchor.id) if (!crop) return null return ( setPinnedPreviewCutout(null)} /> ) })()} } title="元素改造 · Storyboard" subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`} selected={selected} pinned={d.pinnedNodes?.has("storyboard")} onTogglePin={() => d.onToggleNodePin?.("storyboard")} >
不是复刻原视频:先把参考图里的主体 / 场景 / 动作 / 道具拆出来,再替换成 SKG 产品画面。
已有 {totalElements} 个提取元素 · {storyboardCount} 个分镜进入编排
) } /* ============================================================ 9. VideoGenNode (placeholder) ============================================================ */ export function VideoGenNode({ data, selected }: any) { const d: NodeData = data const videos = d.job?.generated_videos ?? [] const rootRef = useRef(null) const [hoverPreviewVideo, setHoverPreviewVideo] = useState | null>(null) const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null) const running = videos.some((v) => v.status === "queued" || v.status === "in_progress") const completed = videos.filter((v) => v.status === "completed" && v.url) const failed = videos.some((v) => v.status === "failed") const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending" const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0 ? `${d.job.width}/${d.job.height}` : "9/16" const modelLabel = (model: string) => { const m = model.toLowerCase() if (m.includes("kling")) return "Kling" if (m.includes("veo")) return "Veo 3" if (m.includes("seedance")) return "Seedance" return model || "Video" } return (
{videos.length > 0 && ( {videos.map((v, i) => { const videoSrc = apiAssetUrl(v.url) const posterSrc = apiAssetUrl(v.poster_url) const ready = v.status === "completed" && !!videoSrc const progress = Math.max(0, Math.min(100, v.progress || 0)) return (
setHoverPreviewVideo({ id: v.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} onMouseLeave={() => setHoverPreviewVideo(null)} >
)})}
)} {deleteVideoTarget && ( setDeleteVideoTarget(null)} onConfirm={() => { const id = deleteVideoTarget.id setDeleteVideoTarget(null) d.onDeleteVideo?.(id) }} /> )} {(() => { if (!hoverPreviewVideo) return null const item = videos.find((v) => v.id === hoverPreviewVideo.id) if (!item) return null const videoSrc = apiAssetUrl(item.url) const posterSrc = apiAssetUrl(item.poster_url) const ready = item.status === "completed" && !!videoSrc if (!ready && !posterSrc) return null return ( ) })()} } title="生成视频 · Video Gen" subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`} selected={selected} pinned={d.pinnedNodes?.has("videogen")} onTogglePin={() => d.onToggleNodePin?.("videogen")} >
{["Seedance", "Kling", "Veo 3"].map((m) => (
{m}
))}
{videos.length > 0 && (
已提交 {videos.length} 个视频任务 · 完成 {completed.length} 个{running ? " · 生成中" : ""}
)}
) } /* ============================================================ 10. ComposeNode (placeholder) ============================================================ */ export function ComposeNode({ data, selected }: any) { const d: NodeData = data return ( } title="合成成品 · Compose" subtitle="STEP 8 · ffmpeg + 字幕" selected={selected} hasSource={false} pinned={d.pinnedNodes?.has("compose")} onTogglePin={() => d.onToggleNodePin?.("compose")} >
视频片段 + 字幕 / TTS
→ 最终 mp4 输出
) }