From 98d4ecb2817f96b52a980182113505bad61f2435 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 10:38:52 +0800 Subject: [PATCH] auto-save 2026-05-13 10:38 (~5) --- .memory/worklog.json | 13 ++ web/components/dashboard.tsx | 10 +- web/components/lightbox.tsx | 404 ++++++++++++++++++++------------- web/components/nodes/index.tsx | 47 +++- web/lib/api.ts | 65 ++++++ 5 files changed, 365 insertions(+), 174 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 871bbbd..2b57512 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1237,6 +1237,19 @@ "message": "auto-save 2026-05-13 10:27 (~1)", "hash": "e154f8b", "files_changed": 1 + }, + { + "ts": "2026-05-13T10:33:17+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 10:33 (~2)", + "hash": "3fee4a4", + "files_changed": 2 + }, + { + "ts": "2026-05-13T02:37:36Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 5 项未提交变更 · 最近提交:auto-save 2026-05-13 10:33 (~2)", + "files_changed": 5 } ] } diff --git a/web/components/dashboard.tsx b/web/components/dashboard.tsx index cad86ed..666acca 100644 --- a/web/components/dashboard.tsx +++ b/web/components/dashboard.tsx @@ -453,11 +453,17 @@ export const Dashboard = forwardRef(function Dashboard({ ) : ( job!.frames.map((f) => { const isSel = data.selectedFrames.has(f.index) + const cleaned = !!f.cleaned_url + const elCount = f.elements?.length ?? 0 + const cutCount = f.elements?.filter((e) => e.cutout_id).length ?? 0 + const tags = [`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`] + if (cleaned) tags.push("已清洗") + if (cutCount > 0) tags.push(`${cutCount}/${elCount} 抠图`) return ( (function Dashboard({ type="button" onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }} className="block w-full rounded-md overflow-hidden bg-black" - title="点击放大" + title="点击放大 · 清洗 / 提取元素" > void onToggleSelect: (idx: number) => void onJobUpdate?: (job: Job) => void - onSwitchPanel?: (key: string) => void // 生成成功后切到目标 sidebar 节点(如 "imagegen") - embedded?: boolean // true=嵌入到容器里(无 fixed),false=独立浮动卡(默认) + onSwitchPanel?: (key: string) => void + embedded?: boolean } export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, embedded = false }: Props) { const [describing, setDescribing] = useState(false) - const [generating, setGenerating] = useState(false) - const [mounted, setMounted] = useState(false) - // 自定义提取元素 — 按 frame 隔离,切换 frame 后回到同一帧时还能看到之前加的 - const [customsByFrame, setCustomsByFrame] = useState>({}) + const [cleaning, setCleaning] = useState(false) + const [cuttingId, setCuttingId] = useState(null) + const [addingZh, setAddingZh] = useState(false) + const [viewCleaned, setViewCleaned] = useState(true) // 默认显示干净版(若有) const [addInput, setAddInput] = useState("") + const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) - const customs = activeIndex !== null ? (customsByFrame[activeIndex] || []) : [] - const updateCustoms = (updater: (prev: CustomItem[]) => CustomItem[]) => { - if (activeIndex === null) return - const key = activeIndex - setCustomsByFrame((prev) => ({ ...prev, [key]: updater(prev[key] || []) })) - } - - const addCustom = async (zhRaw: string, presetEn?: string) => { - const zh = zhRaw.trim() - if (!zh || activeIndex === null) return - const id = Math.random().toString(36).slice(2, 8) - const skipTranslate = !!presetEn - updateCustoms((prev) => [...prev, { id, zh, en: presetEn || "", translating: !skipTranslate }]) - if (skipTranslate) return - try { - const en = await translateText(zh, "en") - updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en, translating: false } : c)) - } catch (e) { - updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en: "", translating: false } : c)) - toast.error("翻译失败:" + (e instanceof Error ? e.message : String(e))) - } - } - - const removeCustom = (id: string) => updateCustoms((prev) => prev.filter((c) => c.id !== id)) - useEffect(() => { if (activeIndex === null) return const onKey = (e: KeyboardEvent) => { @@ -73,44 +51,15 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o return () => window.removeEventListener("keydown", onKey) }, [activeIndex, frames.length, onClose, onChange, onToggleSelect]) - // activeIndex 是 KeyFrame.index 稳定 ID,而 frames 数组按 timestamp 排序——必须用 find 不能用 [index] const f = activeIndex !== null ? frames.find((x) => x.index === activeIndex) : undefined const arrayPos = f ? frames.findIndex((x) => x.index === f.index) : -1 if (activeIndex === null || !f || !mounted) return null const isSelected = selected.has(f.index) const desc = f.description - - const handleGenerateNext = async () => { - if (activeIndex === null || !f) return - const base = f.description?.suggested_prompt?.trim() - if (!base) { - toast.error("请先识别此分镜(右上角『识别』按钮)") - return - } - if (!selected.has(f.index)) onToggleSelect(f.index) - - const extraEn = customs.filter((c) => c.en).map((c) => c.en).join(", ") - setGenerating(true) - try { - const updated = await generateImage(jobId, f.index, { - prompt: base, - extra_prompt: extraEn, - negative_prompt: "watermark, username text, social media handle, platform logo, overlay text, captions", - model: "gemini-3-pro-image-preview", - mode: "edit", - from_selected: true, // 优先用上一轮 selected 的生成图作 reference(迭代) - }) - onJobUpdate?.(updated) - toast.success(`分镜 ${f.index + 1} 生成完成 → 「生图」`) - // 自动切到「生图」节点 drawer,用户立刻看到新图 - onSwitchPanel?.("imagegen") - } catch (e) { - toast.error("生图失败:" + (e instanceof Error ? e.message : String(e))) - } finally { - setGenerating(false) - } - } + const elements = f.elements ?? [] + const hasCleaned = !!f.cleaned_url + const showCleaned = hasCleaned && viewCleaned const handleDescribe = async () => { setDescribing(true) @@ -125,6 +74,64 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } + const handleCleanup = async () => { + setCleaning(true) + try { + const updated = await cleanupFrame(jobId, f.index) + onJobUpdate?.(updated) + setViewCleaned(true) + toast.success(`分镜 ${f.index + 1} 清洗完成`) + } catch (e) { + toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setCleaning(false) + } + } + + const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => { + const zh = name_zh.trim() + if (!zh) return + setAddingZh(true) + try { + const updated = await addElement(jobId, f.index, { name_zh: zh, name_en, position, source }) + onJobUpdate?.(updated) + } catch (e) { + toast.error("加入失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setAddingZh(false) + } + } + + const handleDeleteElement = async (id: string) => { + try { + const updated = await deleteElement(jobId, f.index, id) + onJobUpdate?.(updated) + } catch (e) { + toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) + } + } + + const handleCutout = async (id: string) => { + setCuttingId(id) + try { + const updated = await cutoutElement(jobId, f.index, id) + onJobUpdate?.(updated) + toast.success("抠图完成") + } catch (e) { + toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setCuttingId(null) + } + } + + // cleaned_url 是 /jobs/.../cleaned.jpg?t= 形式(后端写时带) + // 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust + const cleanedSrc = (() => { + if (!f.cleaned_url) return null + const ts = f.cleaned_url.match(/t=(\d+)/)?.[1] + return cleanedFrameUrl(jobId, f.index, ts) + })() + const content = (
e.stopPropagation()} @@ -141,7 +148,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)", }} > - {/* 顶部工具栏 — 切换 / 关闭,用 keyframe 橙红配色 */} + {/* 顶部工具栏 */}
- {/* 主体 — 左大图 + 右识别面板 */} + {/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
{/* 左侧大图 */} -
- {`frame +
+
+ {`frame + {/* 显示版本切换 + 状态标 */} + {hasCleaned && ( +
+ + +
+ )} +
+ + {/* 清洗按钮 */} + +
- {/* 右侧识别面板 */} -
- {/* 识别到的元素 */} + {/* 右侧识别 + 元素清单 */} +
+ {/* 识别 */}
- 识别到的元素 + Vision 识别 {desc && 已识别}
) : ( - <>点击右上角「识别」按钮,Gemini 2.5 看图给出场景描述 / 物体列表 / 风格 / 适合下游的 prompt。 + <>点击「识别」让 Gemini 给出场景 / 风格 / 候选物体 )}
) : ( @@ -249,25 +289,37 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {desc.objects && desc.objects.length > 0 && (
- 物体 · 点击加入自定义列表 + 候选物体 · 点击加入「元素清单」
- {desc.objects.map((o, i) => ( - - ))} + {desc.objects.map((o, i) => { + const alreadyIn = elements.some((e) => e.name_zh === o.name) + return ( + + ) + })}
)} @@ -275,93 +327,123 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o )} - {/* 自定义提取 — 多条 + 中英对照 */} + {/* 元素清单(持久化) */}
- 自定义提取元素 - {customs.length > 0 && ( - · {customs.length} + 元素清单 + {elements.length > 0 && ( + · {elements.length} )} + → 给下游生图作素材
- {customs.length > 0 && ( + {elements.length > 0 && (
- {customs.map((c) => ( -
-
{c.zh}
-
- {c.translating ? ( - - 翻译中… - - ) : c.en ? ( - {c.en} - ) : ( - 翻译失败 · 点击重试 - )} -
- -
- ))} + {/* 抠图缩略图 / 占位 */} +
+ {hasCutout ? ( + + {e.name_zh} + + ) : ( + + )} +
+ +
+
+ {e.name_zh} + {e.source === "auto" && ( + auto + )} +
+
+ {e.name_en || (无英文)} +
+
+ + {/* 抠图按钮 */} + + + {/* 删除 */} + +
+ ) + })}
)} + {/* 添加 */}
setAddInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing) { - e.preventDefault() - if (addInput.trim()) { - addCustom(addInput) + onChange={(ev) => setAddInput(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === "Enter" && !ev.nativeEvent.isComposing) { + ev.preventDefault() + if (addInput.trim() && !addingZh) { + handleAddElement(addInput) setAddInput("") } } }} - placeholder="输入中文 · 回车自动翻英文" + placeholder="加自定义元素 · 中文回车自动翻英文" className="flex-1 text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40" />
- - - {!desc?.suggested_prompt ? ( -
先识别此分镜,再生成
- ) : ( -
- 基于:{f.generated_images?.some((g) => g.selected) ? "上一轮已选生成图" : "原关键帧"} - {customs.length > 0 && ` + ${customs.length} 条提取元素`} -
- )} +
+ 元素持久化保存 · 已抠图的元素将作为透明素材层送到下一步「生图」节点融合 +
- -
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 4d8c1c7..3c9bb35 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -377,6 +377,22 @@ export function KeyframeNode({ data, selected }: any) { {isSel && (
)} + {/* 左上角:已清洗 + 抠图数 */} + {(f.cleaned_url || (f.elements?.some((e) => e.cutout_id))) && ( +
+ {f.cleaned_url && ( + + )} + {(() => { + const cutN = f.elements?.filter((e) => e.cutout_id).length ?? 0 + return cutN > 0 ? ( + + {cutN} + + ) : null + })()} +
+ )} {/* 时间戳 */}
{f.timestamp.toFixed(1)}s @@ -420,20 +436,29 @@ export function KeyframeNode({ data, selected }: any) { } - title="关键帧 · Keyframes" - subtitle={`STEP 2 · ffmpeg · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`} + title="关键帧 · 清洗 + 提取" + subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`} width={KEYFRAME_WIDTH} selected={selected} > - {frames.length > 0 ? ( -
- 自动 {frames.length} 张 · 选中 {d.selectedFrames.size} 张 -
- - 上方缩略图点击放大 · 在 Input 节点拖时间轴可手动加帧 - -
- ) : ( + {frames.length > 0 ? (() => { + const cleanedCount = frames.filter((x) => x.cleaned_url).length + const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0) + const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => e.cutout_id).length ?? 0), 0) + return ( +
+ 自动 {frames.length} 张 + {" · "} + 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗 + {" · "} + 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图 +
+ + 点缩略图 → 清洗水印 / 提取元素 → 抠图给下游融合 + +
+ ) + })() : (
等待解析(默认 5 张)
diff --git a/web/lib/api.ts b/web/lib/api.ts index f2e3773..630b9fc 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -34,11 +34,23 @@ export interface GeneratedImage { created_at: number } +export interface KeyElement { + id: string + name_zh: string + name_en: string + position?: string + source: "auto" | "manual" + cutout_id?: string | null + created_at?: number +} + export interface KeyFrame { index: number timestamp: number url: string description?: FrameDescription | null + cleaned_url?: string | null + elements?: KeyElement[] generated_images?: GeneratedImage[] } @@ -185,3 +197,56 @@ export function frameUrl(jobId: string, frameIndex: number): string { export function videoUrl(jobId: string): string { return `${API_BASE}/jobs/${jobId}/video.mp4` } + +export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string { + const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg` + return bust ? `${u}?t=${bust}` : u +} + +export function cutoutUrl(jobId: string, frameIndex: number, elementId: string): string { + return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.png` +} + +export async function cleanupFrame(jobId: string, frameIdx: number): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, { method: "POST" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`cleanup ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function addElement( + jobId: string, + frameIdx: number, + body: { name_zh: string; name_en?: string; position?: string; source?: "auto" | "manual" }, +): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: "manual", ...body }), + }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`addElement ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function deleteElement(jobId: string, frameIdx: number, elementId: string): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, { method: "DELETE" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`deleteElement ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, { method: "POST" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +}