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

282 lines
13 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, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X } from "lucide-react"
import { type Job, type KeyFrame, cutoutUrl, effectiveFrameUrl, hasCutout, removeStoryboardImage } from "@/lib/api"
import { toast } from "sonner"
interface Props {
job: Job | null
selectedFrames: Set<number>
focusedFrame: number | null
onFocusFrame: (idx: number | null) => void
onJobUpdate?: (j: Job) => void
onOpenWorkbench?: () => void
}
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate, onOpenWorkbench }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const [hover, setHover] = useState<{ src: string; topLabel: string; subLabel: string; rect: DOMRect } | null>(null)
const btnRefs = useRef<Record<number, HTMLButtonElement | null>>({})
if (!job) return null
const frames = job.frames
.filter((f) => selectedFrames.has(f.index))
.sort((a, b) => a.timestamp - b.timestamp)
const aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16"
const totalElements = frames.reduce(
(sum, f) => sum + (f.elements?.filter((e) => hasCutout(e)).length ?? 0),
0,
)
// focused 分镜数据
// focus 用来高亮分镜缩略图 + 顶部 indicator不再触发底部详情面板展开
const focusFrame = focusedFrame !== null
? job.frames.find((f) => f.index === focusedFrame) ?? null
: null
const focusSeq = focusFrame
? frames.findIndex((f) => f.index === focusFrame.index) + 1
: 0
// 用户已"上推"到分镜头编排区的图片
const pushedImages = job.storyboard_images ?? []
const frameSeqByIdx: Record<number, number> = {}
frames.forEach((f, i) => { frameSeqByIdx[f.index] = i + 1 })
const handleRemovePushed = async (refId: string) => {
try {
const updated = await removeStoryboardImage(job.id, refId)
onJobUpdate?.(updated)
} catch (e) {
toast.error("移除失败:" + (e instanceof Error ? e.message : String(e)))
}
}
return (
<div className="relative z-20 flex-shrink-0 border-b border-white/5 bg-black/30 backdrop-blur-xl">
{/* header */}
<div className="flex items-center justify-between px-4 py-2">
<div className="flex items-center gap-2 min-w-0">
<LayoutGrid className="h-3.5 w-3.5 text-violet-300 shrink-0" />
<span className="text-[12.5px] font-semibold text-white shrink-0"></span>
<span className="text-[10px] text-white/40 font-mono shrink-0">
{frames.length} · {totalElements}
</span>
{focusFrame ? (
<span className="text-[10px] text-violet-300/90 shrink-0 inline-flex items-center gap-1">
· <span className="text-white font-medium"> {focusSeq}</span>
</span>
) : (
<span className="text-[10px] text-white/30 truncate">
·
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{onOpenWorkbench && (
<button
onClick={onOpenWorkbench}
disabled={frames.length === 0}
className="text-[11px] px-2.5 py-1 rounded-md bg-gradient-to-r from-violet-500 to-pink-500 hover:from-violet-400 hover:to-pink-400 text-white inline-flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed font-medium shadow"
title={frames.length === 0 ? "先到关键帧节点选用分镜" : "全屏进入分镜头编排工作台"}
>
<LayoutGrid className="h-3 w-3" />
</button>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="text-white/50 hover:text-white text-[11px] inline-flex items-center gap-1"
title={collapsed ? "展开" : "折叠"}
>
{collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
{collapsed ? "展开" : "折叠"}
</button>
</div>
</div>
{/* thumbnails row */}
{!collapsed && (
frames.length === 0 ? (
<div className="px-4 pb-3 text-[11px] text-white/40">
·
</div>
) : (
<div className="px-4 pb-3 flex gap-2 overflow-x-auto">
{frames.map((f, i) => {
const elementCount = f.elements?.filter((e) => hasCutout(e)).length ?? 0
const totalElCount = f.elements?.length ?? 0
const cleaned = f.cleaned_applied
const isFocused = focusedFrame === f.index
return (
<button
key={f.index}
ref={(el) => { btnRefs.current[f.index] = el }}
onClick={() => onFocusFrame(f.index)}
onMouseEnter={() => {
const el = btnRefs.current[f.index]
if (el) setHover({
src: effectiveFrameUrl(job.id, f),
topLabel: `分镜 ${i + 1}`,
subLabel: `${f.timestamp.toFixed(2)}s`,
rect: el.getBoundingClientRect(),
})
}}
onMouseLeave={() => setHover(null)}
title={`分镜 ${i + 1} · ${f.timestamp.toFixed(2)}s${cleaned ? " · 已清洗" : ""} · ${elementCount}/${totalElCount} 元素 · 点击进入编排`}
className={`relative shrink-0 rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
isFocused
? "border-violet-300 ring-2 ring-violet-300/70"
: "border-white/15 hover:border-violet-300/60"
}`}
style={{ width: 88, aspectRatio: aspect }}
>
<img
src={effectiveFrameUrl(job.id, f)}
alt={`frame ${f.index}`}
className="absolute inset-0 w-full h-full object-cover rounded-md"
/>
<div className="absolute top-1 left-1 text-[9.5px] font-bold text-white bg-violet-500/85 backdrop-blur px-1.5 py-0.5 rounded">
#{i + 1}
</div>
{cleaned && (
<div className="absolute top-1 right-1 text-[9px] text-white bg-cyan-500/85 backdrop-blur px-1 py-0.5 rounded font-bold" title="已清洗">
</div>
)}
<div className="absolute bottom-0 right-0 left-0 px-1.5 py-0.5 text-[9px] font-mono text-white bg-gradient-to-t from-black/85 to-transparent flex items-center justify-between rounded-b-md">
<span>{f.timestamp.toFixed(1)}s</span>
{totalElCount > 0 && (
<span className="inline-flex items-center gap-0.5">
<Sparkle className="h-2 w-2" />
{elementCount}/{totalElCount}
</span>
)}
</div>
</button>
)
})}
</div>
)
)}
{/* 已推送到分镜头编排区的图片 */}
{!collapsed && (
<div className="border-t border-white/10 px-4 py-2 bg-black/10">
<div className="text-[10px] text-white/45 mb-1.5 inline-flex items-center gap-1">
<Sparkle className="h-2.5 w-2.5 text-violet-300" />
· {pushedImages.length}
<span className="text-white/30 ml-1"> </span>
</div>
{pushedImages.length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[10.5px] text-white/35">
· / /
</div>
) : (
<div className="flex gap-1.5 overflow-x-auto pb-1">
{pushedImages.map((p) => {
const seq = frameSeqByIdx[p.frame_idx] ?? p.frame_idx + 1
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) // legacy
: cutoutUrl(job.id, p.frame_idx, p.element_id, p.cutout_id))
: "")
const isFocusFrame = focusedFrame === p.frame_idx
return (
<div
key={p.ref_id}
className={`group/p relative shrink-0 rounded-md overflow-hidden border bg-white transition hover:-translate-y-0.5 ${
isFocusFrame
? "border-violet-300 ring-2 ring-violet-300/60"
: "border-white/15 hover:border-violet-300/50"
}`}
style={{ width: 88, height: 88 }}
>
<button
onClick={() => onFocusFrame(p.frame_idx)}
onMouseEnter={(ev) => {
if (!url) return
setHover({
src: url,
topLabel: p.label || (p.kind === "keyframe" ? "关键帧" : "元素"),
subLabel: `分镜 #${seq}`,
rect: (ev.currentTarget as HTMLElement).getBoundingClientRect(),
})
}}
onMouseLeave={() => setHover(null)}
title={`${p.label || p.kind} · 来自分镜 #${seq} · 点击聚焦该分镜`}
className="absolute inset-0 w-full h-full"
>
{url && <img src={url} alt={p.label} className="absolute inset-0 w-full h-full object-contain" />}
<div className="absolute top-0.5 left-0.5 text-[8.5px] font-bold text-white bg-violet-500/85 backdrop-blur px-1 py-0.5 rounded leading-none">
#{seq}
</div>
{p.kind === "keyframe" && (
<div className="absolute top-0.5 right-5 text-[8.5px] text-white bg-amber-500/85 backdrop-blur px-1 py-0.5 rounded leading-none">
KF
</div>
)}
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8.5px] text-white bg-black/70 truncate leading-tight">
{p.label || (p.kind === "keyframe" ? "关键帧" : "元素")}
</div>
</button>
{/* 右上移除按钮hover 显示) */}
<button
onClick={(ev) => { ev.stopPropagation(); handleRemovePushed(p.ref_id) }}
title="从分镜头编排移除"
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-sm bg-black/70 text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover/p:opacity-100 transition"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)
})}
</div>
)}
</div>
)}
{/* Hover 预览 · 浮在缩略图正下方bar 在顶部 fixed下方是 DAG 画布区) */}
{mounted && hover && (() => {
const vidAspect = job.height > 0 ? job.height / job.width : 16 / 9
const w = 280
const h = w * vidAspect
const gap = 10
const centerX = hover.rect.left + hover.rect.width / 2
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
const top = hover.rect.bottom + gap
return createPortal(
<div
className="fixed z-[120] pointer-events-none"
style={{
left, top,
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<div className="rounded-lg overflow-hidden border-2 border-violet-300/50 bg-white shadow-2xl">
<img
src={hover.src}
alt="preview"
className="block"
style={{ width: w, height: h, objectFit: "cover" }}
/>
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between gap-2">
<span className="truncate">{hover.topLabel}</span>
<span className="text-white/60 font-mono shrink-0">{hover.subLabel}</span>
</div>
</div>
</div>,
document.body,
)
})()}
</div>
)
}