"use client" import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react" import { createPortal } from "react-dom" import { Link2, Upload, Download, Scissors, Image as ImageIcon, Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, Check, ChevronDown, X, } from "lucide-react" import { type Job, type KeyFrame, frameUrl, videoUrl, generateImage, selectGenerated, generatedImageUrl } from "@/lib/api" import { type NodeData } from "@/components/nodes" import { FrameLightbox } from "@/components/lightbox" import { toast } from "sonner" type ColType = "input" | "process" | "ai" | "output" const TYPE_GRAD: Record = { input: "linear-gradient(135deg, #6366f1, #a855f7)", process: "linear-gradient(135deg, #f59e0b, #ef4444)", ai: "linear-gradient(135deg, #d946ef, #ec4899)", output: "linear-gradient(135deg, #10b981, #06b6d4)", } type ColState = "pending" | "running" | "done" | "failed" const STATE_DOT: Record = { pending: "bg-white/25", running: "bg-violet-300 shadow-[0_0_8px_rgba(167,139,250,0.8)] animate-pulse", done: "bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.7)]", failed: "bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)]", } function MiniCard({ children, className = "", onClick }: { children: ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void }) { return (
{children}
) } type KanbanTone = "violet" | "pink" | "orange" | "blue" | "green" | "cyan" | "rose" | "amber" function KanbanCard({ tone = "violet", tags, title, className = "", onClick, meta, children, }: { tone?: KanbanTone tags?: string[] title?: ReactNode className?: string onClick?: (e: React.MouseEvent) => void meta?: ReactNode children?: ReactNode }) { return (
{tags && tags.length > 0 && (
{tags.map((t) => ( #{t} ))}
)} {title &&

{title}

} {children} {meta &&
{meta}
}
) } interface Props { data: NodeData } export interface DashboardHandle { openPanel: (key: string) => void } export const Dashboard = forwardRef(function Dashboard({ data }, ref) { const { job } = data const [url, setUrl] = useState("") const [videoT, setVideoT] = useState(0) const [addingFrame, setAddingFrame] = useState(false) const [expanded, setExpanded] = useState>(new Set()) const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) useImperativeHandle(ref, () => ({ openPanel: (key: string) => setExpanded(new Set([key])), }), []) const tileRefs = useRef>({}) const fileRef = useRef(null) const videoRef = useRef(null) /* 状态推导 */ const hasVideo = !!job?.video_url const isDownloading = job?.status === "downloading" || job?.status === "created" const isSplitting = job?.status === "splitting" const isAnalyzing = !!job && ["splitting", "frames_extracted", "transcribing"].includes(job.status) const hasFrames = (job?.frames.length ?? 0) > 0 const hasTranscript = (job?.transcript.length ?? 0) > 0 const hasZh = job?.transcript.some((s) => s.zh) ?? false const isFailed = job?.status === "failed" const colState: Record = { // input 状态合并:覆盖原 input + download + split input: !job ? "pending" : isDownloading ? "running" : isSplitting ? "running" : hasFrames || hasTranscript ? "done" : hasVideo ? "done" : isFailed && job.progress < 50 ? "failed" : "pending", keyframe: !job ? "pending" : (isSplitting && !hasFrames) ? "running" : hasFrames ? "done" : isFailed && job.progress >= 50 && job.progress < 70 ? "failed" : "pending", asr: !job ? "pending" : job.status === "transcribing" ? "running" : hasTranscript ? "done" : isFailed && job.progress >= 70 ? "failed" : "pending", translate: !job ? "pending" : job.status === "transcribing" ? "running" : hasZh ? "done" : "pending", rewrite: "pending", imagegen: "pending", videogen: "pending", compose: "pending", } /* 每列摘要 = tile 副标题 */ const colSummary: Record = { input: !job ? "等待" : isDownloading ? "下载中…" : hasVideo ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : "—", keyframe: hasFrames ? `${data.selectedFrames.size}/${job!.frames.length} 选用` : "—", asr: hasTranscript ? `${job!.transcript.length} 段` : "—", translate: hasZh ? `${job!.transcript.filter((s) => s.zh).length} 段` : "—", rewrite: "占位", imagegen: data.selectedFrames.size > 0 ? `${data.selectedFrames.size} 张待生` : "占位", videogen: "占位", compose: "占位", } const TILES: Array<{ key: string; title: string; type: ColType; icon: ReactNode; step: number }> = [ { key: "input", title: "输入", type: "input", icon: , step: 1 }, { key: "keyframe", title: "关键帧", type: "ai", icon: , step: 2 }, { key: "asr", title: "转录", type: "ai", icon: , step: 3 }, { key: "translate", title: "翻译", type: "ai", icon: , step: 4 }, { key: "rewrite", title: "改写", type: "ai", icon: , step: 5 }, { key: "imagegen", title: "生图", type: "ai", icon: , step: 6 }, { key: "videogen", title: "生视频", type: "ai", icon: , step: 7 }, { key: "compose", title: "合成", type: "output", icon: , step: 8 }, ] // 单选展开:toggle 同一 key = 收起;点其他 key = 切换 const toggleTile = (key: string) => { setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key]))) } const closeTile = (_key: string) => { setExpanded(new Set()) data.onCloseExpandedFrame() } // 点关键帧缩略图时(onExpandFrame 触发),自动打开 keyframe drawer useEffect(() => { if (data.expandedFrame !== null && !expanded.has("keyframe")) { setExpanded(new Set(["keyframe"])) } }, [data.expandedFrame]) const Tile = ({ tkey }: { tkey: string }) => { const t = TILES.find((x) => x.key === tkey)! const state = colState[t.key] const isOpen = expanded.has(t.key) return ( ) } return (
{/* 起点:输入(含下载+拆分) */} {/* 分叉:上路 关键帧/生图/生视频 */}
{/* 分叉:下路 转录/翻译/改写 */}
{/* 合流 */} {/* 展开面板 — keyframe 有选中帧时变宽容纳 lightbox */} {expanded.size > 0 && mounted && createPortal(
{TILES.filter((t) => expanded.has(t.key)).map((t) => { const isKeyframeWithExpand = t.key === "keyframe" && data.expandedFrame !== null return (
{String(t.step).padStart(2, "0")} {t.icon} {t.title} {colSummary[t.key]}
{isKeyframeWithExpand && data.job ? ( { data.onCloseExpandedFrame() setExpanded(new Set([key])) }} /> ) : ( renderSection(t.key) )}
) })}
, document.body )}
) function renderSection(key: string): ReactNode { return (
{/* ---- Input — Kanban ---- */} {key === "input" && ( <> setUrl(e.target.value)} placeholder="粘贴 TikTok 链接" disabled={isDownloading || data.submitting} className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/30 border border-white/15 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40 mt-1" />
{ const f = e.target.files?.[0] if (f) data.onUploadFile(f) e.target.value = "" }} />
{hasVideo && job && ( <> {/* 元数据 */}
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : `🔗 ${job.url}`}
{/* 拆分流 */}
视频流
→ 关键帧
音频流
→ ASR (wav)
{/* 解析按钮 */}
全流程 = 拆分 → 抽帧 → ASR → 翻译
)} )} {/* ---- Download — 只显示元数据,视频播放器移到 Keyframe section ---- */} {key === "download" && ( !hasVideo ? (
TikTok / yt-dlp 兼容站点
) : ( <>
{job!.url.startsWith("upload://") ? `📎 ${job!.url.slice(9)}` : `🔗 ${job!.url}`}
分辨率
{job!.width}×{job!.height}
时长
{job!.duration.toFixed(1)}s
大小
~9MB
要看视频 / 选帧请打开「关键帧」
) )} {/* ---- Split — Kanban ---- */} {key === "split" && ( <>
fast seek + Laplacian 方差评分
16kHz mono · pcm_s16le wav
-vn -ac 1 -ar 16000
)} {/* ---- Keyframe — Kanban 卡片(视频播放器 + 加帧 + 缩略图都在这里) ---- */} {key === "keyframe" && (
{hasVideo && job && ( <> )} {!hasFrames ? (
候选 30 张 → pHash 去重 + 清晰度排序 → 时序分桶 → 5 张代表分镜
) : ( job!.frames.map((f) => { const isSel = data.selectedFrames.has(f.index) const cleaned = !!f.cleaned_url const elCount = f.elements?.length ?? 0 const cutCount = f.elements?.filter((e) => e.cutout_id).length ?? 0 const tags = [`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`] if (cleaned) tags.push("已清洗") if (cutCount > 0) tags.push(`${cutCount}/${elCount} 抠图`) return ( { e.stopPropagation(); data.onToggleFrame(f.index) }} className={`ml-auto text-[10.5px] px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${ isSel ? "bg-emerald-500 text-white" : "bg-white/10 text-[var(--text-soft)] border border-white/15 hover:bg-white/20" }`} > {isSel ? "已选用" : "选用此帧"} } > ) }) )}
)} {/* ---- ASR / Translate — Kanban 段落卡 ---- */} {(key === "asr" || key === "translate") && ( !hasTranscript ? (
{colState.asr === "running" ? "Gemini 转录中…" : "需要先完成关键帧抽取"}
) : ( job!.transcript.map((s) => (
EN {s.en}
ZH {s.zh || 翻译中…}
)) ) )} {/* ---- Rewrite — Kanban ---- */} {key === "rewrite" && ( <>