"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, KeyframeNode, ASRNode, TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode, type NodeData, } from "@/components/nodes" import { ThemeToggle } from "@/components/theme-toggle" import { Dashboard, type DashboardHandle } from "@/components/dashboard" import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, type Job } from "@/lib/api" import { VideoLightbox } from "@/components/video-lightbox" const NODE_TYPES = { input: InputNode, keyframe: KeyframeNode, asr: ASRNode, translate: TranslateNode, rewrite: RewriteNode, imagegen: ImageGenNode, videogen: VideoGenNode, compose: ComposeNode, } // 合并 input + download + split 为一个节点 // 分叉:上路 input → keyframe → imagegen → videogen ↘ // 下路 input → 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: "keyframe", type: "keyframe", x: 460, y: 60 }, { id: "asr", type: "asr", x: 460, y: 440 }, { id: "translate", type: "translate", x: 840, y: 440 }, { id: "imagegen", type: "imagegen", x: 880, y: 60 }, { id: "rewrite", type: "rewrite", x: 1220, y: 440 }, { id: "videogen", type: "videogen", x: 1260, y: 60 }, { id: "compose", type: "compose", x: 1640, y: 240 }, ] const EDGES_RAW: Array<[string, string]> = [ ["input", "keyframe"], ["input", "asr"], ["asr", "translate"], ["translate", "rewrite"], ["keyframe", "imagegen"], ["rewrite", "imagegen"], ["imagegen", "videogen"], ["videogen", "compose"], ["rewrite", "compose"], ] export default function Home() { const { resolvedTheme } = useTheme() const [jobs, setJobs] = useState([]) const [activeJobId, setActiveJobId] = useState(null) const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId]) const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) const dashboardRef = useRef(null) // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => { setJobs((prev) => { const current = prev.find((j) => j.id === activeJobId) ?? null const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater if (!next) return prev const idx = prev.findIndex((j) => j.id === next.id) if (idx < 0) { setActiveJobId(next.id) return [...prev, next] } const arr = [...prev] arr[idx] = next return arr }) }, [activeJobId]) // 新增 job + 设为 active const addJob = useCallback((j: Job) => { setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j]) setActiveJobId(j.id) }, []) const handleSwitchJob = useCallback((id: string) => { setActiveJobId(id) setSelectedFrames(new Set()) }, []) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { setSubmitting(true) setSelectedFrames(new Set()) try { const created = await createJob(url) addJob(created) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, [addJob]) 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) addJob(created) toast.success(`已上传 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } }, [addJob]) const handleAnalyze = useCallback(async () => { if (!job) return setAnalyzing(true) setSelectedFrames(new Set()) try { await analyzeJob(job.id, 5) toast.info("开始解析:拆轨 → 抽帧 → ASR → 翻译") // 乐观更新本地状态,让轮询 useEffect 重新启动 setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev) } catch (e) { toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) } finally { setAnalyzing(false) } }, [job?.id]) const handleAddManualFrame = useCallback(async (t: number) => { if (!job) return try { const updated = await addManualFrame(job.id, t) setJob(updated) toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`) } catch (e) { toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e))) } }, [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 }) }, []) // URL ?job=xxx,yyy 自动恢复多个 job useEffect(() => { const params = new URLSearchParams(window.location.search) const idsStr = params.get("job") ?? "" const ids = idsStr.split(",").filter(Boolean) if (ids.length === 0) return Promise.all(ids.map((id) => getJob(id).catch(() => null))).then((results) => { const valid = results.filter((j): j is Job => !!j) if (valid.length > 0) { setJobs(valid) setActiveJobId(valid[valid.length - 1].id) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 写回 URL(所有 jobs id 用 , 分隔) useEffect(() => { if (jobs.length === 0) return const url = new URL(window.location.href) url.searchParams.set("job", jobs.map((j) => j.id).join(",")) window.history.replaceState({}, "", url.toString()) }, [jobs.length]) // 轮询 Job(downloaded / transcribed / failed 三态停止) const prevStatusRef = useRef(null) useEffect(() => { if (!job) return // 状态切到 downloaded 时提示用户点解析(仅一次) if (job.status === "downloaded" && prevStatusRef.current !== "downloaded") { toast.info("📥 视频已就绪 — 请点 Input 节点里的「点这里开始解析」按钮", { duration: 6000 }) } prevStatusRef.current = job.status 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, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, onSubmitUrl: handleSubmit, onUploadFile: handleUpload, onAnalyze: handleAnalyze, onToggleFrame: handleToggleFrame, onExpandFrame: setExpandedFrame, onCloseExpandedFrame: () => setExpandedFrame(null), onAddManualFrame: handleAddManualFrame, onOpenVideoLightbox: () => setVideoLightboxOpen(true), onSwitchJob: handleSwitchJob, onJobUpdate: setJob as any, onOpenPanel: (key: string) => dashboardRef.current?.openPanel(key), }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob]) // 用 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?.video_url, 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 ( <>
{/* 右上工具 */}
{job && (
JOB {job.id.slice(0, 8)} · {job.message || job.status}
)}
{/* 左侧:竖向 tile 看板(极窄) */} {/* 右区:紧凑 DAG 节点流图(撑满剩余宽度) */}
{/* FrameLightbox 已嵌入 dashboard 的 keyframe drawer(embedded mode),不再独立浮动 */} {/* Video lightbox — InputNode 缩略图点击进入 */} setVideoLightboxOpen(false)} onAddFrame={handleAddManualFrame} />
) }