Files
20260512-skg-tk/web/app/page.tsx
2026-05-13 15:22:51 +08:00

376 lines
14 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, 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 { StoryboardBar } from "@/components/storyboard-bar"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, 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<Job[]>([])
const [activeJobId, setActiveJobId] = useState<string | null>(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<Set<number>>(new Set())
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const dashboardRef = useRef<DashboardHandle>(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<ReturnType<typeof setInterval> | 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
})
}, [])
const handleDeleteFrame = useCallback(async (idx: number) => {
if (!activeJobId) return
try {
const updated = await deleteFrame(activeJobId, idx)
setJob(updated)
setSelectedFrames((prev) => {
if (!prev.has(idx)) return prev
const next = new Set(prev)
next.delete(idx)
return next
})
if (expandedFrame === idx) setExpandedFrame(null)
toast.success(`分镜 ${idx + 1} 已删除`)
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, expandedFrame, setJob])
const handleDeleteGenerated = useCallback(async (frameIdx: number, genId: string) => {
if (!activeJobId) return
try {
const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId)
setJob(updated)
toast.success("生成图已删除")
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, setJob])
const handlePushToStoryboard = useCallback(async (payload: { kind: "keyframe" | "cutout"; frameIdx: number; elementId?: string; cutoutId?: string; label?: string }) => {
if (!activeJobId) return
try {
const updated = await pushStoryboardImage(activeJobId, {
kind: payload.kind,
frame_idx: payload.frameIdx,
element_id: payload.elementId,
cutout_id: payload.cutoutId,
label: payload.label,
})
setJob(updated)
toast.success("已推送到分镜头编排")
} catch (e) {
toast.error("推送失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, setJob])
// 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])
// 轮询 Jobdownloaded / transcribed / failed 三态停止)
const prevStatusRef = useRef<string | null>(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),
onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated,
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
onPushToStoryboard: handlePushToStoryboard,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handlePushToStoryboard])
// 用 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?.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 (
<>
<div className="canvas-bg" />
<main className="relative h-screen w-screen overflow-hidden flex">
{/* 主题切换 — 左下角 */}
<div className="absolute bottom-3 left-3 z-30 pointer-events-auto">
<ThemeToggle />
</div>
{/* sidebar tile 列已去掉 · Dashboard 仍渲染hidden以保留 keyframe lightbox 等 drawer portal */}
<aside className="hidden">
<Dashboard ref={dashboardRef} data={nodeData} />
</aside>
{/* 右区:顶部 storyboard bar + DAG 节点流图 */}
<section className="relative flex-1 min-h-0 flex flex-col">
<StoryboardBar
job={job}
selectedFrames={selectedFrames}
focusedFrame={storyboardFrame}
onFocusFrame={setStoryboardFrame}
onJobUpdate={setJob as any}
/>
<div className="relative flex-1 min-h-0">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
colorMode={resolvedTheme === "light" ? "light" : "dark"}
fitView
fitViewOptions={{ padding: 0.12 }}
minZoom={0.2}
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>
</div>
</section>
<Toaster theme="system" position="bottom-center" />
{/* FrameLightbox 已嵌入 dashboard 的 keyframe drawerembedded mode不再独立浮动 */}
{/* Video lightbox — InputNode 缩略图点击进入 */}
<VideoLightbox
jobId={job?.id ?? null}
open={videoLightboxOpen}
onClose={() => setVideoLightboxOpen(false)}
onAddFrame={handleAddManualFrame}
/>
</main>
</>
)
}