auto-save 2026-05-13 11:45 (~4)
This commit is contained in:
@@ -1363,6 +1363,13 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 11:34 (~3)",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T11:40:01+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 11:39 (~1)",
|
||||
"hash": "9214885",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
68
api/main.py
68
api/main.py
@@ -68,10 +68,12 @@ class KeyElement(BaseModel):
|
||||
id: str # uuid hex 8
|
||||
name_zh: str
|
||||
name_en: str = ""
|
||||
position: str = "" # 在画面中的位置描述(vision 给的)
|
||||
source: Literal["auto", "manual", "region"] = "manual" # auto=vision / manual=用户加 / region=画框
|
||||
region: dict | None = None # 用户画框的相对坐标 {x,y,w,h}(用于精准抠图)
|
||||
cutout_id: str | None = None # 已抠图 → /jobs/{id}/frames/{idx}/elements/{element_id}/cutout.png
|
||||
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
|
||||
cutout_background: Literal["white", "black"] = "white"
|
||||
created_at: float = 0.0
|
||||
|
||||
|
||||
@@ -1205,12 +1207,11 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
removed = len(f.elements) < before
|
||||
# 若有抠图文件也删
|
||||
if removed:
|
||||
cutout = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.png"
|
||||
if cutout.exists():
|
||||
try:
|
||||
cutout.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
for ext in ("jpg", "png"):
|
||||
cutout = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.{ext}"
|
||||
if cutout.exists():
|
||||
try: cutout.unlink()
|
||||
except OSError: pass
|
||||
new_frames.append(f)
|
||||
if not removed:
|
||||
raise HTTPException(404, "element not found")
|
||||
@@ -1218,10 +1219,14 @@ 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) -> Job:
|
||||
"""单元素抠图:调 nano-banana image edit 输出透明背景元素图"""
|
||||
import time as _time
|
||||
def cutout_element(job_id: str, idx: int, element_id: str, req: CutoutReq | None = None) -> Job:
|
||||
"""单元素抠图:调 nano-banana image edit,输出纯白底 / 纯黑底元素图。
|
||||
注:Gemini 输出 JPEG 不支持真 alpha,因此用纯色背景。"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
@@ -1238,18 +1243,21 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
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. "
|
||||
"Output on transparent background, isolated, no other objects."
|
||||
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} "
|
||||
"Output on transparent background, isolated, no other objects."
|
||||
f"Place it on a {bg_phrase} background, isolated, no other objects."
|
||||
)
|
||||
try:
|
||||
img_bytes, _mode = _image_edit_call(src, prompt, fallback_text=False, max_attempts=3)
|
||||
@@ -1258,26 +1266,37 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
|
||||
out_dir = job_dir(job_id) / "elements"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / f"{idx:03d}_{element_id}.png"
|
||||
# 实际是 JPEG bytes,文件用 .jpg 真名
|
||||
out_path = out_dir / f"{idx:03d}_{element_id}.jpg"
|
||||
out_path.write_bytes(img_bytes)
|
||||
# 旧版的 .png 文件(错命名为 .png 的 JPEG)也清理掉
|
||||
old_png = out_dir / f"{idx:03d}_{element_id}.png"
|
||||
if old_png.exists():
|
||||
try: old_png.unlink()
|
||||
except OSError: pass
|
||||
|
||||
new_frames = []
|
||||
for f in job.frames:
|
||||
if f.index == idx:
|
||||
for e in f.elements:
|
||||
if e.id == element_id:
|
||||
e.cutout_id = element_id # marker that cutout exists; URL derived from id
|
||||
e.cutout_id = element_id
|
||||
e.cutout_background = background
|
||||
new_frames.append(f)
|
||||
update(job, frames=new_frames, message=f"抠图完成 · {el.name_zh}")
|
||||
update(job, frames=new_frames, message=f"抠图完成 · {el.name_zh}({background} 底)")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout.png")
|
||||
@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout.jpg")
|
||||
def get_cutout(job_id: str, idx: int, element_id: str):
|
||||
p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.png"
|
||||
p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.jpg"
|
||||
if not p.exists():
|
||||
# 兼容老数据
|
||||
legacy = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}.png"
|
||||
if legacy.exists():
|
||||
return FileResponse(legacy, media_type="image/jpeg")
|
||||
raise HTTPException(404, "cutout not found")
|
||||
return FileResponse(p, media_type="image/png")
|
||||
return FileResponse(p, media_type="image/jpeg")
|
||||
|
||||
|
||||
# ---------- 删除:关键帧 / 单张生成图 ----------
|
||||
@@ -1305,9 +1324,10 @@ def delete_frame(job_id: str, idx: int) -> Job:
|
||||
# 该帧的所有元素抠图(命名前缀 {idx:03d}_)
|
||||
elements_dir = d / "elements"
|
||||
if elements_dir.exists():
|
||||
for p in elements_dir.glob(f"{idx:03d}_*.png"):
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
for ext in ("png", "jpg"):
|
||||
for p in elements_dir.glob(f"{idx:03d}_*.{ext}"):
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
# 该帧的所有生成图
|
||||
gen_dir = d / "gen"
|
||||
if gen_dir.exists():
|
||||
|
||||
@@ -210,12 +210,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
}
|
||||
}
|
||||
|
||||
const handleCutout = async (id: string) => {
|
||||
const handleCutout = async (id: string, background: "white" | "black" = "white") => {
|
||||
setCuttingId(id)
|
||||
try {
|
||||
const updated = await cutoutElement(jobId, f.index, id)
|
||||
const updated = await cutoutElement(jobId, f.index, id, background)
|
||||
onJobUpdate?.(updated)
|
||||
toast.success("抠图完成")
|
||||
toast.success(`抠图完成(${background === "white" ? "白底" : "黑底"})`)
|
||||
} catch (e) {
|
||||
toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
@@ -589,29 +589,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"
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5 flex items-start gap-2"
|
||||
>
|
||||
{/* 抠图缩略图 / 占位 */}
|
||||
{/* 抠图缩略图 / 占位(真实底色:white/black) */}
|
||||
<div
|
||||
className="flex-shrink-0 rounded bg-black/40 border border-white/8 flex items-center justify-center overflow-hidden"
|
||||
style={{ width: 36, height: 36 }}
|
||||
className="flex-shrink-0 rounded border border-white/8 flex items-center justify-center overflow-hidden"
|
||||
style={{ width: 36, height: 36, background: hasCutout ? (bg === "black" ? "#000" : "#fff") : "rgba(0,0,0,0.4)" }}
|
||||
>
|
||||
{hasCutout ? (
|
||||
<a
|
||||
href={cutoutUrl(jobId, f.index, e.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="点击查看抠图原图"
|
||||
title={`点击查看 · ${bg === "white" ? "白底" : "黑底"}`}
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={cutoutUrl(jobId, f.index, e.id)}
|
||||
alt={e.name_zh}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ background: "repeating-conic-gradient(rgba(255,255,255,0.04) 0 25%, transparent 0 50%) 0 0 / 8px 8px" }}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
@@ -625,26 +625,49 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
{e.source === "auto" && (
|
||||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||||
)}
|
||||
{hasCutout && (
|
||||
<span className={`text-[8.5px] font-mono uppercase ${bg === "white" ? "text-white/80" : "text-white/50"}`}>
|
||||
{bg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-mono leading-tight truncate text-white/45">
|
||||
{e.name_en || <span className="text-white/30">(无英文)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 抠图按钮 */}
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={hasCutout ? "重新抠图" : "调 nano-banana 抠透明背景元素"}
|
||||
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-violet-500/30 text-white/90 hover:bg-violet-500/50"
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Wand2 className="h-2.5 w-2.5" />}
|
||||
{isCutting ? "抠图中" : hasCutout ? "重抠" : "抠图"}
|
||||
</button>
|
||||
{/* 底色 toggle(小) — 选 white/black 后点抠图 */}
|
||||
<div className="shrink-0 inline-flex rounded overflow-hidden border border-white/15">
|
||||
<button
|
||||
onClick={() => handleCutout(e.id, "white")}
|
||||
disabled={isCutting}
|
||||
title="白底抠图"
|
||||
className={`text-[9.5px] px-1.5 py-0.5 transition ${
|
||||
hasCutout && bg === "white"
|
||||
? "bg-white text-black"
|
||||
: "bg-white/[0.05] text-white/70 hover:bg-white/15"
|
||||
}`}
|
||||
>W</button>
|
||||
<button
|
||||
onClick={() => handleCutout(e.id, "black")}
|
||||
disabled={isCutting}
|
||||
title="黑底抠图"
|
||||
className={`text-[9.5px] px-1.5 py-0.5 transition ${
|
||||
hasCutout && bg === "black"
|
||||
? "bg-black text-white"
|
||||
: "bg-black/30 text-white/70 hover:bg-black/60"
|
||||
}`}
|
||||
>B</button>
|
||||
</div>
|
||||
|
||||
{/* 抠图状态指示(不再是按钮,因为 W/B toggle 即触发) */}
|
||||
{isCutting ? (
|
||||
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 bg-violet-500/30 text-white/90">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" /> 抠图中
|
||||
</span>
|
||||
) : !hasCutout ? (
|
||||
<span className="shrink-0 text-[9.5px] text-white/35 self-center">未抠</span>
|
||||
) : null}
|
||||
|
||||
{/* 删除 */}
|
||||
<button
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface KeyElement {
|
||||
source: "auto" | "manual" | "region"
|
||||
region?: { x: number; y: number; w: number; h: number } | null
|
||||
cutout_id?: string | null
|
||||
cutout_background?: "white" | "black"
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
@@ -212,7 +213,7 @@ export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string
|
||||
}
|
||||
|
||||
export function cutoutUrl(jobId: string, frameIndex: number, elementId: string): string {
|
||||
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.png`
|
||||
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.jpg`
|
||||
}
|
||||
|
||||
export async function cleanupFrame(
|
||||
@@ -291,8 +292,17 @@ export async function deleteGeneratedImage(jobId: string, frameIdx: number, genI
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cutoutElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, { method: "POST" })
|
||||
export async function cutoutElement(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
elementId: string,
|
||||
background: "white" | "black" = "white",
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutout`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ background }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`)
|
||||
|
||||
Reference in New Issue
Block a user