auto-save 2026-05-14 02:25 (~2)

This commit is contained in:
2026-05-14 02:25:30 +08:00
parent 66a7a818fc
commit eace01e94a
2 changed files with 371 additions and 0 deletions

View File

@@ -2888,6 +2888,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-14 02:14 (+4, ~3)",
"files_changed": 4
},
{
"ts": "2026-05-14T02:20:00+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 02:19 (~4)",
"hash": "66a7a81",
"files_changed": 4
},
{
"ts": "2026-05-13T18:23:11Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 02:19 (~4)",
"files_changed": 1
}
]
}

View File

@@ -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)