"use client" import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { type NodeProps } from "@xyflow/react" import { Link2, Upload, Download, Scissors, Image as ImageIcon, Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X, LayoutGrid, } from "lucide-react" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { type Job, type ImageRef, frameUrl, effectiveFrameUrl, videoUrl, generatedImageUrl, cutoutUrl, hasCutout, representativeCutoutUrl } from "@/lib/api" export interface NodeData { job: Job | null // 当前 active job jobs: Job[] // 所有 job 列表 activeJobId: string | null submitting: boolean analyzing: boolean selectedFrames: Set expandedFrame: number | null onSubmitUrl: (url: string) => void onUploadFile: (file: File) => void onAnalyze: () => void onToggleFrame: (idx: number) => void onExpandFrame: (idx: number) => 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 // 删单张生成图 onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板 onPushToStoryboard?: (payload: { kind: "keyframe" | "cutout"; frameIdx: number; elementId?: string; cutoutId?: string; label?: string }) => 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", "frames_extracted", "transcribing"].includes(job.status) const isDone = job?.status === "transcribed" const hasFrames = (job?.frames.length ?? 0) > 0 const inputLocked = isDownloading || d.submitting return (
{/* 多视频缩略图浮条 — 每个 job 一张 + 末尾「+」按钮再上传 */} {!videoExpanded && d.jobs.length > 0 && (
{d.jobs.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} 已抠图
点缩略图 → 清洗水印 / 提取元素 → 抠图给「分镜头编排」用
) })() : (
等待解析(默认 5 张)
)}
) } /* ============================================================ 5. ASRNode — Gemini 转录 ============================================================ */ export function ASRNode({ data, selected }: any) { const d: NodeData = data return ( } title="转录 · ASR" subtitle="STEP 5 · Gemini" 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 6 · 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 7 · 接产品信息" selected={selected} >