"use client" import { useEffect, useState, useRef } from "react" import { X, Loader2, Check, Wand2, GripHorizontal } from "lucide-react" import { type Job, type StoryboardScene, type ImageRef, updateStoryboard, resolveImageRefUrl, uploadStoryboardAsset, } from "@/lib/api" import { ProductLibraryPicker } from "@/components/product-library-picker" import { toast } from "sonner" interface Props { job: Job | null selectedFrames: Set open: boolean onClose: () => void onJobUpdate?: (j: Job) => void clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供) focusedFrame: number | null onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void } const emptyScene = (): StoryboardScene => ({ subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [], }) const VIDEO_MODELS = [ { value: "seedance", label: "Seedance" }, { value: "kling", label: "Kling" }, { value: "veo3", label: "Veo / Voe" }, ] as const export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) const [focusedIdx, setFocusedIdx] = useState(null) const [form, setForm] = useState(emptyScene()) const [saving, setSaving] = useState(false) const [savedTick, setSavedTick] = useState(0) const [panelHeight, setPanelHeight] = useState(320) const [videoModel, setVideoModel] = useState<(typeof VIDEO_MODELS)[number]["value"]>("seedance") const [generating, setGenerating] = useState(false) const saveTimer = useRef | null>(null) const loadedFormKey = useRef("") const productFileInput = useRef(null) // Esc 关闭 useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [open, onClose]) // 外部点击某个分镜 / 元素时,直接定位到那一帧。 useEffect(() => { if (!open || !job || focusedFrame === null) return if (!selectedFrames.has(focusedFrame)) return if (!job.frames.some((f) => f.index === focusedFrame)) return setFocusedIdx(focusedFrame) }, [open, job?.id, job?.frames, selectedFrames, focusedFrame]) // 默认选第一个分镜 useEffect(() => { if (!open || !job) return if (focusedIdx !== null && job.frames.find((f) => f.index === focusedIdx)) return const frames = job.frames .filter((f) => selectedFrames.has(f.index)) .sort((a, b) => a.timestamp - b.timestamp) if (frames.length > 0) setFocusedIdx(frames[0].index) else setFocusedIdx(null) }, [open, job?.id, selectedFrames, focusedIdx, job?.frames]) // 切换 focused 时加载表单数据。不要在每次 job 轮询/保存回包时重灌, // 否则用户刚粘贴到尾帧,外部旧 job 刷新会把本地表单闪回去。 useEffect(() => { if (!job || focusedIdx === null) { loadedFormKey.current = "" setForm(emptyScene()) return } const key = `${job.id}:${focusedIdx}` if (loadedFormKey.current === key) return const f = job.frames.find((x) => x.index === focusedIdx) setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene()) loadedFormKey.current = key }, [focusedIdx, job?.id]) if (!mounted || !open || !job) return null const frames = job.frames .filter((f) => selectedFrames.has(f.index)) .sort((a, b) => a.timestamp - b.timestamp) const focusFrame = focusedIdx !== null ? job.frames.find((f) => f.index === focusedIdx) ?? null : null const focusSeq = focusFrame ? frames.findIndex((f) => f.index === focusFrame.index) + 1 : 0 const defaultFirstRef: ImageRef | null = focusFrame ? { kind: "keyframe", frame_idx: focusFrame.index, label: `分镜 ${focusSeq || focusFrame.index + 1} 首帧` } : null const nextFrame = focusFrame ? frames.find((f) => f.timestamp > focusFrame.timestamp) ?? null : null const defaultLastRef: ImageRef | null = nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${frames.findIndex((f) => f.index === nextFrame.index) + 1} 尾帧` } : null const queueSave = (next: StoryboardScene) => { setForm(next) if (!job || focusedIdx === null) return if (saveTimer.current) clearTimeout(saveTimer.current) saveTimer.current = setTimeout(async () => { setSaving(true) try { const updated = await updateStoryboard(job.id, focusedIdx, next) onJobUpdate?.(updated) setSavedTick((t) => t + 1) } catch (e) { toast.error("保存失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSaving(false) } }, 600) } const clampPanelHeight = (height: number) => { const max = typeof window === "undefined" ? 680 : Math.max(300, window.innerHeight - 190) return Math.max(180, Math.min(max, Math.round(height))) } const startResize = (e: React.PointerEvent) => { e.preventDefault() e.stopPropagation() const startY = e.clientY const startHeight = panelHeight const onMove = (ev: PointerEvent) => { setPanelHeight(clampPanelHeight(startHeight + ev.clientY - startY)) } const onUp = () => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) } window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) } const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance" const productRefs = form.product_images?.length ? form.product_images : form.product_image ? [form.product_image] : [] const setProductRefs = (refs: ImageRef[]) => { const next = refs.slice(0, 6) queueSave({ ...form, product_image: next[0] ?? null, product_images: next }) } const addProductRef = (ref: ImageRef) => { if (productRefs.length >= 6) { toast.error("最多添加 6 张产品参考") return } setProductRefs([...productRefs, ref]) } const addProductFiles = async (files: FileList | File[]) => { if (!job) return const room = 6 - productRefs.length if (room <= 0) { toast.error("最多添加 6 张产品参考") return } const imageFiles = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, room) if (imageFiles.length === 0) { toast.error("请上传图片文件") return } try { const uploaded = await Promise.all(imageFiles.map((file) => uploadStoryboardAsset(job.id, file))) setProductRefs([...productRefs, ...uploaded]) toast.success(`已上传 ${uploaded.length} 张产品参考`) } catch (e) { toast.error("产品图上传失败:" + (e instanceof Error ? e.message : String(e))) } } return (
{!focusFrame ? (
在上方选择一个分镜开始编排
) : (
{/* 顶栏:分镜信息 + 剪贴板提示 + 时长 */}
分镜 {focusSeq} {focusFrame.timestamp.toFixed(2)}s
{clipboard ? (
📋 剪贴板:{clipboard.label || (clipboard.kind === "keyframe" ? "关键帧" : "元素")}
) : (
剪贴板为空 · 在 DAG / lightbox 上点 📋 复制
)} {saving ? (<> 保存中) : savedTick > 0 ? (<> 已保存) : "自动保存"}
{/* 首尾帧:图片直接参与视频生成 */}
{([ { key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" }, { key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" }, ]).map(({ key, label, placeholder }) => { const fallback = key === "first_image" ? defaultFirstRef : key === "last_image" ? defaultLastRef : null const ref = form[key] ?? fallback const url = ref ? resolveImageRefUrl(job.id, ref) : "" return (
{label} {form[key] && ( )}
{ref && url ? ( {label} ) : (
空 · 点下方
「粘贴剪贴板」
填入图片
)}
{ref?.label || placeholder}
) })}
现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;产品参考组用于锁定 SKG 外观。
SKG 产品参考 {productRefs.length}/6
{ const files = e.target.files if (files) void addProductFiles(files) e.currentTarget.value = "" }} />
{ e.preventDefault() e.dataTransfer.dropEffect = productRefs.length < 6 ? "copy" : "none" }} onDrop={(e) => { e.preventDefault() if (e.dataTransfer.files?.length) void addProductFiles(e.dataTransfer.files) }} > {productRefs.length === 0 ? (
可添加同一 SKG 产品的不同角度,最多 6 张
) : (
{productRefs.map((ref, i) => { const url = resolveImageRefUrl(job.id, ref) return (
{url && {`产品参考}
#{i + 1}
) })}
)}
= 6} onPick={(ref) => addProductRef(ref)} /> {/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
改造目标 图片只是参考,生成时按这里把元素替换成 SKG 产品语境
queueSave({ ...form, subject: v })} placeholder="例:保留手部拿取动作,但人物改为更干净的产品演示模特" rows={2} /> queueSave({ ...form, product: v })} placeholder="例:把原视频里的瓶子 / 糖果替换成 SKG 颈椎按摩仪,突出佩戴形态" rows={2} /> queueSave({ ...form, scene: v })} placeholder="例:借鉴药店货架的可信感,但换成现代家居 / 办公桌场景" rows={2} /> queueSave({ ...form, action: v })} placeholder="例:缓慢推近,展示佩戴、按键、表情放松,镜头节奏参考原视频" rows={2} />
{/* 快速生成:直接调用生视频 API,结果显示到 Video Gen 节点 */}
生成视频
{VIDEO_MODELS.map((m) => ( ))}
直接用首帧 + 尾帧快速生成连续过渡视频;改造目标和原视频链接只作为节奏 / 镜头参考,生成进度和 MP4 会显示在 Video Gen 节点。
)}
) } function FieldText({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) { return ( ) } function FieldNum({ label, value, onChange, placeholder }: { label: string; value: number; onChange: (v: number) => void; placeholder?: string }) { return ( ) } function FieldTextarea({ label, value, onChange, placeholder, rows = 2 }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number }) { return (