diff --git a/.memory/worklog.json b/.memory/worklog.json index 06bf826..fa6a35a 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1516,6 +1516,19 @@ "type": "session-heartbeat", "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 12:57 (~2)", "files_changed": 1 + }, + { + "ts": "2026-05-13T13:03:04+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 13:02 (~1)", + "hash": "b6c9e0c", + "files_changed": 1 + }, + { + "ts": "2026-05-13T05:07:38Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 5 项未提交变更 · 最近提交:auto-save 2026-05-13 13:02 (~1)", + "files_changed": 5 } ] } diff --git a/api/main.py b/api/main.py index 1710c24..4e46914 100644 --- a/api/main.py +++ b/api/main.py @@ -1261,14 +1261,11 @@ 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, req: CutoutReq | None = None) -> Job: - """单元素抠图:调 nano-banana image edit,输出纯白底 / 纯黑底元素图。 - 注:Gemini 输出 JPEG 不支持真 alpha,因此用纯色背景。""" +def cutout_element(job_id: str, idx: int, element_id: str) -> Job: + """元素裁切:直接 PIL crop 原帧 region 区域(不调模型,原汁原味保留表情/形体)。 + 只支持 region 模式元素;vision auto / 纯手动文字元素没有坐标,无法裁切。""" + from PIL import Image as _PILImage job = JOBS.get(job_id) if not job: raise HTTPException(404, "job not found") @@ -1278,48 +1275,38 @@ def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None el = next((e for e in frame.elements if e.id == element_id), None) if not el: raise HTTPException(404, "element not found") + if not el.region: + raise HTTPException(400, "该元素没有坐标,无法裁切(仅画框元素支持)") - # 优先用 cleaned 版作 reference(已去掉 logo / 水印干扰),fallback 原图 + # 优先用 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(): 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. " - 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} " - f"Place it on a {bg_phrase} background, isolated, no other objects." - ) - # 模型轮换:nano-banana-pro (首选) → gemini-2.5-flash-image (兜底确认可用) - # gemini-3.1-flash-image-preview 不支持 i2i (404),剔除 - models = [ - IMAGE_MODEL, - "gemini-2.5-flash-image", - ] try: - img_bytes, _mode = _image_edit_call( - src, prompt, models=models, fallback_text=False, max_attempts=3, - ) - except RuntimeError as e: - raise HTTPException(500, f"cutout failed: {e}") + im = _PILImage.open(src).convert("RGB") + W, H = im.size + r = el.region + x = max(0.0, min(1.0, float(r.get("x", 0)))) + y = max(0.0, min(1.0, float(r.get("y", 0)))) + w = max(0.0, min(1.0 - x, float(r.get("w", 0)))) + h = max(0.0, min(1.0 - y, float(r.get("h", 0)))) + 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 太小,无法裁切") + cropped = im.crop((left, top, right, bottom)) + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"crop failed: {e}") out_dir = job_dir(job_id) / "elements" out_dir.mkdir(parents=True, exist_ok=True) - # 实际是 JPEG bytes,文件用 .jpg 真名 out_path = out_dir / f"{idx:03d}_{element_id}.jpg" - out_path.write_bytes(img_bytes) - # 旧版的 .png 文件(错命名为 .png 的 JPEG)也清理掉 + cropped.save(out_path, format="JPEG", quality=92) + # 清理旧版的 .png(旧版"抠图"产物) old_png = out_dir / f"{idx:03d}_{element_id}.png" if old_png.exists(): try: old_png.unlink() @@ -1331,9 +1318,8 @@ def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None for e in f.elements: if e.id == element_id: e.cutout_id = element_id - e.cutout_background = background new_frames.append(f) - update(job, frames=new_frames, message=f"抠图完成 · {el.name_zh}({background} 底)") + update(job, frames=new_frames, message=f"裁切完成 · {el.name_zh}") return job diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 3b4e9a3..e150e30 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -220,14 +220,14 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } - const handleCutout = async (id: string, background: "white" | "black" = "white") => { + const handleCutout = async (id: string) => { setCuttingId(id) try { - const updated = await cutoutElement(jobId, f.index, id, background) + const updated = await cutoutElement(jobId, f.index, id) onJobUpdate?.(updated) - toast.success(`抠图完成(${background === "white" ? "白底" : "黑底"})`) + 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) } @@ -609,29 +609,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" + const hasRegion = !!e.region return (