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

154 lines
7.0 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 } from "@/lib/api"
interface Props {
job: Job | null
selectedFrames: Set<number>
onOpenStoryboard: (idx: number) => void
}
export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// hover preview state — portal 渲染到 body 避免被父级 overflow-x-auto clip
const [hover, setHover] = useState<{ frame: KeyFrame; seq: number; 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) => e.cutout_id).length ?? 0),
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>
<span className="text-[10px] text-white/30 truncate">
·
</span>
</div>
<button
onClick={() => setCollapsed(!collapsed)}
className="text-white/50 hover:text-white text-[11px] inline-flex items-center gap-1 shrink-0"
title={collapsed ? "展开" : "折叠"}
>
{collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
{collapsed ? "展开" : "折叠"}
</button>
</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) => e.cutout_id).length ?? 0
const totalElCount = f.elements?.length ?? 0
const cleaned = f.cleaned_applied
return (
<button
key={f.index}
ref={(el) => { btnRefs.current[f.index] = el }}
onClick={() => onOpenStoryboard(f.index)}
onMouseEnter={() => {
const el = btnRefs.current[f.index]
if (el) setHover({ frame: f, seq: i + 1, rect: el.getBoundingClientRect() })
}}
onMouseLeave={() => setHover(null)}
title={`分镜 ${i + 1} · ${f.timestamp.toFixed(2)}s${cleaned ? " · 已清洗" : ""} · ${elementCount}/${totalElCount} 元素 · 点击进入精细调整`}
className="relative shrink-0 rounded-md border border-white/15 hover:border-violet-300/60 transition shadow-lg hover:-translate-y-0.5"
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 大图预览 · portal 到 body 避免父容器 overflow clip · 跟关键帧节点 hover 同尺寸 */}
{mounted && hover && (() => {
// 预览宽度按视口动态算,最大 720跟 KEYFRAME hover 一致)
const previewMaxWidth = Math.min(720, window.innerWidth * 0.8)
const halfW = previewMaxWidth / 2
const centerX = hover.rect.left + hover.rect.width / 2
const clampedLeft = Math.max(12, Math.min(window.innerWidth - previewMaxWidth - 12, centerX - halfW))
return createPortal(
<div
className="fixed z-[120] pointer-events-none"
style={{
top: hover.rect.bottom + 8,
left: clampedLeft,
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={effectiveFrameUrl(job.id, hover.frame)}
alt={`preview ${hover.frame.index}`}
className="block"
style={{
width: 720,
maxWidth: "min(720px, 80vw)",
height: "auto",
maxHeight: "82vh",
objectFit: "contain",
}}
/>
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
<span className="text-white text-[12.5px] font-medium"> {hover.seq} · {hover.frame.timestamp.toFixed(2)}s</span>
<span className="text-white/60 text-[11px] font-mono"></span>
</div>
</div>
</div>,
document.body,
)
})()}
</div>
)
}