Files
20260512-skg-tk/web/app/page.tsx

274 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"
import { FrameLightbox } from "@/components/lightbox"
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<Job | null>(null)
const [submitting, setSubmitting] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 → 翻译")
// 乐观更新本地状态,让轮询 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 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 自动恢复 job state
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const id = params.get("job")
if (id && !job) {
getJob(id).then(setJob).catch(() => toast.error(`找不到 job ${id}`))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 写回 URL不刷新页面
useEffect(() => {
if (!job) return
const url = new URL(window.location.href)
url.searchParams.set("job", job.id)
window.history.replaceState({}, "", url.toString())
}, [job?.id])
// 轮询 Jobdownloaded / 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,
onExpandFrame: setExpandedFrame,
}), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
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<Edge>(
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<string, boolean> = {
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 (
<>
<div className="canvas-bg" />
<main className="relative h-screen w-screen overflow-hidden">
{/* 顶部栏 */}
<header className="absolute top-4 left-6 right-6 z-20 flex items-center justify-between pointer-events-none">
<div className="pointer-events-auto">
<div className="text-[10px] uppercase tracking-[0.3em] text-[var(--text-faint)]">SKG · AI Material Pipeline</div>
<h1 className="font-serif text-[26px] leading-none mt-1 text-[var(--text-strong)]">
TK
<span className="text-[var(--text-faint)] ml-3 text-[14px] font-sans tracking-tight">/ Node Workflow</span>
</h1>
</div>
<div className="pointer-events-auto flex items-center gap-2">
{job && (
<div className="glass-node flex items-center gap-2 px-3 h-9" style={{ borderRadius: 12 }}>
<span className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">JOB</span>
<span className="text-[11.5px] font-mono text-[var(--text-strong)]">{job.id.slice(0, 8)}</span>
<span className="text-[10.5px] text-[var(--text-faint)]">·</span>
<span className="text-[11.5px] text-[var(--text-soft)]">{job.message || job.status}</span>
</div>
)}
<ThemeToggle />
</div>
</header>
{/* 画布 */}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
colorMode={resolvedTheme === "light" ? "light" : "dark"}
fitView
fitViewOptions={{ padding: 0.12 }}
minZoom={0.4}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
<Controls position="bottom-left" />
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
</ReactFlow>
{/* 底部说明 */}
<footer className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 text-[10.5px] font-mono text-[var(--text-faint)] pointer-events-none">
MVP 1-6 · 7-10 · ·
</footer>
<Toaster theme="system" position="top-right" />
{/* Lightbox 看大图 */}
{job && (
<FrameLightbox
jobId={job.id}
frames={job.frames}
activeIndex={expandedFrame}
selected={selectedFrames}
onClose={() => setExpandedFrame(null)}
onChange={setExpandedFrame}
onToggleSelect={handleToggleFrame}
/>
)}
</main>
</>
)
}