auto-save 2026-05-13 14:27 (~4)

This commit is contained in:
2026-05-13 14:27:23 +08:00
parent 36dcc7d67c
commit e1ef9fb718
4 changed files with 195 additions and 68 deletions

View File

@@ -680,34 +680,38 @@ export function ImageGenNode({ data, selected }: any) {
)}
</NodeShell>
{/* Portal hover 大预览 — 视口居中 */}
{mounted && hoverKey && job && (() => {
const [fi, ei] = hoverKey.split("_")
{/* Portal hover 大预览 — 浮在缩略图上方 */}
{mounted && hover && job && (() => {
const [fi, ei] = hover.key.split("_")
const frameIdx = parseInt(fi, 10)
const p = elementCrops.find((x) => x.frameIdx === frameIdx && x.elementId === ei)
if (!p) return null
const vidAspect = job.height > 0 ? job.height / job.width : 16 / 9
const maxH = Math.min(window.innerHeight * 0.7, hover.rect.top - 16)
const maxW = Math.min(window.innerWidth * 0.6, 600)
let h = maxH, w = h / vidAspect
if (w > maxW) { w = maxW; h = w * vidAspect }
const centerX = hover.rect.left + hover.rect.width / 2
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
return createPortal(
<div
className="fixed inset-0 z-[120] pointer-events-none flex items-center justify-center p-6"
style={{ animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
className="fixed z-[120] pointer-events-none"
style={{
top: hover.rect.top - h - 12,
left,
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div className="relative rounded-2xl overflow-hidden border border-violet-300/40 bg-black" style={{ boxShadow: "0 50px 120px -10px rgba(0,0,0,0.9), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 30px 80px -10px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
alt={`preview ${p.elementId}`}
className="block"
style={{
maxHeight: "85vh",
maxWidth: "70vw",
width: "auto",
height: "auto",
objectFit: "contain",
}}
style={{ width: w, height: h, objectFit: "contain" }}
/>
<div className="flex items-center justify-between px-4 py-2.5 bg-black/80 backdrop-blur-md">
<span className="text-white text-[13px] font-medium">{p.name}</span>
<span className="text-white/60 text-[11.5px] font-mono"> {p.frameIdx + 1} · </span>
<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">{p.name}</span>
<span className="text-white/60 text-[11px] font-mono"> {p.frameIdx + 1}</span>
</div>
</div>
</div>,

View File

@@ -1,16 +1,17 @@
"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"
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X, Wand2, Brush } from "lucide-react"
import { type Job, type KeyFrame, effectiveFrameUrl, cutoutUrl } from "@/lib/api"
interface Props {
job: Job | null
selectedFrames: Set<number>
onOpenStoryboard: (idx: number) => void
focusedFrame: number | null // 当前 focus 的分镜imagegen 节点 / bar 缩略图点击触发)
onFocusFrame: (idx: number | null) => void
}
export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props) {
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
@@ -20,7 +21,6 @@ export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props)
if (!job) return null
// 按时间序排已选用的分镜
const frames = job.frames
.filter((f) => selectedFrames.has(f.index))
.sort((a, b) => a.timestamp - b.timestamp)
@@ -31,6 +31,16 @@ export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props)
0,
)
// focused 分镜数据
const focusFrame = focusedFrame !== null
? job.frames.find((f) => f.index === focusedFrame) ?? null
: null
const focusSeq = focusFrame
? job.frames.filter((f) => selectedFrames.has(f.index) && f.timestamp <= focusFrame.timestamp).length
: 0
const focusElements = focusFrame?.elements ?? []
const focusCutCount = focusElements.filter((e) => e.cutout_id).length
return (
<div className="relative z-20 flex-shrink-0 border-b border-white/5 bg-black/30 backdrop-blur-xl">
{/* header */}
@@ -41,18 +51,36 @@ export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props)
<span className="text-[10px] text-white/40 font-mono shrink-0">
{frames.length} · {totalElements}
</span>
<span className="text-[10px] text-white/30 truncate">
·
</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">
{focusFrame && (
<button
onClick={() => onFocusFrame(null)}
className="text-[10.5px] text-white/60 hover:text-white inline-flex items-center gap-1 px-2 py-0.5 rounded border border-white/15 hover:border-white/30"
title="收起详情,回到列表视图"
>
<X 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>
<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 */}
@@ -67,18 +95,23 @@ export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props)
const elementCount = f.elements?.filter((e) => e.cutout_id).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={() => onOpenStoryboard(f.index)}
onClick={() => onFocusFrame(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"
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
@@ -110,35 +143,124 @@ export function StoryboardBar({ job, selectedFrames, onOpenStoryboard }: Props)
)
)}
{/* Hover 大图预览 · 视口居中大尺寸(绕开竖屏图被高度压窄的问题 */}
{mounted && hover && createPortal(
{/* 编排详情面板 — focusedFrame 有值时展开(嵌入 bar不弹 modal */}
{focusFrame && !collapsed && (
<div
className="fixed inset-0 z-[120] pointer-events-none flex items-center justify-center p-6"
style={{ animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
className="border-t border-white/10 bg-black/20 overflow-y-auto"
style={{ maxHeight: "50vh" }}
>
{/* 背景轻微暗化让大图更突出 */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div className="relative rounded-2xl overflow-hidden border border-violet-300/40 bg-black" style={{ boxShadow: "0 50px 120px -10px rgba(0,0,0,0.9), 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={{
maxHeight: "85vh",
maxWidth: "70vw",
width: "auto",
height: "auto",
objectFit: "contain",
}}
/>
<div className="flex items-center justify-between px-4 py-2.5 bg-black/80 backdrop-blur-md">
<span className="text-white text-[13px] font-medium"> {hover.seq} · {hover.frame.timestamp.toFixed(2)}s</span>
<span className="text-white/60 text-[11.5px] font-mono"></span>
<div className="flex gap-4 p-4">
{/* 左:分镜大图 */}
<div className="flex-shrink-0 flex flex-col gap-1.5" style={{ width: 260 }}>
<img
src={effectiveFrameUrl(job.id, focusFrame)}
alt={`frame ${focusFrame.index}`}
className="w-full rounded-lg object-contain bg-black"
style={{ maxHeight: "38vh" }}
/>
<div className="text-[10px] text-white/50 text-center">
{focusFrame.cleaned_applied ? "✨ 已清洗版" : "原图"} · {focusSeq} · {focusFrame.timestamp.toFixed(2)}s
</div>
</div>
{/* 右:元素 + Phase 2 操作 */}
<div className="flex-1 min-w-0 space-y-3">
<section>
<div className="text-[12px] font-semibold text-white mb-1.5 flex items-center gap-1.5">
<Sparkle className="h-3.5 w-3.5 text-violet-300" />
<span className="text-[10px] text-white/40 font-mono">· {focusCutCount}/{focusElements.length} </span>
</div>
{focusElements.length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[11px] text-white/40">
·
</div>
) : (
<div className="grid grid-cols-5 gap-1.5">
{focusElements.map((e) => (
<div key={e.id} className="rounded-md bg-white/[0.04] border border-white/10 p-1.5">
<div className="w-full aspect-square rounded bg-black/40 overflow-hidden mb-1">
{e.cutout_id ? (
<img
src={cutoutUrl(job.id, focusFrame.index, e.id)}
alt={e.name_zh}
className="w-full h-full object-contain"
/>
) : (
<div className="w-full h-full inline-flex items-center justify-center">
<Sparkle className="h-3.5 w-3.5 text-white/20" />
</div>
)}
</div>
<div className="text-[10px] text-white truncate">{e.name_zh}</div>
</div>
))}
</div>
)}
</section>
{/* Phase 2 操作占位 */}
<section className="rounded-lg border border-dashed border-violet-300/30 bg-violet-500/5 p-2.5">
<div className="text-[11.5px] font-semibold text-white mb-1.5 flex items-center gap-1.5">
<Wand2 className="h-3 w-3 text-violet-300" />
· Phase 2
</div>
<div className="grid grid-cols-3 gap-1.5">
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
<div className="text-[11px] font-medium text-white mb-0.5">📐 </div>
<div className="text-[9px] text-white/45 leading-tight"> / / </div>
</button>
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
<div className="text-[11px] font-medium text-white mb-0.5 inline-flex items-center gap-1">
<Brush className="h-3 w-3" />
</div>
<div className="text-[9px] text-white/45 leading-tight"> + </div>
</button>
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
<div className="text-[11px] font-medium text-white mb-0.5">🎬 </div>
<div className="text-[9px] text-white/45 leading-tight"> / </div>
</button>
</div>
</section>
</div>
</div>
</div>,
document.body,
</div>
)}
{/* Hover 大图预览 · 浮在缩略图下方(不挡其他界面) */}
{mounted && hover && (() => {
const vidAspect = job.height > 0 ? job.height / job.width : 16 / 9
const maxH = Math.min(window.innerHeight * 0.7, window.innerHeight - hover.rect.bottom - 16)
const maxW = Math.min(window.innerWidth * 0.6, 600)
let h = maxH, w = h / vidAspect
if (w > maxW) { w = maxW; h = w * vidAspect }
const centerX = hover.rect.left + hover.rect.width / 2
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
return createPortal(
<div
className="fixed z-[120] pointer-events-none"
style={{
top: hover.rect.bottom + 8,
left,
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 30px 80px -10px 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: w, height: h, 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>
)
}