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 走模型抠图) */}
+
{/* 删除 */}