auto-save 2026-05-13 14:32 (~3)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
75
api/main.py
75
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
|
||||
|
||||
|
||||
|
||||
@@ -654,24 +654,30 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 裁切按钮 — 只在有 region 时可用 */}
|
||||
{hasRegion ? (
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={hasCutout ? "重新裁切原图区域" : "从原图裁切该 region 区域(保留原表情/形体)"}
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
hasCutout
|
||||
? "bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30"
|
||||
: "bg-cyan-500/30 text-white/90 hover:bg-cyan-500/50"
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Sparkle className="h-2.5 w-2.5" />}
|
||||
{isCutting ? "裁切中" : hasCutout ? "重裁" : "裁切"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="shrink-0 text-[9.5px] text-white/35 self-center" title="该元素仅文字描述 · 在原图上画框才能裁切">仅文字</span>
|
||||
)}
|
||||
{/* 裁切 / 抠图按钮(有 region 走 PIL crop,无 region 走模型抠图) */}
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={
|
||||
hasRegion
|
||||
? hasCutout ? "重新裁切原图区域" : "从原图裁切该区域(保留原表情/形体 · 瞬时)"
|
||||
: hasCutout ? "重新抠图" : "调 nano-banana 抠白底图(5-15s · 模型重画可能有差异)"
|
||||
}
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
hasCutout
|
||||
? "bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30"
|
||||
: hasRegion
|
||||
? "bg-cyan-500/30 text-white/90 hover:bg-cyan-500/50"
|
||||
: "bg-violet-500/30 text-white/90 hover:bg-violet-500/50"
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Sparkle className="h-2.5 w-2.5" />}
|
||||
{isCutting
|
||||
? (hasRegion ? "裁切中" : "抠图中")
|
||||
: hasCutout
|
||||
? "重抠"
|
||||
: (hasRegion ? "裁切" : "抠图")}
|
||||
</button>
|
||||
|
||||
{/* 删除 */}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user