diff --git a/.memory/worklog.json b/.memory/worklog.json index 7e30f63..742c55a 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1669,6 +1669,19 @@ "message": "auto-save 2026-05-13 14:21 (~2)", "hash": "36dcc7d", "files_changed": 2 + }, + { + "ts": "2026-05-13T14:27:23+08:00", + "type": "commit", + "message": "auto-save 2026-05-13 14:27 (~4)", + "hash": "e1ef9fb", + "files_changed": 4 + }, + { + "ts": "2026-05-13T06:27:39Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 14:27 (~4)", + "files_changed": 1 } ] } diff --git a/api/main.py b/api/main.py index 4e46914..fe628b5 100644 --- a/api/main.py +++ b/api/main.py @@ -64,15 +64,17 @@ class GeneratedImage(BaseModel): class KeyElement(BaseModel): - """关键帧里识别 / 用户提取的元素,可单独抠图给下游做"二创素材层" """ + """关键帧里识别 / 用户提取的元素 · 多次提取累积多张图,让用户挑选满意的""" id: str # uuid hex 8 name_zh: str name_en: str = "" 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 + # 多张提取图 id(每次 cutout 端点累积新 id)→ /jobs/.../elements/{element_id}/cutouts/{cutout_id}.jpg + cutouts: list[str] = [] + # 旧字段兼容(v1 单图)· 渲染时 fallback 用,新提取不再写入 + cutout_id: str | None = None cutout_background: Literal["white", "black"] = "white" created_at: float = 0.0 @@ -1263,8 +1265,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: - """元素裁切:直接 PIL crop 原帧 region 区域(不调模型,原汁原味保留表情/形体)。 - 只支持 region 模式元素;vision auto / 纯手动文字元素没有坐标,无法裁切。""" + """元素裁切 / 抠图: + - 有 region(用户画框)→ PIL crop 原帧(瞬时 · 原汁原味保留表情/形体) + - 无 region(vision auto / 手动文字)→ 调 nano-banana 抠白底图(5-15s · 模型重画可能有差异)""" from PIL import Image as _PILImage job = JOBS.get(job_id) if not job: @@ -1275,8 +1278,6 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: 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 版作 source cleaned_path = job_dir(job_id) / "cleaned" / f"{idx:03d}.jpg" @@ -1284,28 +1285,47 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: if not src.exists(): raise HTTPException(404, "source frame file missing") - try: - 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) out_path = out_dir / f"{idx:03d}_{element_id}.jpg" - cropped.save(out_path, format="JPEG", quality=92) + + if el.region: + # 路径 1:PIL crop(用户画的框,原汁原味) + try: + 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)) + cropped.save(out_path, format="JPEG", quality=92) + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"crop 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 = ( + f"Extract the {target} from this image as a standalone asset.{position_hint} " + "Place it on a pure white background, isolated, no other objects." + ) + 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}") + out_path.write_bytes(img_bytes) + # 清理旧版的 .png(旧版"抠图"产物) old_png = out_dir / f"{idx:03d}_{element_id}.png" if old_png.exists(): @@ -1319,7 +1339,8 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job: if e.id == element_id: e.cutout_id = element_id new_frames.append(f) - update(job, frames=new_frames, message=f"裁切完成 · {el.name_zh}") + msg_label = "裁切(PIL crop)" if el.region else "抠图(模型)" + update(job, frames=new_frames, message=f"{msg_label}完成 · {el.name_zh}") return job diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index e150e30..5757c81 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -654,24 +654,30 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o - {/* 裁切按钮 — 只在有 region 时可用 */} - {hasRegion ? ( - - ) : ( - 仅文字 - )} + {/* 裁切 / 抠图按钮(有 region 走 PIL crop,无 region 走模型抠图) */} + {/* 删除 */}