"use client" import { useEffect, useRef, useState } 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, Pin, Maximize2, Copy, Trash2, } 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, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, } from "@/lib/api" import { FrameLightbox } from "@/components/lightbox" export interface NodeData { job: Job | null // 当前 active job jobs: Job[] // 所有 job 列表 activeJobId: string | null submitting: boolean analyzing: boolean selectedFrames: Set expandedFrame: number | null framePanelScale?: number framePanelPinned?: boolean onSubmitUrl: (url: string) => void onUploadFile: (file: File) => void onAnalyze: () => void onToggleFrame: (idx: number) => void onExpandFrame: (idx: number) => void onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板 onFramePanelScaleChange?: (scale: number) => void onFramePanelPinnedChange?: (pinned: boolean) => void onCloseExpandedFrame: () => void onAddManualFrame: (t: number) => void onOpenVideoLightbox: () => void onSwitchJob: (id: string) => void onJobUpdate: (j: Job) => void onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开 onDeleteFrame?: (idx: number) => 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 // 展开顶部分镜编排内嵌面板 onCopyImage?: (ref: ImageRef) => 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 } 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, } } /* ============================================================ 1. InputNode — TK 链接 / 上传 ============================================================ */ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) { const d: NodeData = data const [url, setUrl] = useState("") const [videoT, setVideoT] = useState(0) const [addingFrame, setAddingFrame] = useState(false) const [videoExpanded, setVideoExpanded] = useState(false) const [hoverPreviewJob, setHoverPreviewJob] = useState | null>(null) const [pinnedPreviewJob, setPinnedPreviewJob] = useState | null>(null) const rootRef = useRef(null) const fileRef = useRef(null) const videoRef = 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 isDone = job?.status === "transcribed" const hasFrames = (job?.frames.length ?? 0) > 0 const inputLocked = isDownloading || d.submitting return (
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。 浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */} {!videoExpanded && 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" return (
setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} onMouseLeave={() => setHoverPreviewJob(null)} >
) })}
)} {(() => { const anchor = pinnedPreviewJob ?? hoverPreviewJob if (!anchor || videoExpanded) 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)} /> ) })()} {/* 展开态 — 稍微放大(360 宽),含 controls + 加帧按钮,不全屏 */} {hasVideo && job && videoExpanded && (
e.stopPropagation()} className="relative rounded-xl overflow-hidden border border-white/25 shadow-2xl bg-black" style={{ width: 360, animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }} >
)} } 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://") ? "📎 上传" : "🔗 链接"}
)}
) } /* ============================================================ 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 } 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 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 elementCrops = collectElementCrops(job) const cleanedCount = frames.filter((x) => x.cleaned_url).length const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0) 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 || elementCrops.length > 0 || completedVideos.length > 0 ? "done" : keyframeStatus(job) type VisualPreview = | { id: string; kind: "frame"; frameIdx: number; src: string; label: string; caption: string; borderClass: string } | { id: string; kind: "cutout"; frameIdx: number; elementId: string; cutoutId: string; src: string; label: string; caption: string; borderClass: string } | { id: string; kind: "video"; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string } const [hoverPreview, setHoverPreview] = useState | null>(null) const [pinnedPreview, setPinnedPreview] = useState | null>(null) const rootRef = useRef(null) const previews: VisualPreview[] = [ ...(job && jobId ? frames.map((f) => ({ id: `frame:${f.index}`, kind: "frame" as const, frameIdx: f.index, src: effectiveFrameUrl(jobId, f), label: `分镜 ${f.index + 1}`, caption: `${f.timestamp.toFixed(2)}s`, borderClass: "border-orange-300/50", })) : []), ...elementCrops.map((p) => ({ id: `cutout:${p.frameIdx}:${p.elementId}:${p.cid}`, kind: "cutout" as const, frameIdx: p.frameIdx, elementId: p.elementId, cutoutId: p.cid, src: p.src, label: p.name, caption: `分镜 ${p.frameIdx + 1}`, borderClass: "border-violet-300/60", })), ...videos.map((v, i) => { const videoSrc = apiAssetUrl(v.url) const posterSrc = apiAssetUrl(v.poster_url) return { id: `video:${v.id}`, kind: "video" as const, 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", } }), ] 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.kind === "frame" && d.onCopyImage && ( )} {p.kind === "cutout" && d.onCopyImage && ( )} {p.kind === "video" && ( )} {p.kind === "frame" && d.onDeleteFrame && ( )} {p.kind === "cutout" && d.onDeleteCutout && ( )} {p.kind === "video" && d.onDeleteVideo && ( )}
) })}
)} {(() => { 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")} >
{videos.length}
视频任务
{frames.length > 0 ? ( <> {cleanedCount} 已清洗 · {cutoutCount} 已抠图 · {d.selectedFrames.size}/{frames.length} 入编排 · {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 && ( )} {/* 删除按钮:hover 时右上角浮出 */} {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 ? `${d.selectedFrames.size}/${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) const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0) return (
自动 {frames.length} 张 {" · "} 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗 {" · "} 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图
点缩略图 → 清洗水印 / 提取可借鉴元素 → 改造成 SKG 画面素材
) })() : (
等待解析(默认 5 张)
)}
) } /* ============================================================ 4b. KeyframePanelNode — 画布内可移动详情面板 ============================================================ */ export function KeyframePanelNode({ data }: any) { const d: NodeData = data const { getZoom } = useReactFlow() const panelRef = useRef(null) const [pinRect, setPinRect] = useState<{ left: number; top: number }>({ left: 24, top: 72 }) const scale = d.framePanelScale ?? 1 const pinned = d.framePanelPinned ?? false const getStoryboardDockTop = () => { if (typeof window === "undefined") return 64 const dock = document.querySelector('[data-storyboard-dock="true"]') const bar = document.querySelector('[data-storyboard-bar="true"]') const bottom = (dock ?? bar)?.getBoundingClientRect().bottom ?? 52 return Math.max(56, Math.min(window.innerHeight - 120, bottom + 10)) } useEffect(() => { if (!pinned || typeof window === "undefined") return const syncDock = () => { setPinRect({ left: 16, top: getStoryboardDockTop() }) } syncDock() const bar = document.querySelector('[data-storyboard-dock="true"]') ?? document.querySelector('[data-storyboard-bar="true"]') let observer: ResizeObserver | null = null if (bar && "ResizeObserver" in window) { observer = new ResizeObserver(syncDock) observer.observe(bar) } window.addEventListener("resize", syncDock) return () => { observer?.disconnect() window.removeEventListener("resize", syncDock) } }, [pinned]) if (!d.job || d.expandedFrame === null) return null const active = d.job.frames.find((f) => f.index === d.expandedFrame) 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 togglePinned = () => { if (!pinned) { const zoom = getZoom() setScale(scale * zoom) setPinRect({ left: 16, top: getStoryboardDockTop() }) } d.onFramePanelPinnedChange?.(!pinned) } const startResize = (e: React.PointerEvent) => { e.preventDefault() e.stopPropagation() const startX = e.clientX const startY = e.clientY const startScale = scale const zoom = pinned ? 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` : "未选分镜"}
{pinned ? "已钉住左侧 · 不跟画布" : "拖动标题栏移动 · 可钉住"}
e.stopPropagation()}>
) if (pinned && typeof document !== "undefined") { return createPortal(
{panel}
, document.body, ) } return panel } /* ============================================================ 5. ASRNode — Gemini 转录 ============================================================ */ 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")} >
Gemini 2.5 · 英文带时间戳分段
{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 (placeholder) ============================================================ */ export function RewriteNode({ data, selected }: any) { const d: NodeData = data return ( } title="产品文案 · Rewrite" subtitle="STEP 5 · 接 SKG 卖点" selected={selected} pinned={d.pinnedNodes?.has("rewrite")} onTogglePin={() => d.onToggleNodePin?.("rewrite")} >