388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
"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,
|
||
} from "@/lib/api"
|
||
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 3" },
|
||
] 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)
|
||
|
||
// 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 加载表单数据
|
||
useEffect(() => {
|
||
if (!job || focusedIdx === null) { setForm(emptyScene()); return }
|
||
const f = job.frames.find((x) => x.index === focusedIdx)
|
||
setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene())
|
||
}, [focusedIdx, job])
|
||
|
||
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 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 hasVideoRefs = !!(form.subject_image || form.scene_image || form.product_image || form.action_image)
|
||
const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance"
|
||
|
||
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>
|
||
|
||
{/* 4 图槽 grid:图片是参考,不是最终复刻素材 */}
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{([
|
||
{ key: "subject_image" as const, label: "参考主体", placeholder: "人物 / 手部 / 模特姿态" },
|
||
{ key: "scene_image" as const, label: "参考场景", placeholder: "药店柜台 / 卧室 / 浴室" },
|
||
{ key: "product_image" as const, label: "SKG 产品", placeholder: "产品图 / 包装 / 使用状态" },
|
||
{ key: "action_image" as const, label: "参考动作", placeholder: "拿起 / 佩戴 / 展示 / 递给顾客" },
|
||
]).map(({ key, label, placeholder }) => {
|
||
const ref = form[key]
|
||
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>
|
||
{ref && (
|
||
<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>
|
||
|
||
{/* 改造 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={!hasVideoRefs || 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={hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
|
||
>
|
||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||
调用 {currentModelLabel} 生成视频
|
||
</button>
|
||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||
用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 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>
|
||
)
|
||
}
|