auto-save 2026-05-14 02:25 (~2)
This commit is contained in:
@@ -424,6 +424,364 @@ const KEYFRAME_WIDTH = 360
|
||||
const THUMB_W = 64
|
||||
const THUMB_GAP = 6
|
||||
|
||||
type ElementPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
|
||||
|
||||
function collectElementCrops(job: Job | null): ElementPreview[] {
|
||||
return job
|
||||
? job.frames.flatMap((f) =>
|
||||
(f.elements ?? [])
|
||||
.filter((e) => hasCutout(e))
|
||||
.map((e) => {
|
||||
const src = representativeCutoutUrl(job.id, f.index, e) || ""
|
||||
const cid = (e.cutouts && e.cutouts.length > 0)
|
||||
? e.cutouts[e.cutouts.length - 1]
|
||||
: (e.cutout_id ?? "")
|
||||
return {
|
||||
frameIdx: f.index,
|
||||
elementId: e.id,
|
||||
name: e.name_zh,
|
||||
src,
|
||||
cid,
|
||||
timestamp: f.timestamp,
|
||||
}
|
||||
})
|
||||
.filter((p) => p.src),
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
function videoModelLabel(model: string) {
|
||||
const m = model.toLowerCase()
|
||||
if (m.includes("kling")) return "Kling"
|
||||
if (m.includes("veo")) return "Veo 3"
|
||||
if (m.includes("seedance")) return "Seedance"
|
||||
return model || "Video"
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4. VisualLabNode — 合并镜头拆解 / 元素改造 / 生视频展示入口
|
||||
============================================================ */
|
||||
export function VisualLabNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const job = d.job
|
||||
const frames = job?.frames ?? []
|
||||
const videos = job?.generated_videos ?? []
|
||||
const jobId = job?.id
|
||||
const aspect = job && (job.width ?? 0) > 0 && (job.height ?? 0) > 0
|
||||
? `${job.width}/${job.height}`
|
||||
: "9/16"
|
||||
const elementCrops = collectElementCrops(job)
|
||||
const cleanedCount = frames.filter((x) => x.cleaned_url).length
|
||||
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
|
||||
const runningVideo = videos.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const completedVideos = videos.filter((v) => v.status === "completed" && v.url)
|
||||
const failedVideo = videos.some((v) => v.status === "failed")
|
||||
const status: NodeStatus = runningVideo
|
||||
? "running"
|
||||
: failedVideo
|
||||
? "failed"
|
||||
: frames.length > 0 || elementCrops.length > 0 || completedVideos.length > 0
|
||||
? "done"
|
||||
: keyframeStatus(job)
|
||||
|
||||
type VisualPreview =
|
||||
| { id: string; kind: "frame"; frameIdx: number; src: string; label: string; caption: string; borderClass: string }
|
||||
| { id: string; kind: "cutout"; frameIdx: number; elementId: string; cutoutId: string; src: string; label: string; caption: string; borderClass: string }
|
||||
| { id: string; kind: "video"; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string }
|
||||
|
||||
const [hoverPreview, setHoverPreview] = useState<PreviewAnchor<string> | null>(null)
|
||||
const [pinnedPreview, setPinnedPreview] = useState<PreviewAnchor<string> | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const previews: VisualPreview[] = [
|
||||
...(job && jobId ? frames.map((f) => ({
|
||||
id: `frame:${f.index}`,
|
||||
kind: "frame" as const,
|
||||
frameIdx: f.index,
|
||||
src: effectiveFrameUrl(jobId, f),
|
||||
label: `分镜 ${f.index + 1}`,
|
||||
caption: `${f.timestamp.toFixed(2)}s`,
|
||||
borderClass: "border-orange-300/50",
|
||||
})) : []),
|
||||
...elementCrops.map((p) => ({
|
||||
id: `cutout:${p.frameIdx}:${p.elementId}:${p.cid}`,
|
||||
kind: "cutout" as const,
|
||||
frameIdx: p.frameIdx,
|
||||
elementId: p.elementId,
|
||||
cutoutId: p.cid,
|
||||
src: p.src,
|
||||
label: p.name,
|
||||
caption: `分镜 ${p.frameIdx + 1}`,
|
||||
borderClass: "border-violet-300/60",
|
||||
})),
|
||||
...videos.map((v, i) => {
|
||||
const videoSrc = apiAssetUrl(v.url)
|
||||
const posterSrc = apiAssetUrl(v.poster_url)
|
||||
return {
|
||||
id: `video:${v.id}`,
|
||||
kind: "video" as const,
|
||||
videoId: v.id,
|
||||
videoSrc: v.status === "completed" && videoSrc ? videoSrc : undefined,
|
||||
posterSrc: posterSrc || undefined,
|
||||
label: `视频 ${i + 1}`,
|
||||
caption: `${videoModelLabel(v.model)} · ${v.status}`,
|
||||
borderClass: v.status === "completed" ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55",
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (pinnedPreview === null) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement
|
||||
if (t.closest('.react-flow__node[data-id="visual"]')) return
|
||||
setPinnedPreview(null)
|
||||
}
|
||||
document.addEventListener("mousedown", handler, true)
|
||||
return () => document.removeEventListener("mousedown", handler, true)
|
||||
}, [pinnedPreview])
|
||||
|
||||
const openFirstFrame = () => {
|
||||
const idx = frames[0]?.index
|
||||
if (typeof idx === "number") (d.onOpenFramePanel ?? d.onExpandFrame)(idx)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{previews.length > 0 && (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
{previews.map((p) => {
|
||||
const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx)
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
|
||||
p.kind === "frame"
|
||||
? isSelected ? "border-emerald-400 ring-2 ring-emerald-400/60" : "border-white/30 dark:border-white/20"
|
||||
: p.borderClass
|
||||
} ${p.kind === "cutout" ? "bg-white" : "bg-black"}`}
|
||||
style={{ height: 150, aspectRatio: aspect }}
|
||||
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreview(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
|
||||
setPinnedPreview((prev) => (prev?.id === p.id ? null : { id: p.id, ...anchor }))
|
||||
if (p.kind === "frame") {
|
||||
;(d.onOpenFramePanel ?? d.onExpandFrame)(p.frameIdx)
|
||||
} else if (p.kind === "cutout") {
|
||||
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
|
||||
d.onOpenStoryboard?.(p.frameIdx)
|
||||
d.onOpenWorkbench?.(p.frameIdx)
|
||||
} else {
|
||||
const video = videos.find((v) => v.id === p.videoId)
|
||||
if (video) {
|
||||
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
|
||||
toast.success("已复制视频 prompt")
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={`${p.label} · 单击钉住预览${p.kind === "frame" ? " / 打开镜头处理" : p.kind === "cutout" ? " / 进入分镜编排" : " / 复制 prompt"}`}
|
||||
className="absolute inset-0 h-full w-full overflow-hidden rounded-md"
|
||||
>
|
||||
{p.kind === "video" ? (
|
||||
p.videoSrc ? (
|
||||
<video src={p.videoSrc} poster={p.posterSrc} muted loop playsInline preload="metadata" className="absolute inset-0 h-full w-full object-cover" />
|
||||
) : p.posterSrc ? (
|
||||
<img src={p.posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-violet-950/50" />
|
||||
)
|
||||
) : (
|
||||
<img src={p.src} alt={p.label} className={`absolute inset-0 h-full w-full ${p.kind === "cutout" ? "object-contain" : "object-cover"}`} />
|
||||
)}
|
||||
{p.kind === "frame" && isSelected && (
|
||||
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
||||
)}
|
||||
<div className="absolute bottom-0 right-0 bg-black/70 px-1 py-0.5 text-[8.5px] font-mono leading-none text-white rounded-bl rounded-br-md">
|
||||
{p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "cutout" ? "元素" : "视频"}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{p.kind === "frame" && d.onCopyImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
d.onCopyImage?.({ kind: "keyframe", frame_idx: p.frameIdx, label: `${p.label} 关键帧` })
|
||||
}}
|
||||
title="复制此图(到分镜头编排工作台插槽粘贴)"
|
||||
className="absolute top-1 left-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center text-[10px] leading-none transition hover:bg-violet-400 hover:scale-110"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
)}
|
||||
|
||||
{p.kind === "cutout" && d.onCopyImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
d.onCopyImage?.({
|
||||
kind: "cutout",
|
||||
frame_idx: p.frameIdx,
|
||||
element_id: p.elementId,
|
||||
cutout_id: p.cutoutId,
|
||||
label: p.label,
|
||||
})
|
||||
}}
|
||||
title="复制此图(到分镜头编排工作台插槽粘贴)"
|
||||
className="absolute top-1 left-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center text-[10px] leading-none transition hover:bg-violet-400 hover:scale-110"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
)}
|
||||
|
||||
{p.kind === "video" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const video = videos.find((v) => v.id === p.videoId)
|
||||
if (!video) return
|
||||
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
|
||||
toast.success("已复制视频 prompt")
|
||||
}}
|
||||
className="absolute left-1 top-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-violet-400 hover:scale-110"
|
||||
title="复制视频 prompt"
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{p.kind === "frame" && d.onDeleteFrame && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`删除${p.label}?相关清洗 / 抠图 / 生成图都会一并清除。`)) d.onDeleteFrame?.(p.frameIdx)
|
||||
}}
|
||||
title="删除该关键帧"
|
||||
className="absolute top-1 right-1 z-[70] h-5 w-5 rounded-full bg-black/70 text-white/80 backdrop-blur inline-flex items-center justify-center opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{p.kind === "cutout" && d.onDeleteCutout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`删除元素提取图「${p.label}」?该 cutout 文件会被移除。`)) {
|
||||
d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cutoutId)
|
||||
}
|
||||
}}
|
||||
title="删除该提取图"
|
||||
className="absolute top-1 right-1 z-[70] h-5 w-5 rounded-full bg-black/70 text-white/85 backdrop-blur inline-flex items-center justify-center opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{p.kind === "video" && d.onDeleteVideo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
d.onDeleteVideo?.(p.videoId)
|
||||
}}
|
||||
className="absolute right-1 top-1 z-[70] h-5 w-5 rounded-full bg-rose-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-rose-400 hover:scale-110"
|
||||
title="删除这个视频任务"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const anchor = pinnedPreview ?? hoverPreview
|
||||
if (!anchor) return null
|
||||
const p = previews.find((x) => x.id === anchor.id)
|
||||
if (!p) return null
|
||||
return (
|
||||
<HoverPreview
|
||||
imgSrc={p.kind === "video" ? p.posterSrc : p.src}
|
||||
videoSrc={p.kind === "video" ? p.videoSrc : undefined}
|
||||
poster={p.kind === "video" ? p.posterSrc : undefined}
|
||||
aspect={aspect}
|
||||
label={p.label}
|
||||
caption={p.caption}
|
||||
borderClass={p.borderClass}
|
||||
visible={!!hoverPreview && !pinnedPreview}
|
||||
anchorX={anchor.x}
|
||||
anchorY={anchor.y}
|
||||
pinned={!!pinnedPreview}
|
||||
onClose={() => setPinnedPreview(null)}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
<NodeShell
|
||||
type="ai"
|
||||
status={status}
|
||||
icon={<LayoutGrid className="h-4 w-4" />}
|
||||
title="画面工作台 · Visual Lab"
|
||||
subtitle={`STEP 2-7 · ${frames.length ? `${frames.length} 帧` : "等待解析"}${videos.length ? ` · ${videos.length} 视频` : ""}`}
|
||||
selected={selected}
|
||||
pinned={d.pinnedNodes?.has("visual")}
|
||||
onTogglePin={() => d.onToggleNodePin?.("visual")}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2 text-[10.5px] text-[var(--text-soft)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
|
||||
disabled={frames.length === 0}
|
||||
className="rounded-md border border-white/10 px-2 py-1.5 text-left transition hover:border-orange-300/50 hover:bg-orange-400/10 disabled:opacity-35"
|
||||
title="打开镜头处理面板"
|
||||
>
|
||||
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{frames.length}</div>
|
||||
<div>关键帧</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.(frames.find((f) => d.selectedFrames.has(f.index))?.index ?? frames[0]?.index) }}
|
||||
disabled={!job || frames.length === 0}
|
||||
className="rounded-md border border-white/10 px-2 py-1.5 text-left transition hover:border-violet-300/50 hover:bg-violet-400/10 disabled:opacity-35"
|
||||
title="进入分镜编排"
|
||||
>
|
||||
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{elementCrops.length}</div>
|
||||
<div>元素 / 编排</div>
|
||||
</button>
|
||||
<div className="rounded-md border border-white/10 px-2 py-1.5">
|
||||
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{videos.length}</div>
|
||||
<div>视频任务</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
|
||||
{frames.length > 0 ? (
|
||||
<>
|
||||
{cleanedCount} 已清洗 · {cutoutCount} 已抠图 · {d.selectedFrames.size}/{frames.length} 入编排 · {completedVideos.length} 已完成
|
||||
</>
|
||||
) : (
|
||||
"解析后这里展示关键帧、元素和视频任务;具体处理仍在点击后的工作台完成。"
|
||||
)}
|
||||
</div>
|
||||
</NodeShell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeyframeNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const st = keyframeStatus(d.job)
|
||||
|
||||
Reference in New Issue
Block a user