From 5328965e2050e727f5b97d5222cd01605e262b76 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 13 May 2026 11:45:33 +0800 Subject: [PATCH] auto-save 2026-05-13 11:45 (~4) --- .memory/worklog.json | 7 ++++ api/main.py | 68 ++++++++++++++++++++++++------------- web/components/lightbox.tsx | 67 ++++++++++++++++++++++++------------ web/lib/api.ts | 16 +++++++-- 4 files changed, 109 insertions(+), 49 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 4ea1c26..d230a2d 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1363,6 +1363,13 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 11:34 (~3)", "files_changed": 1 + }, + { + "ts": "2026-05-13T11:40:01+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 11:39 (~1)", + "hash": "9214885", + "files_changed": 1 } ] } diff --git a/api/main.py b/api/main.py index fae3ab2..c0cb95d 100644 --- a/api/main.py +++ b/api/main.py @@ -68,10 +68,12 @@ class KeyElement(BaseModel): id: str # uuid hex 8 name_zh: str name_en: str = "" - position: str = "" # 在画面中的位置描述(vision 给的) - source: Literal["auto", "manual", "region"] = "manual" # auto=vision / manual=用户加 / region=画框 - region: dict | None = None # 用户画框的相对坐标 {x,y,w,h}(用于精准抠图) - cutout_id: str | None = None # 已抠图 → /jobs/{id}/frames/{idx}/elements/{element_id}/cutout.png + position: str = "" + source: Literal["auto", "manual", "region"] = "manual" + region: dict | None = None + # 抠图(注:Gemini 输出 JPEG 不支持真透明,所以让模型在纯白 / 纯黑底上输出) + cutout_id: str | None = None # 已抠图 → /jobs/{id}/frames/{idx}/elements/{element_id}/cutout.jpg + cutout_background: Literal["white", "black"] = "white" created_at: float = 0.0 @@ -1205,12 +1207,11 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job: removed = len(f.elements) < before # 若有抠图文件也删 if removed: - cutout = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.png" - if cutout.exists(): - try: - cutout.unlink() - except OSError: - pass + 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 new_frames.append(f) if not removed: raise HTTPException(404, "element not found") @@ -1218,10 +1219,14 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job: return job +class CutoutReq(BaseModel): + background: Literal["white", "black"] = "white" + + @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: - """单元素抠图:调 nano-banana image edit 输出透明背景元素图""" - import time as _time +def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None = None) -> Job: + """单元素抠图:调 nano-banana image edit,输出纯白底 / 纯黑底元素图。 + 注:Gemini 输出 JPEG 不支持真 alpha,因此用纯色背景。""" job = JOBS.get(job_id) if not job: raise HTTPException(404, "job not found") @@ -1238,18 +1243,21 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: if not src.exists(): raise HTTPException(404, "source frame file missing") + background = (req.background if req else "white") or "white" + bg_phrase = f"pure {background}" + target = (el.name_en or el.name_zh).strip() region_phrase = _region_to_phrase(el.region) if el.region else "" if region_phrase: prompt = ( f"Extract whatever is in the {region_phrase} part of the image as a standalone asset. " - "Output on transparent background, isolated, no other objects." + f"Place it on a {bg_phrase} background, isolated, no other objects." ) else: position_hint = f" Located in the {el.position} area." if el.position else "" prompt = ( f"Extract the {target} from this image as a standalone asset.{position_hint} " - "Output on transparent background, isolated, no other objects." + f"Place it on a {bg_phrase} background, isolated, no other objects." ) try: img_bytes, _mode = _image_edit_call(src, prompt, fallback_text=False, max_attempts=3) @@ -1258,26 +1266,37 @@ 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}.png" + # 实际是 JPEG bytes,文件用 .jpg 真名 + out_path = out_dir / f"{idx:03d}_{element_id}.jpg" out_path.write_bytes(img_bytes) + # 旧版的 .png 文件(错命名为 .png 的 JPEG)也清理掉 + 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 # marker that cutout exists; URL derived from id + e.cutout_id = element_id + e.cutout_background = background new_frames.append(f) - update(job, frames=new_frames, message=f"抠图完成 · {el.name_zh}") + update(job, frames=new_frames, message=f"抠图完成 · {el.name_zh}({background} 底)") return job -@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout.png") +@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout.jpg") def get_cutout(job_id: str, idx: int, element_id: str): - p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.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") raise HTTPException(404, "cutout not found") - return FileResponse(p, media_type="image/png") + return FileResponse(p, media_type="image/jpeg") # ---------- 删除:关键帧 / 单张生成图 ---------- @@ -1305,9 +1324,10 @@ def delete_frame(job_id: str, idx: int) -> Job: # 该帧的所有元素抠图(命名前缀 {idx:03d}_) elements_dir = d / "elements" if elements_dir.exists(): - for p in elements_dir.glob(f"{idx:03d}_*.png"): - try: p.unlink() - except OSError: pass + for ext in ("png", "jpg"): + for p in elements_dir.glob(f"{idx:03d}_*.{ext}"): + try: p.unlink() + except OSError: pass # 该帧的所有生成图 gen_dir = d / "gen" if gen_dir.exists(): diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index deb6788..69f9650 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -210,12 +210,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } - const handleCutout = async (id: string) => { + const handleCutout = async (id: string, background: "white" | "black" = "white") => { setCuttingId(id) try { - const updated = await cutoutElement(jobId, f.index, id) + const updated = await cutoutElement(jobId, f.index, id, background) onJobUpdate?.(updated) - toast.success("抠图完成") + toast.success(`抠图完成(${background === "white" ? "白底" : "黑底"})`) } catch (e) { toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -589,29 +589,29 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {elements.map((e) => { const isCutting = cuttingId === e.id const hasCutout = !!e.cutout_id + const bg = e.cutout_background ?? "white" return (
- {/* 抠图缩略图 / 占位 */} + {/* 抠图缩略图 / 占位(真实底色:white/black) */}
{hasCutout ? ( {e.name_zh} ) : ( @@ -625,26 +625,49 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {e.source === "auto" && ( auto )} + {hasCutout && ( + + {bg} + + )}
{e.name_en || (无英文)}
- {/* 抠图按钮 */} - + {/* 底色 toggle(小) — 选 white/black 后点抠图 */} +
+ + +
+ + {/* 抠图状态指示(不再是按钮,因为 W/B toggle 即触发) */} + {isCutting ? ( + + 抠图中 + + ) : !hasCutout ? ( + 未抠 + ) : null} {/* 删除 */}