517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
"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,
|
||
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 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 duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||
const prompt = [
|
||
`Vertical 9:16 short product video for SKG, ${duration.toFixed(1)} seconds.`,
|
||
"Use the reference materials only for composition, pose, scene mood and motion rhythm; do not copy the original video, text, watermark, logo, or non-SKG product.",
|
||
`Reference subject: ${labelOf(scene.subject_image, "clean product demonstration subject")}.`,
|
||
`Reference scene: ${labelOf(scene.scene_image, "clean modern wellness / home / retail scene")}.`,
|
||
`SKG product reference: ${labelOf(scene.product_image, "SKG product as the hero object")}.`,
|
||
`Reference action: ${labelOf(scene.action_image, "hands-on product demonstration action")}.`,
|
||
scene.subject ? `Subject change: ${scene.subject}.` : "Subject change: clean, trustworthy product demo talent or hands, no medical skeleton unless explicitly requested.",
|
||
scene.product ? `Product replacement: ${scene.product}.` : "Product replacement: make SKG product the visual focus, premium, clean, realistic, clearly visible.",
|
||
scene.scene ? `Scene adaptation: ${scene.scene}.` : "Scene adaptation: borrow only the useful layout and credibility from the reference, convert it to SKG product context.",
|
||
scene.action ? `Camera and action: ${scene.action}.` : "Camera and action: slow push-in, product reveal, close-up detail, natural hand interaction, stable commercial lighting.",
|
||
"High quality realistic commercial video, clean background, no captions, no platform UI, no TikTok watermark, no extra text.",
|
||
].join("\n")
|
||
|
||
try {
|
||
toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`)
|
||
const updated = await generateStoryboardVideo(job.id, frameIdx, {
|
||
prompt,
|
||
duration,
|
||
subject_image: scene.subject_image ?? null,
|
||
scene_image: scene.scene_image ?? null,
|
||
product_image: scene.product_image ?? null,
|
||
action_image: scene.action_image ?? 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, 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])
|
||
|
||
// 恢复已保存的分镜选择:刷新页面后,已有 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])
|
||
|
||
// 轮询 Job(downloaded / 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,
|
||
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">
|
||
<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>
|
||
</>
|
||
)
|
||
}
|