105 lines
4.5 KiB
TypeScript
105 lines
4.5 KiB
TypeScript
"use client"
|
||
import { useState } from "react"
|
||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle } from "lucide-react"
|
||
import { type Job, effectiveFrameUrl } from "@/lib/api"
|
||
|
||
interface Props {
|
||
job: Job | null
|
||
selectedFrames: Set<number>
|
||
onExpandFrame: (idx: number) => void
|
||
}
|
||
|
||
export function StoryboardBar({ job, selectedFrames, onExpandFrame }: Props) {
|
||
const [collapsed, setCollapsed] = useState(false)
|
||
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}
|
||
onClick={() => onExpandFrame(f.index)}
|
||
title={`分镜 ${i + 1} · ${f.timestamp.toFixed(2)}s${cleaned ? " · 已清洗" : ""} · ${elementCount}/${totalElCount} 元素 · 点击编辑`}
|
||
className="group relative shrink-0 rounded-md border border-white/15 hover:border-violet-300/60 overflow-hidden 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"
|
||
/>
|
||
{/* 左上:序号 */}
|
||
<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">
|
||
<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>
|
||
)
|
||
)}
|
||
</div>
|
||
)
|
||
}
|