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

362 lines
16 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, Sparkle, 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 提供)
}
const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
})
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard }: 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) 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 pushedImages = job.storyboard_images ?? []
const refOptions = pushedImages
.map((p) => {
const url = p.kind === "keyframe"
? effectiveFrameUrl(job.id, { index: p.frame_idx, cleaned_applied: false })
: (p.element_id && p.cutout_id
? (p.cutout_id === p.element_id
? cutoutUrl(job.id, p.frame_idx, p.element_id)
: cutoutUrl(job.id, p.frame_idx, p.element_id, p.cutout_id))
: "")
return { ref_id: p.ref_id, url, label: p.label || "", frame_idx: p.frame_idx, kind: p.kind }
})
.filter((r) => r.url)
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} · {pushedImages.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>
{/* 右侧详情 */}
<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">
<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>
{/* 左大图 + 右字段 */}
<div className="flex gap-5">
<div className="flex-shrink-0">
<img
src={effectiveFrameUrl(job.id, focusFrame)}
alt=""
className="rounded-lg bg-black object-contain"
style={{ width: 320, maxHeight: "52vh" }}
/>
<div className="mt-1.5 text-[10.5px] text-white/50 text-center">
{focusFrame.cleaned_applied ? "✨ 已清洗版" : "原图"}
</div>
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid grid-cols-2 gap-3">
<FieldText label="主体" placeholder="如:戴头带的骨架人"
value={form.subject} onChange={(v) => queueSave({ ...form, subject: v })} />
<FieldText label="产品" placeholder="如Goli 营养软糖"
value={form.product} onChange={(v) => queueSave({ ...form, product: v })} />
<FieldText label="场景" placeholder="如:药店柜台 / 夜晚卧室"
value={form.scene} onChange={(v) => queueSave({ ...form, scene: v })} />
<FieldNum label="时长 (秒)" placeholder="3.5"
value={form.duration} onChange={(v) => queueSave({ ...form, duration: v })} />
</div>
<FieldTextarea
label="在干什么"
placeholder="如:骨架人递给顾客一瓶 Goli · 顾客接过并查看 · 灯光柔和"
value={form.action}
onChange={(v) => queueSave({ ...form, action: v })}
rows={4}
/>
</div>
</div>
{/* 参考图选择 */}
<section>
<div className="flex items-center justify-between mb-2">
<div className="text-[12.5px] font-semibold text-white inline-flex items-center gap-1.5">
<Sparkle className="h-3.5 w-3.5 text-violet-300" />
<span className="text-[10px] text-white/40 font-mono">
· {form.reference_ids.length} / {refOptions.length}
</span>
</div>
</div>
{refOptions.length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 p-3 text-[11px] text-white/40">
· / /
</div>
) : (
<div className="grid grid-cols-8 gap-1.5">
{refOptions.map((r) => {
const checked = form.reference_ids.includes(r.ref_id)
return (
<button
key={r.ref_id}
onClick={() => {
const next = checked
? form.reference_ids.filter((x) => x !== r.ref_id)
: [...form.reference_ids, r.ref_id]
queueSave({ ...form, reference_ids: next })
}}
title={r.label}
className={`relative rounded-md overflow-hidden border-2 transition bg-white ${
checked ? "border-emerald-400 ring-2 ring-emerald-400/40" : "border-white/15 hover:border-white/40"
}`}
style={{ aspectRatio: "1/1" }}
>
<img src={r.url} alt={r.label} className="absolute inset-0 w-full h-full object-contain" />
{checked && (
<div className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-emerald-500 text-white inline-flex items-center justify-center">
<Check className="h-2.5 w-2.5" />
</div>
)}
{r.kind === "keyframe" && (
<div className="absolute top-0.5 left-0.5 text-[8px] text-white bg-amber-500/85 px-1 py-0.5 rounded font-bold leading-none">KF</div>
)}
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8px] text-white bg-black/70 truncate leading-tight">
{r.label}
</div>
</button>
)
})}
</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">
+ 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>
)
}