Files
20260512-skg-tk/web/components/storyboard-workbench.tsx

539 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 { 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<number>
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> | 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<number | null>(null)
const [form, setForm] = useState<StoryboardScene>(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<ReturnType<typeof setTimeout> | null>(null)
const loadedFormKey = useRef("")
const productFileInput = useRef<HTMLInputElement | null>(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 (
<div
className="relative z-20 flex-shrink-0 border-t border-white/5 border-b border-white/10 bg-black/70 backdrop-blur-xl shadow-2xl"
style={{ height: panelHeight, animation: "drawer-in 0.2s cubic-bezier(0.32, 0.72, 0, 1)" }}
>
<div className="h-full overflow-y-auto pb-4">
{!focusFrame ? (
<div className="px-4 py-5 text-[12px] text-white/40"></div>
) : (
<div className="max-w-6xl mx-auto px-4 py-4 space-y-4">
{/* 顶栏:分镜信息 + 剪贴板提示 + 时长 */}
<div className="flex items-center justify-between gap-4">
<div className="text-[15.5px] font-semibold text-white">
{focusSeq}
<span className="text-white/40 text-[12px] font-mono ml-2">{focusFrame.timestamp.toFixed(2)}s</span>
</div>
<div className="flex items-center gap-3">
{clipboard ? (
<div className="text-[11px] text-emerald-300 inline-flex items-center gap-1">
📋 <span className="text-white font-medium truncate max-w-[180px]">{clipboard.label || (clipboard.kind === "keyframe" ? "关键帧" : "元素")}</span>
</div>
) : (
<div className="text-[11px] text-white/40"> · DAG / lightbox 📋 </div>
)}
<label className="inline-flex items-center gap-1.5 text-[11px] text-white/55">
<input
type="number"
step="0.1"
min="0"
value={form.duration || ""}
onChange={(e) => queueSave({ ...form, duration: parseFloat(e.target.value) || 0 })}
placeholder="3.5"
className="w-16 text-[12.5px] px-2 py-1 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40 text-center"
/>
</label>
<span className="text-[11px] text-white/45 font-mono inline-flex items-center gap-1">
{saving ? (<><Loader2 className="h-2.5 w-2.5 animate-spin" /> </>)
: savedTick > 0 ? (<><Check className="h-2.5 w-2.5 text-emerald-300" /> </>)
: "自动保存"}
</span>
<button
onClick={onClose}
className="h-7 px-2.5 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center gap-1 text-[11.5px]"
title="收起编排 (Esc)"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
{/* 首尾帧:图片直接参与视频生成 */}
<div className="grid grid-cols-2 gap-4">
{([
{ 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 (
<div key={key} className="rounded-lg bg-white/[0.04] border border-white/10 p-2.5">
<div className="text-[12px] text-white font-semibold mb-2 flex items-center justify-between">
<span>{label}</span>
{form[key] && (
<button
onClick={() => queueSave({ ...form, [key]: null })}
className="text-[10px] text-white/40 hover:text-rose-300"
title="清空"
>
</button>
)}
</div>
<div
className="relative rounded-md overflow-hidden border-2 border-dashed border-white/15 bg-white/[0.03] flex items-center justify-center mb-2"
style={{ aspectRatio: "1/1" }}
>
{ref && url ? (
<img src={url} alt={label} className="absolute inset-0 w-full h-full object-contain bg-white" />
) : (
<div className="text-center text-white/30 text-[10.5px] p-3 leading-relaxed">
· <br /><br />
</div>
)}
</div>
<button
onClick={() => {
if (!clipboard) {
toast.error("先在某张图上点 📋 复制")
return
}
queueSave({ ...form, [key]: clipboard })
toast.success(`已粘贴到「${label}`)
}}
disabled={!clipboard}
className={`w-full text-[11.5px] py-1.5 rounded-md inline-flex items-center justify-center gap-1 transition font-medium ${
clipboard
? "bg-violet-500 hover:bg-violet-400 text-white"
: "bg-white/[0.04] text-white/30 cursor-not-allowed"
}`}
title={clipboard ? `粘贴剪贴板的图:${clipboard.label || ""}` : "剪贴板为空"}
>
📋
</button>
<div className="mt-1 text-[9.5px] text-white/30 truncate text-center">
{ref?.label || placeholder}
</div>
</div>
)
})}
</div>
<div className="rounded-md border border-violet-300/20 bg-violet-500/10 px-3 py-2 text-[11px] leading-relaxed text-violet-100/75">
SKG
</div>
<section className="rounded-lg bg-white/[0.04] border border-white/10 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] text-white font-semibold">
SKG
<span className="ml-1.5 text-[10px] font-mono text-white/35">{productRefs.length}/6</span>
</div>
<button
onClick={() => {
if (!clipboard) {
toast.error("先在产品图、关键帧或生成图上点 📋 复制")
return
}
if (productRefs.length >= 6) {
toast.error("最多添加 6 张产品参考")
return
}
addProductRef(clipboard)
toast.success("已添加产品参考")
}}
disabled={!clipboard || productRefs.length >= 6}
className={`rounded px-2 py-1 text-[10.5px] font-medium transition ${
clipboard && productRefs.length < 6
? "bg-violet-500 text-white hover:bg-violet-400"
: "bg-white/[0.04] text-white/30 cursor-not-allowed"
}`}
title={clipboard ? "把剪贴板图片添加到产品参考组" : "剪贴板为空"}
>
📋
</button>
<button
onClick={() => productFileInput.current?.click()}
disabled={productRefs.length >= 6}
className={`rounded px-2 py-1 text-[10.5px] font-medium transition ${
productRefs.length < 6
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/[0.04] text-white/30 cursor-not-allowed"
}`}
title="从本地上传产品图"
>
</button>
<input
ref={productFileInput}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files) void addProductFiles(files)
e.currentTarget.value = ""
}}
/>
</div>
<div
onDragOver={(e) => {
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 ? (
<div className="rounded-md border border-dashed border-white/15 bg-black/25 px-3 py-4 text-center text-[11px] text-white/30">
SKG 6
</div>
) : (
<div className="grid grid-cols-6 gap-2">
{productRefs.map((ref, i) => {
const url = resolveImageRefUrl(job.id, ref)
return (
<div key={`${ref.kind}-${ref.frame_idx}-${ref.element_id ?? ""}-${ref.cutout_id ?? ""}-${i}`} className="relative overflow-hidden rounded-md border border-white/10 bg-white" style={{ aspectRatio: "1/1" }}>
{url && <img src={url} alt={`产品参考 ${i + 1}`} className="absolute inset-0 h-full w-full object-contain" />}
<div className="absolute left-0 top-0 rounded-br bg-black/70 px-1 text-[9px] font-mono text-white">#{i + 1}</div>
<button
onClick={() => setProductRefs(productRefs.filter((_, idx) => idx !== i))}
className="absolute right-0 top-0 rounded-bl bg-rose-500/85 px-1 text-[10px] text-white hover:bg-rose-400"
title="移除这张产品参考"
>
×
</button>
</div>
)
})}
</div>
)}
</div>
</section>
<ProductLibraryPicker
jobId={job.id}
compact
buttonLabel="加入"
title="产品融合 · SKG 白底图库"
disabled={productRefs.length >= 6}
onPick={(ref) => addProductRef(ref)}
/>
{/* 改造 brief明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
<section className="rounded-lg bg-white/[0.035] border border-white/10 p-3">
<div className="text-[12.5px] font-semibold text-white mb-2">
<span className="ml-2 text-[10px] font-normal text-white/35">
SKG
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<FieldTextarea
label="主体怎么改"
value={form.subject || ""}
onChange={(v) => queueSave({ ...form, subject: v })}
placeholder="例:保留手部拿取动作,但人物改为更干净的产品演示模特"
rows={2}
/>
<FieldTextarea
label="产品怎么替换"
value={form.product || ""}
onChange={(v) => queueSave({ ...form, product: v })}
placeholder="例:把原视频里的瓶子 / 糖果替换成 SKG 颈椎按摩仪,突出佩戴形态"
rows={2}
/>
<FieldTextarea
label="场景怎么借鉴"
value={form.scene || ""}
onChange={(v) => queueSave({ ...form, scene: v })}
placeholder="例:借鉴药店货架的可信感,但换成现代家居 / 办公桌场景"
rows={2}
/>
<FieldTextarea
label="动作和镜头"
value={form.action || ""}
onChange={(v) => queueSave({ ...form, action: v })}
placeholder="例:缓慢推近,展示佩戴、按键、表情放松,镜头节奏参考原视频"
rows={2}
/>
</div>
</section>
{/* 快速生成:直接调用生视频 API结果显示到 Video Gen 节点 */}
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12.5px] font-semibold text-white"></div>
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-black/35 p-0.5">
{VIDEO_MODELS.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setVideoModel(m.value)}
className={`h-6 rounded px-2 text-[10.5px] transition ${
videoModel === m.value
? "bg-violet-500 text-white shadow"
: "text-white/50 hover:bg-white/10 hover:text-white"
}`}
title={`使用 ${m.label} 生成视频`}
>
{m.label}
</button>
))}
</div>
</div>
<button
disabled={focusedIdx === null || generating}
onClick={async () => {
if (focusedIdx === null) return
queueSave(form)
setGenerating(true)
try {
await onGenerateVideo?.(focusedIdx, form, videoModel)
} finally {
setGenerating(false)
}
}}
className="w-full py-3 rounded-lg text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 bg-gradient-to-r from-rose-500 to-violet-500 text-white border border-violet-300/40 shadow-lg shadow-violet-500/20 hover:from-rose-400 hover:to-violet-400 disabled:opacity-40 disabled:cursor-not-allowed"
title={`用首帧和尾帧调用 ${currentModelLabel} 生视频 API`}
>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
+ / MP4 Video Gen
</div>
</section>
</div>
)}
</div>
<button
type="button"
onPointerDown={startResize}
className="absolute bottom-0 left-0 right-0 z-10 h-4 cursor-ns-resize border-t border-white/10 bg-black/50 text-white/45 hover:bg-violet-500/25 hover:text-white inline-flex items-center justify-center transition"
title="拖动上推 / 下拉调整编排区高度"
>
<GripHorizontal className="h-3.5 w-3.5" />
</button>
</div>
)
}
function FieldText({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
return (
<label className="block">
<div className="text-[10.5px] text-white/55 mb-1">{label}</div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full text-[12.5px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
/>
</label>
)
}
function FieldNum({ label, value, onChange, placeholder }: { label: string; value: number; onChange: (v: number) => void; placeholder?: string }) {
return (
<label className="block">
<div className="text-[10.5px] text-white/55 mb-1">{label}</div>
<input
type="number"
step="0.1"
min="0"
value={value || ""}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
placeholder={placeholder}
className="w-full text-[12.5px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
/>
</label>
)
}
function FieldTextarea({ label, value, onChange, placeholder, rows = 2 }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number }) {
return (
<label className="block">
<div className="text-[10.5px] text-white/55 mb-1">{label}</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full text-[12.5px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40 resize-none"
/>
</label>
)
}