auto-save 2026-05-13 21:29 (~7)

This commit is contained in:
2026-05-13 21:30:04 +08:00
parent 2befdf4e40
commit 7b59ed9bf1
7 changed files with 123 additions and 159 deletions

View File

@@ -1,8 +1,6 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle } from "lucide-react"
import { type Job, effectiveFrameUrl, hasCutout } from "@/lib/api"
import { LayoutGrid, ChevronDown, ChevronUp } from "lucide-react"
import { type Job, hasCutout } from "@/lib/api"
interface Props {
job: Job | null
@@ -15,19 +13,12 @@ interface Props {
}
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, workbenchOpen = false, onOpenWorkbench, onCloseWorkbench }: 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,
@@ -73,7 +64,6 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
if (frames.length === 0) return
const nextFrame = focusedFrame ?? frames[0].index
if (focusedFrame === null) onFocusFrame(nextFrame)
setCollapsed(false)
onOpenWorkbench?.(nextFrame)
}}
disabled={frames.length === 0}
@@ -83,123 +73,8 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
{workbenchOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{workbenchOpen ? "收起编排" : "展开编排"}
</button>
<button
onClick={() => {
const nextCollapsed = !collapsed
setCollapsed(nextCollapsed)
if (nextCollapsed) onCloseWorkbench?.()
}}
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)
setCollapsed(false)
}}
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>
)
}

View File

@@ -82,6 +82,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
.sort((a, b) => a.timestamp - b.timestamp)
const focusFrame = focusedIdx !== null ? job.frames.find((f) => f.index === focusedIdx) ?? null : null
const focusSeq = focusFrame ? frames.findIndex((f) => f.index === focusFrame.index) + 1 : 0
const defaultFirstRef: ImageRef | null = focusFrame
? { kind: "keyframe", frame_idx: focusFrame.index, label: `分镜 ${focusSeq || focusFrame.index + 1} 首帧` }
: null
const nextFrame = focusFrame ? frames.find((f) => f.timestamp > focusFrame.timestamp) ?? null : null
const defaultLastRef: ImageRef | null = nextFrame
? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${frames.findIndex((f) => f.index === nextFrame.index) + 1} 尾帧` }
: null
const queueSave = (next: StoryboardScene) => {
setForm(next)
@@ -176,21 +183,20 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
</div>
</div>
{/* 4 图槽 grid图片是参考不是最终复刻素材 */}
<div className="grid grid-cols-4 gap-4">
{/* 首尾帧:图片直接参与视频生成 */}
<div className="grid grid-cols-2 gap-4">
{([
{ key: "subject_image" as const, label: "参考主体", placeholder: "人物 / 手部 / 模特姿态" },
{ key: "scene_image" as const, label: "参考场景", placeholder: "药店柜台 / 卧室 / 浴室" },
{ key: "product_image" as const, label: "SKG 产品", placeholder: "产品图 / 包装 / 使用状态" },
{ key: "action_image" as const, label: "参考动作", placeholder: "拿起 / 佩戴 / 展示 / 递给顾客" },
{ key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" },
{ key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" },
]).map(({ key, label, placeholder }) => {
const ref = form[key]
const fallback = key === "first_image" ? defaultFirstRef : defaultLastRef
const ref = form[key] ?? fallback
const url = ref ? resolveImageRefUrl(job.id, ref) : ""
return (
<div key={key} className="rounded-lg bg-white/[0.04] border border-white/10 p-2.5">
<div className="text-[12px] text-white font-semibold mb-2 flex items-center justify-between">
<span>{label}</span>
{ref && (
{form[key] && (
<button
onClick={() => queueSave({ ...form, [key]: null })}
className="text-[10px] text-white/40 hover:text-rose-300"
@@ -238,6 +244,9 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
)
})}
</div>
<div className="rounded-md border border-violet-300/20 bg-violet-500/10 px-3 py-2 text-[11px] leading-relaxed text-violet-100/75">
📋
</div>
{/* 改造 brief明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
<section className="rounded-lg bg-white/[0.035] border border-white/10 p-3">
@@ -314,13 +323,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
}
}}
className="w-full py-3 rounded-lg text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 bg-gradient-to-r from-rose-500 to-violet-500 text-white border border-violet-300/40 shadow-lg shadow-violet-500/20 hover:from-rose-400 hover:to-violet-400 disabled:opacity-40 disabled:cursor-not-allowed"
title={`当前分镜关键帧作为首帧,调用 ${currentModelLabel} 生视频 API`}
title={`首帧和尾帧调用 ${currentModelLabel} 生视频 API`}
>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
4 MP4 Video Gen
+ / MP4 Video Gen
</div>
</section>
</div>