Files
20260512-skg-tk/web/app/page.tsx
2026-05-12 16:50:05 +08:00

232 lines
8.4 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 { createJob, getJob, triggerTranscribe, 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<Job | null>(null)
const [submitting, setSubmitting] = useState(false)
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const transcribeTriggeredRef = useRef<string | null>(null)
const handleSubmit = useCallback(async (url: string) => {
setSubmitting(true)
setSelectedFrames(new Set())
transcribeTriggeredRef.current = null
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())
transcribeTriggeredRef.current = null
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 handleToggleFrame = useCallback((idx: number) => {
setSelectedFrames((prev) => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx)
else if (next.size < 10) next.add(idx)
return next
})
}, [])
// 轮询 Job
useEffect(() => {
if (!job) return
if (job.status === "transcribed" || job.status === "failed") {
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])
// 抽帧完后自动触发 transcribe
useEffect(() => {
if (!job) return
if (job.status !== "frames_extracted") return
if (transcribeTriggeredRef.current === job.id) return
transcribeTriggeredRef.current = job.id
triggerTranscribe(job.id).catch((e) => toast.error("启动转录失败:" + e.message))
}, [job?.id, job?.status])
const nodeData: NodeData = useMemo(() => ({
job,
submitting,
selectedFrames,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onToggleFrame: handleToggleFrame,
}), [job, submitting, selectedFrames, handleSubmit, handleUpload, 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" />
</main>
</>
)
}