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

193 lines
8.4 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 } from "lucide-react"
import { type Job, type KeyFrame, effectiveFrameUrl, hasCutout } from "@/lib/api"
interface Props {
job: Job | null
selectedFrames: Set<number>
focusedFrame: number | null
onFocusFrame: (idx: number | null) => void
onJobUpdate?: (j: Job) => void
onOpenWorkbench?: (frameIdx?: number) => 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
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(focusedFrame ?? frames[0]?.index)}
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)
onOpenWorkbench?.(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>
)
)}
{/* 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>
)
}