"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, LayoutGrid, } from "lucide-react" import { type Job, type KeyFrame, frameUrl, effectiveFrameUrl, videoUrl, generateImage, selectGenerated, generatedImageUrl, apiAssetUrl } 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])), }), []) // drawer 宽度 per panel · 持久化到 localStorage const drawerKey = (() => { const keys = Array.from(expanded) if (keys.length === 0) return "" const base = keys[0] if (base === "keyframe" && data.expandedFrame !== null) return "keyframe:lightbox" return base })() const isLightboxMode = drawerKey === "keyframe:lightbox" const defaultWidth = isLightboxMode ? 760 : 400 const minDrawerWidth = isLightboxMode ? 480 : 280 const [drawerWidths, setDrawerWidths] = useState>({}) useEffect(() => { if (typeof window === "undefined") return try { const stored = JSON.parse(localStorage.getItem("skg.drawer.widths") || "{}") if (stored && typeof stored === "object") setDrawerWidths(stored) } catch { /* ignore */ } }, []) const drawerWidth = drawerKey ? (drawerWidths[drawerKey] ?? defaultWidth) : defaultWidth const setDrawerWidth = (w: number) => { if (!drawerKey) return const clamped = Math.max(minDrawerWidth, Math.min(1400, w)) setDrawerWidths((prev) => { const next = { ...prev, [drawerKey]: clamped } try { localStorage.setItem("skg.drawer.widths", JSON.stringify(next)) } catch { /* ignore */ } return next }) } // 拖拽 const dragRef = useRef<{ startX: number; startW: number } | null>(null) const onResizeMouseDown = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() dragRef.current = { startX: e.clientX, startW: drawerWidth } const onMove = (ev: MouseEvent) => { if (!dragRef.current) return const delta = ev.clientX - dragRef.current.startX setDrawerWidth(dragRef.current.startW + delta) } const onUp = () => { dragRef.current = null window.removeEventListener("mousemove", onMove) window.removeEventListener("mouseup", onUp) } window.addEventListener("mousemove", onMove) window.addEventListener("mouseup", onUp) } 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 hasAudioRewrite = !!job?.audio_script?.rewritten_text?.trim() const isAudioRewriting = job?.audio_script?.status === "rewriting" const audioCompareRows = job?.transcript.slice(0, 8) ?? [] 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: !job ? "pending" : isAudioRewriting ? "running" : hasAudioRewrite ? "done" : "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: hasAudioRewrite ? "已生成" : isAudioRewriting ? "生成中…" : "待文案", 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 }, // imagegen(分镜头编排)已移到顶部 StoryboardBar,不在 sidebar 里 { 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 (
{/* 起点:输入(含下载+拆分) */} {/* 分叉:上路 关键帧 / 生视频(分镜头编排在顶部 bar) */}
{/* 分叉:下路 转录/翻译/改写 */}
{/* 合流 */} {/* 展开面板 — 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 (
{/* 右边拖拽手柄 — hover 时显示 */}
setDrawerWidth(defaultWidth)} className="group/rs absolute top-0 bottom-0 right-0 w-1.5 cursor-col-resize hover:bg-violet-400/30 transition z-[110]" >
{String(t.step).padStart(2, "0")} {t.icon} {t.title} {colSummary[t.key]}
{isKeyframeWithExpand && data.job ? ( { data.onCloseExpandedFrame() setExpanded(new Set([key])) }} clipboard={data.clipboard} onCopyImage={data.onCopyImage} onGenerateProductFusionVideo={data.onGenerateProductFusionVideo} onDeleteVideo={data.onDeleteVideo} /> ) : ( 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 ? (
候选帧 → pHash 去重 + 清晰度排序 + 时序分桶 → 按当前设置产出参考帧
) : ( 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" ? "音频转写中…" : "需要先完成关键帧抽取"}
) : ( job!.transcript.map((s) => (
EN {s.en}
ZH {s.zh || 翻译中…}
)) ) )} {/* ---- Rewrite — Kanban ---- */} {key === "rewrite" && ( <> {audioCompareRows.length > 0 ? (
{audioCompareRows.map((s) => (
{s.start.toFixed(1)}s → {s.end.toFixed(1)}s
{s.zh || 中文翻译中…}
{s.en && (
{s.en}
)}
))}
) : (
音频识别完成后,这里显示原始 ASR 和中文翻译。
)}
参考视频原话,不直接用于成片
{job?.audio_script?.rewritten_text ? (
{job.audio_script.rewritten_text}
) : (
{isAudioRewriting ? "正在按原音频时长生成 SKG 英文产品口播…" : "转录完成后自动生成 SKG 英文产品口播"}
)}
用于后续 TTS、字幕和视频生成 prompt
{job?.audio_script?.product_brief || "等待音频转写完成后,按默认 SKG 放松产品卖点生成口播。"}
{job?.audio_script?.voice_url ? ( )} {/* ---- 分镜头编排 — 基于关键帧 + 元素 + 场景图编排(Phase 2 重写) ---- */} {key === "imagegen" && ( data.selectedFrames.size === 0 ? (
在「关键帧」节点勾选 1+ 张后,每张关键帧 → 1 张生成图
) : ( Array.from(data.selectedFrames).sort((a, b) => a - b).map((frameIdx) => { const f = data.job?.frames.find((x) => x.index === frameIdx) if (!f || !data.job) return null return }) ) )} {/* ---- VideoGen — Kanban ---- */} {key === "videogen" && ( <>
按后端 VIDEO_CREATE_PATHS 提交,模型 ID 走环境变量映射
字节跳动 · 需独立 API key
快手 · 需独立 API key
每张生成图 → 1 段 5-10s 视频,按改写文案合成 prompt
)} {/* ---- Compose — Kanban ---- */} {key === "compose" && ( <>
待合成
视频片段 + 字幕(改写中文)+ TTS 配音 → 最终 mp4
完全本地 · 零 API 调用
)}
) } }) /* ============================================================ ImageGenCard — 单张关键帧的生图卡 ============================================================ */ function ImageGenCard({ job, frame, onJobUpdate }: { job: Job frame: KeyFrame onJobUpdate: (j: Job) => void }) { const [extra, setExtra] = useState("") const [negative, setNegative] = useState("水印, @用户名, TikTok logo, 平台文字, 浮水印") const model = "gpt-image-2" const [mode, setMode] = useState<"edit" | "text">("edit") const [generating, setGenerating] = useState(false) const basePrompt = frame.description?.suggested_prompt ?? "(尚未识别 · 点关键帧打开 lightbox 先识别)" const [editablePrompt, setEditablePrompt] = useState(basePrompt) const [showPrompt, setShowPrompt] = useState(false) const [previewGenId, setPreviewGenId] = useState(null) const [previewMounted, setPreviewMounted] = useState(false) useEffect(() => setPreviewMounted(true), []) // 当 vision 识别完成后更新默认 prompt useEffect(() => { if (frame.description?.suggested_prompt) { setEditablePrompt(frame.description.suggested_prompt) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [frame.description?.suggested_prompt]) const handleGenerate = async () => { if (!editablePrompt.trim()) { toast.error("请先填写 prompt(点上方关键帧识别会自动生成)") return } setGenerating(true) try { const updated = await generateImage(job.id, frame.index, { prompt: editablePrompt, extra_prompt: extra, negative_prompt: negative, model, mode, }) onJobUpdate(updated) toast.success(`分镜 ${frame.index + 1} 生成完成`) } catch (e) { toast.error("生图失败:" + (e instanceof Error ? e.message : String(e))) } finally { setGenerating(false) } } const handleSelectGen = async (genId: string, currentlySelected: boolean) => { try { const updated = await selectGenerated(job.id, frame.index, genId, !currentlySelected) onJobUpdate(updated) } catch (e) { toast.error("选用失败:" + (e instanceof Error ? e.message : String(e))) } } const gens = frame.generated_images ?? [] const objects = frame.description?.objects ?? [] return ( {/* 参考图 + 识别物体 chips */}
{`frame
识别元素
{objects.length > 0 ? (
{objects.slice(0, 6).map((o, i) => ( ))}
) : (
未识别(点上方缩略图打开识别)
)}
{/* 画面描述(AI 自动 · 可展开编辑) */}
{showPrompt && (