"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 { 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 任务 onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板 onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板 onCopyImage?: (ref: ImageRef) => 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" } /* ============================================================ 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 fileRef = useRef(null) const videoRef = useRef(null) const job = d.job // 是否已下载 → 显示视频 + 解析按钮 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 return ( ) })}
)} {/* 展开态 — 稍微放大(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"} width={320} selected={selected} hasTarget={false} > {/* 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 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" return (
{/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */} {frames.length > 0 && jobId && (
{frames.map((f) => { const isSel = d.selectedFrames.has(f.index) return (
0 ? `${d.job.width}/${d.job.height}` : "16/9", }} > {/* 复制按钮:常驻可见 — 复制该关键帧到剪贴板 */} {d.onCopyImage && ( )} {/* 删除按钮:hover 时右上角浮出 */} {d.onDeleteFrame && ( )} {/* hover 预览 — absolute 浮在缩略图上方,跟着 ReactFlow 画布缩放平移 */}
分镜 {f.index + 1} {f.timestamp.toFixed(2)}s
) })}
)} } title="镜头拆解 · 元素提取" subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`} width={KEYFRAME_WIDTH} selected={selected} > {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} >
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} >
中文翻译 · 段落级 · 实时输出
{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({ selected }: any) { return ( } title="产品文案 · Rewrite" subtitle="STEP 5 · 接 SKG 卖点" selected={selected} >