Files
20260512-skg-tk/web/app/page.tsx
2026-05-13 23:29:35 +08:00

593 lines
26 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, listJobs, uploadJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} 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 handleDeleteVideo = useCallback(async (videoId: string) => {
if (!activeJobId) return
try {
const updated = await deleteGeneratedVideo(activeJobId, videoId)
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" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback
const keyframeRef: ImageRef = {
kind: "keyframe",
frame_idx: frameIdx,
label: `分镜 ${frameIdx + 1} 首帧`,
}
const orderedSelected = job.frames
.filter((f) => selectedFrames.has(f.index))
.sort((a, b) => a.timestamp - b.timestamp)
const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null
const defaultLastRef: ImageRef | null = nextFrame
? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` }
: null
const firstRef = scene.first_image ?? keyframeRef
const lastRef = scene.last_image ?? defaultLastRef
const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
const sourceObjects = frame.description?.objects?.length
? `参考元素:${frame.description.objects.slice(0, 6).map((o) => o.name).join("、")}`
: ""
const subjectDirection = scene.subject?.trim()
|| "保留首尾帧里的主体位置、手部动作关系和镜头调度;如果参考主体是人形骷髅、透明骨骼人或卡通骨骼角色,可以保留为视觉隐喻,让它正确佩戴 SKG 颈部按摩仪后状态变好。不要做恐怖、血腥或严肃医疗治疗画面。"
const productDirection = scene.product?.trim()
|| "以已上传 SKG 产品图为唯一产品真源,把参考视频或首尾帧里的任何非 SKG 产品替换成该产品;产品轮廓、颜色、材质、按键/接口/包装比例必须稳定,不要变成其他物体。"
const sceneDirection = scene.scene?.trim()
|| "借鉴参考画面的构图、可信感和空间层次,但改造成适合 SKG 产品广告的现代家居、办公或零售场景。"
const actionDirection = scene.action?.trim()
|| "按首帧到尾帧做平滑过渡,动作连续自然,镜头运动稳定,最后准确停在尾帧意图。"
const productNature = [
"产品性质:这是 SKG 白色 U 形可穿戴颈部/肩颈按摩仪,不是药品、护肤品、饮料、瓶罐、医疗器械镜头道具或普通项链。",
"正确使用方式:产品应佩戴/环绕在人的脖子和肩颈位置U 形机身落在肩颈两侧,内侧金属按摩触点贴合后颈或肩颈肌肉区域。",
"可表现的交互:手拿起产品、展开并戴到脖子上、轻按侧边圆形按键/控制区、轻微调整贴合位置、闭眼放松、肩颈从紧绷变舒展。",
"效果表达:使用后状态变好,表现为颈肩放松、姿态更自然、表情舒缓、精神恢复;如果主体是人形骷髅,可以通过放下揉脖子的手、抬头、肩颈舒展、表情/动作变轻松来表现改善。不要表现治愈疾病、骨骼修复、皮肤祛痘或夸张医疗效果。",
].join("\n")
const prompt = [
`竖屏 9:16${duration.toFixed(1)}SKG 产品短视频广告。`,
productNature,
productRefs.length
? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。`
: "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。",
"首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。",
"使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。",
"生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。",
"如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。",
"时间线0%-15% 锁住首帧构图并轻微启动15%-85% 做平滑连续运动85%-100% 缓慢贴近尾帧并稳定收住。",
`主体改造:${subjectDirection}`,
`产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。`,
`场景改造:${sceneDirection}`,
`连续动作和镜头:${actionDirection}`,
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join("") : "SKG 产品视觉主角"}`,
sourceScene,
sourceStyle,
sourceObjects,
"产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。",
"产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。",
"状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。",
"运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。",
"商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。",
"禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。",
].join("\n")
try {
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
const sourceUrl = job.url?.trim()
const updated = await generateStoryboardVideo(job.id, frameIdx, {
prompt,
duration,
first_image: firstRef,
last_image: lastRef,
product_images: productRefs,
subject_image: firstRef,
scene_image: null,
product_image: productRefs[0] ?? null,
action_image: null,
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
model,
size: "720x1280",
})
setJob(updated)
void navigator.clipboard?.writeText(prompt).catch(() => {})
toast.success("视频任务已进入 Video Gen 节点")
} catch (e) {
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [job, selectedFrames, setJob])
// 启动恢复URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const ids = (params.get("job") ?? "").split(",").filter(Boolean)
const restore = async () => {
let targetIds = ids
if (targetIds.length === 0) {
try {
const list = await listJobs()
targetIds = list.map((s) => s.id).reverse()
} catch {
return
}
}
if (targetIds.length === 0) return
const results = await Promise.all(targetIds.map((id) => getJob(id).catch(() => null)))
const valid = results.filter((j): j is Job => !!j)
if (valid.length > 0) {
setJobs(valid)
setActiveJobId(valid[valid.length - 1].id)
}
}
void restore()
// 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 runningVideo = !!job.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
if (TERMINAL.includes(job.status) && !runningVideo) {
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, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")])
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,
onDeleteVideo: handleDeleteVideo,
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, handleDeleteVideo, 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">
<div data-storyboard-dock="true" className="relative z-20 flex-shrink-0">
<StoryboardBar
job={job}
selectedFrames={selectedFrames}
focusedFrame={storyboardFrame}
onFocusFrame={setStoryboardFrame}
workbenchOpen={workbenchOpen}
onOpenWorkbench={(idx?: number) => {
if (typeof idx === "number") setStoryboardFrame(idx)
setWorkbenchOpen(true)
}}
onCloseWorkbench={() => setWorkbenchOpen(false)}
/>
<StoryboardWorkbench
job={job}
selectedFrames={selectedFrames}
open={workbenchOpen}
onClose={() => setWorkbenchOpen(false)}
onJobUpdate={setJob as any}
clipboard={clipboard}
focusedFrame={storyboardFrame}
onGenerateVideo={handleQuickGenerateVideo}
/>
</div>
<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}
/>
</main>
</>
)
}