"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTheme } from "next-themes" import { ReactFlow, Background, BackgroundVariant, Controls, MiniMap, useNodesState, useEdgesState, type Node, type Edge, } from "@xyflow/react" import { Toaster, toast } from "sonner" import { InputNode, DownloadNode, SplitNode, KeyframeNode, ASRNode, TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode, type NodeData, } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" import { analyzeJob, createJob, getJob, uploadJob, type Job } from "@/lib/api" const NODE_TYPES = { input: InputNode, download: DownloadNode, split: SplitNode, keyframe: KeyframeNode, asr: ASRNode, translate: TranslateNode, rewrite: RewriteNode, imagegen: ImageGenNode, videogen: VideoGenNode, compose: ComposeNode, } // 手布局:DAG,从左到右 // 拆分后两路:上路 video → keyframe → imagegen → videogen ↘ // 下路 audio → asr → translate → rewrite ────→ compose const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number }> = [ { id: "input", type: "input", x: 40, y: 240 }, { id: "download", type: "download", x: 400, y: 240 }, { id: "split", type: "split", x: 720, y: 240 }, { id: "keyframe", type: "keyframe", x: 1060, y: 60 }, { id: "asr", type: "asr", x: 1060, y: 440 }, { id: "translate", type: "translate", x: 1440, y: 440 }, { id: "imagegen", type: "imagegen", x: 1480, y: 60 }, { id: "rewrite", type: "rewrite", x: 1820, y: 440 }, { id: "videogen", type: "videogen", x: 1860, y: 60 }, { id: "compose", type: "compose", x: 2240, y: 240 }, ] const EDGES_RAW: Array<[string, string]> = [ ["input", "download"], ["download", "split"], ["split", "keyframe"], ["split", "asr"], ["asr", "translate"], ["translate", "rewrite"], ["keyframe", "imagegen"], ["rewrite", "imagegen"], ["imagegen", "videogen"], ["videogen", "compose"], ["rewrite", "compose"], ] export default function Home() { const { resolvedTheme } = useTheme() const [job, setJob] = useState(null) const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { setSubmitting(true) setSelectedFrames(new Set()) try { const created = await createJob(url) setJob(created) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, []) const handleUpload = useCallback(async (file: File) => { setSubmitting(true) setSelectedFrames(new Set()) try { toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) const created = await uploadJob(file) setJob(created) toast.success(`已上传 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, []) const handleAnalyze = useCallback(async () => { if (!job) return setAnalyzing(true) setSelectedFrames(new Set()) try { await analyzeJob(job.id, 5) toast.info("开始解析:拆轨 → 抽帧 → ASR → 翻译") } catch (e) { toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) } finally { setAnalyzing(false) } }, [job?.id]) const handleToggleFrame = useCallback((idx: number) => { setSelectedFrames((prev) => { const next = new Set(prev) if (next.has(idx)) next.delete(idx) else next.add(idx) return next }) }, []) // 轮询 Job(downloaded / transcribed / failed 三态停止) useEffect(() => { if (!job) return const TERMINAL: Job["status"][] = ["downloaded", "transcribed", "failed"] if (TERMINAL.includes(job.status)) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } return } pollRef.current = setInterval(async () => { try { const latest = await getJob(job.id) setJob(latest) } catch { /* silent */ } }, 1500) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [job?.id, job?.status]) const nodeData: NodeData = useMemo(() => ({ job, submitting, analyzing, selectedFrames, onSubmitUrl: handleSubmit, onUploadFile: handleUpload, onAnalyze: handleAnalyze, onToggleFrame: handleToggleFrame, }), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const [nodes, setNodes, onNodesChange] = useNodesState( LAYOUT.map((n) => ({ id: n.id, type: n.type, position: { x: n.x, y: n.y }, data: nodeData, draggable: true, })), ) const [edges, setEdges, onEdgesChange] = useEdgesState( EDGES_RAW.map(([from, to], i) => ({ id: `e${i}`, source: from, target: to, animated: false, type: "default", })), ) // Job 数据变化时只更新节点 data 不动 position useEffect(() => { setNodes((prev) => prev.map((n) => ({ ...n, data: nodeData }))) }, [nodeData, setNodes]) // 边的 animated 状态跟 Job 进度联动 useEffect(() => { const doneOf: Record = { input: !!job, download: !!job?.video_url, split: !!job && ["frames_extracted", "transcribing", "transcribed"].includes(job.status), keyframe: !!job && job.frames.length > 0, asr: !!job && job.transcript.length > 0, translate: !!job && (job.transcript.some((s) => s.zh) ?? false), } setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] }))) }, [job, setEdges]) return ( <>
{/* 顶部栏 */}
SKG · AI Material Pipeline

TK 二创工作台 / Node Workflow

{job && (
JOB {job.id.slice(0, 8)} · {job.message || job.status}
)}
{/* 画布 */} {/* 底部说明 */}
MVP 第一冲刺:步骤 1-6 已通 · 7-10 占位 · 拖拽节点 · 滚轮缩放
) }