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

464 lines
16 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, StoryboardNode, VideoGenNode, ComposeNode, KeyframePanelNode,
type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job, type ImageRef } from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
const NODE_TYPES = {
input: InputNode,
keyframe: KeyframeNode,
asr: ASRNode,
translate: TranslateNode,
rewrite: RewriteNode,
storyboard: StoryboardNode,
videogen: VideoGenNode,
compose: ComposeNode,
keyframePanel: KeyframePanelNode,
}
const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
// 合并 input + download + split 为一个节点
// 分叉:上路 input → keyframe → storyboard → videogen ↘
// 下路 input → asr → translate → rewrite ──────→ storyboard / 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: "storyboard", type: "storyboard", 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", "storyboard"],
["rewrite", "storyboard"],
["storyboard", "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 [framePanelScale, setFramePanelScale] = useState(1)
const [framePanelPinned, setFramePanelPinned] = useState(false)
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const flowRef = useRef<any>(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("开始解析:拆轨 → 抽帧。声音文案轨单独处理")
// 乐观更新本地状态,让轮询 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 handleOpenFramePanel = useCallback((idx: number) => {
setExpandedFrame(idx)
}, [])
const handleFramePanelScaleChange = useCallback((scale: number) => {
setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
}, [])
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 handleCopyImage = useCallback((ref: ImageRef) => {
setClipboard(ref)
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
// 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])
// 恢复已保存的分镜选择:刷新页面后,已有 storyboard 的帧仍应出现在顶部编排栏。
useEffect(() => {
if (!job || job.frames.length === 0) return
const persisted = job.frames.filter((f) => !!f.storyboard).map((f) => f.index)
if (persisted.length === 0) return
setSelectedFrames((prev) => {
let changed = false
const next = new Set(prev)
for (const idx of persisted) {
if (!next.has(idx)) {
next.add(idx)
changed = true
}
}
return changed ? next : prev
})
}, [job?.id, job?.frames])
// 轮询 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", "frames_extracted", "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,
framePanelScale,
framePanelPinned,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
onToggleFrame: handleToggleFrame,
onExpandFrame: setExpandedFrame,
onOpenFramePanel: handleOpenFramePanel,
onFramePanelScaleChange: handleFramePanelScaleChange,
onFramePanelPinnedChange: setFramePanelPinned,
onCloseExpandedFrame: () => setExpandedFrame(null),
onAddManualFrame: handleAddManualFrame,
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
onSwitchJob: handleSwitchJob,
onJobUpdate: setJob as any,
onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated,
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
onOpenWorkbench: (idx?: number) => {
if (typeof idx === "number") setStoryboardFrame(idx)
setWorkbenchOpen(true)
},
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
// 用 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])
// 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。
// 已打开时点击其他关键帧只切换内容,不移动用户拖好的面板位置。
useEffect(() => {
if (!job || expandedFrame === null) {
setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID))
return
}
let shouldFocusNewPanel = false
setNodes((prev) => {
const keyframeNode = prev.find((n) => n.id === "keyframe")
const inputNode = prev.find((n) => n.id === "input")
const defaultPosition = {
x: (inputNode?.position.x ?? 40) - 820,
y: (keyframeNode?.position.y ?? 60),
}
const exists = prev.some((n) => n.id === KEYFRAME_PANEL_ID)
if (exists) {
return prev.map((n) => n.id === KEYFRAME_PANEL_ID
? {
...n,
data: nodeData,
draggable: !framePanelPinned,
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
}
: n,
)
}
shouldFocusNewPanel = true
return [
...prev,
{
id: KEYFRAME_PANEL_ID,
type: "keyframePanel",
position: defaultPosition,
data: nodeData,
draggable: !framePanelPinned,
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
selectable: true,
},
]
})
if (shouldFocusNewPanel) {
window.setTimeout(() => {
flowRef.current?.fitView?.({
nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "keyframe" }],
padding: 0.18,
duration: 260,
})
}, 0)
}
}, [job?.id, expandedFrame, framePanelPinned, 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),
rewrite: !!job && (job.transcript.some((s) => s.zh) ?? false),
storyboard: selectedFrames.size > 0,
}
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">
{/* 主题切换 — 左下角 Controls 上方(错开) */}
<div className="absolute z-30 pointer-events-auto" style={{ bottom: 180, left: 12 }}>
<ThemeToggle />
</div>
{/* 右区:顶部 storyboard bar + DAG 节点流图 */}
<section className="relative flex-1 min-h-0 flex flex-col">
<StoryboardBar
job={job}
selectedFrames={selectedFrames}
focusedFrame={storyboardFrame}
onFocusFrame={setStoryboardFrame}
/>
<div className="relative flex-1 min-h-0">
<ReactFlow
nodes={nodes}
edges={edges}
onInit={(instance) => { flowRef.current = instance }}
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" />
{/* Video lightbox — InputNode 缩略图点击进入 */}
<VideoLightbox
jobId={job?.id ?? null}
open={videoLightboxOpen}
onClose={() => setVideoLightboxOpen(false)}
onAddFrame={handleAddManualFrame}
/>
{/* 分镜头编排工作台 — 全屏覆盖 DAG */}
<StoryboardWorkbench
job={job}
selectedFrames={selectedFrames}
open={workbenchOpen}
onClose={() => setWorkbenchOpen(false)}
onJobUpdate={setJob as any}
clipboard={clipboard}
focusedFrame={storyboardFrame}
/>
</main>
</>
)
}