diff --git a/.memory/worklog.json b/.memory/worklog.json index 742c55a..83203e0 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1682,6 +1682,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 14:27 (~4)", "files_changed": 1 + }, + { + "ts": "2026-05-13T14:32:54+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 14:32 (~3)", + "hash": "4536418", + "files_changed": 3 + }, + { + "ts": "2026-05-13T06:37:39Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 4 项未提交变更 · 最近提交:auto-save 2026-05-13 14:32 (~3)", + "files_changed": 4 } ] } diff --git a/api/main.py b/api/main.py index fe628b5..d5a89cb 100644 --- a/api/main.py +++ b/api/main.py @@ -1249,13 +1249,15 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job: before = len(f.elements) f.elements = [e for e in f.elements if e.id != element_id] removed = len(f.elements) < before - # 若有抠图文件也删 + # 若有提取图也删(含多版本) if removed: - for ext in ("jpg", "png"): - cutout = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.{ext}" - if cutout.exists(): - try: cutout.unlink() - except OSError: pass + elements_dir = job_dir(job_id) / "elements" + if elements_dir.exists(): + for pat in (f"{idx:03d}_{element_id}.jpg", f"{idx:03d}_{element_id}.png", + f"{idx:03d}_{element_id}_*.jpg"): + for p in elements_dir.glob(pat): + try: p.unlink() + except OSError: pass new_frames.append(f) if not removed: raise HTTPException(404, "element not found") @@ -1265,9 +1267,9 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job: @app.post("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout", response_model=Job) def cutout_element(job_id: str, idx: int, element_id: str) -> Job: - """元素裁切 / 抠图: - - 有 region(用户画框)→ PIL crop 原帧(瞬时 · 原汁原味保留表情/形体) - - 无 region(vision auto / 手动文字)→ 调 nano-banana 抠白底图(5-15s · 模型重画可能有差异)""" + """提取元素 · 每次调用累积一张新图(不覆盖之前的): + - 有 region → PIL crop(瞬时 · 保留原表情/形体) + - 无 region → 调 nano-banana 模型生成白底图(5-15s)""" from PIL import Image as _PILImage job = JOBS.get(job_id) if not job: @@ -1279,7 +1281,6 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: if not el: raise HTTPException(404, "element not found") - # 优先用 cleaned 版作 source cleaned_path = job_dir(job_id) / "cleaned" / f"{idx:03d}.jpg" src = cleaned_path if cleaned_path.exists() else job_dir(job_id) / "frames" / f"{idx:03d}.jpg" if not src.exists(): @@ -1287,10 +1288,11 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: out_dir = job_dir(job_id) / "elements" out_dir.mkdir(parents=True, exist_ok=True) - out_path = out_dir / f"{idx:03d}_{element_id}.jpg" + # 新建一个 cutout_id append 到 element.cutouts(而非覆盖) + new_cutout_id = uuid.uuid4().hex[:8] + out_path = out_dir / f"{idx:03d}_{element_id}_{new_cutout_id}.jpg" if el.region: - # 路径 1:PIL crop(用户画的框,原汁原味) try: im = _PILImage.open(src).convert("RGB") W, H = im.size @@ -1302,15 +1304,14 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: left, top = int(x * W), int(y * H) right, bottom = int((x + w) * W), int((y + h) * H) if right - left < 4 or bottom - top < 4: - raise HTTPException(400, "region 太小,无法裁切") + raise HTTPException(400, "region 太小,无法提取") cropped = im.crop((left, top, right, bottom)) cropped.save(out_path, format="JPEG", quality=92) except HTTPException: raise except Exception as e: - raise HTTPException(500, f"crop failed: {e}") + raise HTTPException(500, f"extract failed: {e}") else: - # 路径 2:vision auto / 手动文字元素 → 调 nano-banana 抠白底图 target = (el.name_en or el.name_zh).strip() position_hint = f" Located in the {el.position} area." if el.position else "" prompt = ( @@ -1323,32 +1324,67 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: src, prompt, models=models, fallback_text=False, max_attempts=3, ) except RuntimeError as e: - raise HTTPException(500, f"cutout failed: {e}") + raise HTTPException(500, f"extract failed: {e}") out_path.write_bytes(img_bytes) - # 清理旧版的 .png(旧版"抠图"产物) - old_png = out_dir / f"{idx:03d}_{element_id}.png" - if old_png.exists(): - try: old_png.unlink() - except OSError: pass - new_frames = [] for f in job.frames: if f.index == idx: for e in f.elements: if e.id == element_id: - e.cutout_id = element_id + e.cutouts = (e.cutouts or []) + [new_cutout_id] + # 兼容:若旧字段 cutout_id 未设置,记一下让旧 UI 仍能读到一张 + if not e.cutout_id: + e.cutout_id = new_cutout_id new_frames.append(f) - msg_label = "裁切(PIL crop)" if el.region else "抠图(模型)" + msg_label = "提取(PIL)" if el.region else "提取(模型)" update(job, frames=new_frames, message=f"{msg_label}完成 · {el.name_zh}") return job +@app.delete("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}", response_model=Job) +def delete_cutout(job_id: str, idx: int, element_id: str, cutout_id: str) -> Job: + """删除该元素的某张提取图""" + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg" + if p.exists(): + try: p.unlink() + except OSError: pass + + removed = False + new_frames = [] + for f in job.frames: + if f.index == idx: + for e in f.elements: + if e.id == element_id: + if cutout_id in (e.cutouts or []): + e.cutouts = [c for c in e.cutouts if c != cutout_id] + removed = True + # cutout_id 兼容字段:若指向被删的就清空 / 移到 cutouts 第一个 + if e.cutout_id == cutout_id: + e.cutout_id = e.cutouts[0] if e.cutouts else None + new_frames.append(f) + if not removed: + raise HTTPException(404, "cutout not found in element") + update(job, frames=new_frames, message=f"删除提取图") + return job + + +@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg") +def get_cutout_versioned(job_id: str, idx: int, element_id: str, cutout_id: str): + p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg" + if not p.exists(): + raise HTTPException(404, "cutout not found") + return FileResponse(p, media_type="image/jpeg") + + @app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout.jpg") def get_cutout(job_id: str, idx: int, element_id: str): + """旧路径兼容(v1 单图)→ 找 elements/{idx}_{element_id}.jpg 或 .png""" p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.jpg" if not p.exists(): - # 兼容老数据 legacy = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.png" if legacy.exists(): return FileResponse(legacy, media_type="image/jpeg") diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 5757c81..e20450e 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -4,7 +4,7 @@ import { createPortal } from "react-dom" import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop } from "lucide-react" import { frameUrl, cleanedFrameUrl, cutoutUrl, - describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, + describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, deleteCutout, type KeyFrame, type Job, } from "@/lib/api" import { toast } from "sonner" @@ -225,14 +225,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o try { const updated = await cutoutElement(jobId, f.index, id) onJobUpdate?.(updated) - toast.success("裁切完成") + toast.success("提取完成") } catch (e) { - toast.error("裁切失败:" + (e instanceof Error ? e.message : String(e))) + toast.error("提取失败:" + (e instanceof Error ? e.message : String(e))) } finally { setCuttingId(null) } } + const handleDeleteCutout = async (elementId: string, cutoutId: string) => { + try { + const updated = await deleteCutout(jobId, f.index, elementId, cutoutId) + onJobUpdate?.(updated) + } catch (e) { + toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) + } + } + // cleaned_url 是 /jobs/.../cleaned.jpg?t= 形式(后端写时带) // 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust const cleanedSrc = (() => { @@ -605,88 +614,111 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {elements.length > 0 && ( -
+
{elements.map((e) => { const isCutting = cuttingId === e.id - const hasCutout = !!e.cutout_id + // 合并新旧字段:cutouts 优先,否则 fallback 用 cutout_id + const cutouts: string[] = (e.cutouts && e.cutouts.length > 0) + ? e.cutouts + : (e.cutout_id ? [e.cutout_id] : []) + const hasAny = cutouts.length > 0 const hasRegion = !!e.region return (
- {/* 裁切缩略图 / 占位 */} -
- {hasCutout ? ( - - {e.name_zh} - - ) : ( - - )} -
- -
-
- {e.name_zh} - {e.source === "auto" && ( - auto - )} - {e.source === "region" && ( - box - )} + {/* 顶部:名字 + 操作 */} +
+
+
+ {e.name_zh} + {e.source === "auto" && ( + auto + )} + {e.source === "region" && ( + box + )} + {cutouts.length > 0 && ( + · {cutouts.length} 张 + )} +
+
+ {e.name_en || (无英文)} +
-
- {e.name_en || (无英文)} -
-
- {/* 裁切 / 抠图按钮(有 region 走 PIL crop,无 region 走模型抠图) */} - + }`} + > + {isCutting ? : } + {isCutting ? "提取中" : hasAny ? "再提取" : "提取"} + - {/* 删除 */} - + {/* 删除整条元素 */} + +
+ + {/* 多张提取图横向 grid */} + {hasAny && ( +
+ {cutouts.map((cid, ci) => { + // 旧数据兼容:当 e.cutouts 为空、靠 cutout_id fallback 时,cid 实际是 element_id 而非 versioned id + const url = (e.cutouts && e.cutouts.length > 0) + ? cutoutUrl(jobId, f.index, e.id, cid) + : cutoutUrl(jobId, f.index, e.id) + return ( +
+ + {`${e.name_zh} + + {/* 序号 */} +
+ #{ci + 1} +
+ {/* 删除该张 — 仅 v2 多图支持,老 fallback 不显示 */} + {e.cutouts && e.cutouts.length > 0 && ( + + )} +
+ ) + })} +
+ )}
) })} diff --git a/web/lib/api.ts b/web/lib/api.ts index 89b1bb5..8f668b0 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -41,7 +41,8 @@ export interface KeyElement { position?: string source: "auto" | "manual" | "region" region?: { x: number; y: number; w: number; h: number } | null - cutout_id?: string | null + cutouts?: string[] // v2 多张提取图 id 列表 + cutout_id?: string | null // v1 兼容字段 cutout_background?: "white" | "black" created_at?: number } @@ -212,10 +213,24 @@ export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string return bust ? `${u}?t=${bust}` : u } -export function cutoutUrl(jobId: string, frameIndex: number, elementId: string): string { +export function cutoutUrl(jobId: string, frameIndex: number, elementId: string, cutoutId?: string): string { + if (cutoutId) { + return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutouts/${cutoutId}.jpg` + } return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.jpg` } +export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise { + const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, { + method: "DELETE", + }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`deleteCutout ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + export async function cleanupFrame( jobId: string, frameIdx: number,