Files
20260512-skg-tk/web/components/storyboard-workbench.tsx
2026-05-13 20:51:23 +08:00

397 lines
19 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,
} 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
videoConfigured?: boolean
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, videoConfigured, 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"
const videoUnavailable = videoConfigured === false
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>
{videoUnavailable && (
<div className="mb-2 rounded-md border border-amber-300/25 bg-amber-500/10 px-2.5 py-2 text-[10.5px] leading-relaxed text-amber-100/80">
SKG ezlink /videos
</div>
)}
<button
disabled={videoUnavailable || !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={videoUnavailable ? "当前视频 API 未开通" : hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
{videoUnavailable ? "视频 API 未开通" : `调用 ${currentModelLabel} 生成视频`}
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
{videoUnavailable
? "需要 IT 给当前 key 分组开通 /videos或配置外部 VIDEO_API_BASE_URL/VIDEO_API_KEY 后再生成。"
: "用当前 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>
)
}