282 lines
13 KiB
TypeScript
282 lines
13 KiB
TypeScript
"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>
|
||
)
|
||
}
|
||
|