auto-save 2026-05-13 14:32 (~3)

This commit is contained in:
2026-05-13 14:32:54 +08:00
parent e1ef9fb718
commit 4536418c76
3 changed files with 85 additions and 45 deletions

View File

@@ -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
}
]
}

View File

@@ -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 原帧(瞬时 · 原汁原味保留表情/形体)
- 无 regionvision 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:
# 路径 1PIL 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:
# 路径 2vision 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

View File

@@ -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