auto-save 2026-05-14 02:25 (~2)
This commit is contained in:
@@ -2888,6 +2888,19 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 02:14 (+4, ~3)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 02:14 (+4, ~3)",
|
||||||
"files_changed": 4
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,6 +424,364 @@ const KEYFRAME_WIDTH = 360
|
|||||||
const THUMB_W = 64
|
const THUMB_W = 64
|
||||||
const THUMB_GAP = 6
|
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) {
|
export function KeyframeNode({ data, selected }: any) {
|
||||||
const d: NodeData = data
|
const d: NodeData = data
|
||||||
const st = keyframeStatus(d.job)
|
const st = keyframeStatus(d.job)
|
||||||
|
|||||||
Reference in New Issue
Block a user