diff --git a/.memory/worklog.json b/.memory/worklog.json index d152c04..c526768 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -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 } ] } diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 868937c..9a937da 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -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 | null>(null) + const [pinnedPreview, setPinnedPreview] = useState | null>(null) + const rootRef = useRef(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 ( +
+ {previews.length > 0 && ( +
+ {previews.map((p) => { + const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx) + return ( +
setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })} + onMouseLeave={() => setHoverPreview(null)} + > + + + {p.kind === "frame" && d.onCopyImage && ( + + )} + + {p.kind === "cutout" && d.onCopyImage && ( + + )} + + {p.kind === "video" && ( + + )} + + {p.kind === "frame" && d.onDeleteFrame && ( + + )} + + {p.kind === "cutout" && d.onDeleteCutout && ( + + )} + + {p.kind === "video" && d.onDeleteVideo && ( + + )} +
+ ) + })} +
+ )} + + {(() => { + const anchor = pinnedPreview ?? hoverPreview + if (!anchor) return null + const p = previews.find((x) => x.id === anchor.id) + if (!p) return null + return ( + setPinnedPreview(null)} + /> + ) + })()} + + } + 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")} + > +
+ + +
+
{videos.length}
+
视频任务
+
+
+
+ {frames.length > 0 ? ( + <> + {cleanedCount} 已清洗 · {cutoutCount} 已抠图 · {d.selectedFrames.size}/{frames.length} 入编排 · {completedVideos.length} 已完成 + + ) : ( + "解析后这里展示关键帧、元素和视频任务;具体处理仍在点击后的工作台完成。" + )} +
+
+
+ ) +} + export function KeyframeNode({ data, selected }: any) { const d: NodeData = data const st = keyframeStatus(d.job)