Files
20260512-skg-tk/web/components/storyboard-workbench.tsx
2026-05-13 18:18:55 +08:00

394 lines
18 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, type ReactNode } from "react"
import { createPortal } from "react-dom"
import { X, LayoutGrid, Loader2, Check, Wand2 } from "lucide-react"
import {
type Job, type StoryboardScene, type ImageRef,
effectiveFrameUrl, 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
}
const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
})
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame }: 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 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 aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return createPortal(
<div
className="fixed inset-0 z-[200] bg-black/92 backdrop-blur-xl flex flex-col"
style={{ animation: "drawer-in 0.2s cubic-bezier(0.32, 0.72, 0, 1)" }}
>
{/* Header */}
<header className="flex items-center justify-between px-5 py-2.5 border-b border-white/10 bg-black/40 flex-shrink-0">
<div className="flex items-center gap-2 text-white">
<LayoutGrid className="h-4 w-4 text-violet-300" />
<span className="text-[14px] font-semibold"></span>
<span className="text-[11px] text-white/40 font-mono ml-2">
{frames.length}
</span>
</div>
<div className="flex items-center gap-3">
<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-8 px-3 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center gap-1.5 text-[12px]"
title="返回 DAG (Esc)"
>
<X className="h-3.5 w-3.5" /> DAG
</button>
</div>
</header>
<div className="flex flex-1 min-h-0">
{/* 左侧分镜列表 */}
<aside
className="flex-shrink-0 border-r border-white/10 overflow-y-auto bg-black/20"
style={{ width: 232 }}
>
{frames.length === 0 ? (
<div className="p-4 text-[11px] text-white/40 leading-relaxed">
·
</div>
) : (
<div className="p-2 space-y-1">
{frames.map((f, i) => {
const isFocused = focusedIdx === f.index
const sb = f.storyboard
return (
<button
key={f.index}
onClick={() => setFocusedIdx(f.index)}
className={`w-full text-left rounded-lg p-2 transition flex gap-2 items-start ${
isFocused
? "bg-violet-500/25 border border-violet-300/60"
: "hover:bg-white/[0.04] border border-transparent"
}`}
>
<div
className="flex-shrink-0 rounded-md overflow-hidden bg-black"
style={{ width: 64, aspectRatio: aspect }}
>
<img src={effectiveFrameUrl(job.id, f)} className="w-full h-full object-cover" alt="" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11.5px] font-semibold text-white flex items-center gap-1.5">
<span> {i + 1}</span>
{sb?.duration ? (
<span className="text-[9px] text-violet-300/80 font-mono">{sb.duration}s</span>
) : null}
</div>
<div className="text-[9.5px] text-white/40 font-mono">
{f.timestamp.toFixed(2)}s · {f.elements?.length ?? 0}
</div>
{sb?.subject && (
<div className="text-[10px] text-white/65 truncate mt-0.5" title={sb.subject}>
{sb.subject}
</div>
)}
</div>
</button>
)
})}
</div>
)}
</aside>
{/* 右侧详情 — 4 图槽 + 时长 */}
<main className="flex-1 overflow-y-auto">
{!focusFrame ? (
<div className="p-8 text-[13px] text-white/40"></div>
) : (
<div className="max-w-5xl mx-auto p-6 space-y-5">
{/* 顶栏:分镜信息 + 剪贴板提示 + 时长 */}
<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>
</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>
{/* 生成按钮Phase 2 占位) */}
<section>
<button
disabled
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/35 to-violet-500/35 text-white/70 border border-violet-300/30 disabled:opacity-60 cursor-not-allowed"
title="Phase 2 实施"
>
<Wand2 className="h-4 w-4" />
/ Phase 2
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
4 + + SKG Seedance / Kling / Veo3
</div>
</section>
</div>
)}
</main>
</div>
{/* 底部快捷 */}
<footer className="border-t border-white/10 px-5 py-1.5 text-[10px] text-white/40 font-mono text-center bg-black/40">
ESC DAG ·
</footer>
</div>,
document.body,
)
}
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>
)
}