"use client" import { useRef, useState } from "react" import { type NodeProps } from "@xyflow/react" import { Link2, Upload, Download, Scissors, Image as ImageIcon, Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, } from "lucide-react" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { type Job, videoUrl } from "@/lib/api" export interface NodeData { job: Job | null submitting: boolean analyzing: boolean selectedFrames: Set onSubmitUrl: (url: string) => void onUploadFile: (file: File) => void onAnalyze: () => void onToggleFrame: (idx: number) => 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 fileRef = 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 inputLocked = isDownloading || d.submitting return ( } title="输入 · Input" subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"} width={320} selected={selected} hasTarget={false} > {/* 未下载:URL + 上传入口 */} {!hasVideo && ( <> setUrl(e.target.value)} placeholder="粘贴 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 = "" }} />
)} {/* 已下载:内嵌视频 + 解析按钮 */} {hasVideo && job && ( <>
) } /* ============================================================ 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 — 缩略图网格 + 多选 ============================================================ */ export function KeyframeNode({ data, selected }: any) { const d: NodeData = data const st = keyframeStatus(d.job) return ( } title="关键帧 · Keyframes" subtitle={`STEP 4 · ${d.selectedFrames.size}/10`} width={360} selected={selected} > {d.job?.frames.length ? (
{d.job.frames.map((f) => { const isSel = d.selectedFrames.has(f.index) return ( ) })}
) : (
等待视频流,自动 + 手动抽取 ≤10 张
)}
) } /* ============================================================ 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} >