auto-save 2026-05-13 14:38 (~4)
This commit is contained in:
@@ -1682,6 +1682,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 14:27 (~4)",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T14:32:54+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 14:32 (~3)",
|
||||
"hash": "4536418",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T06:37:39Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 4 项未提交变更 · 最近提交:auto-save 2026-05-13 14:32 (~3)",
|
||||
"files_changed": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
86
api/main.py
86
api/main.py
@@ -1249,13 +1249,15 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
before = len(f.elements)
|
||||
f.elements = [e for e in f.elements if e.id != element_id]
|
||||
removed = len(f.elements) < before
|
||||
# 若有抠图文件也删
|
||||
# 若有提取图也删(含多版本)
|
||||
if removed:
|
||||
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
|
||||
elements_dir = job_dir(job_id) / "elements"
|
||||
if elements_dir.exists():
|
||||
for pat in (f"{idx:03d}_{element_id}.jpg", f"{idx:03d}_{element_id}.png",
|
||||
f"{idx:03d}_{element_id}_*.jpg"):
|
||||
for p in elements_dir.glob(pat):
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
new_frames.append(f)
|
||||
if not removed:
|
||||
raise HTTPException(404, "element not found")
|
||||
@@ -1265,9 +1267,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:
|
||||
"""元素裁切 / 抠图:
|
||||
- 有 region(用户画框)→ PIL crop 原帧(瞬时 · 原汁原味保留表情/形体)
|
||||
- 无 region(vision auto / 手动文字)→ 调 nano-banana 抠白底图(5-15s · 模型重画可能有差异)"""
|
||||
"""提取元素 · 每次调用累积一张新图(不覆盖之前的):
|
||||
- 有 region → PIL crop(瞬时 · 保留原表情/形体)
|
||||
- 无 region → 调 nano-banana 模型生成白底图(5-15s)"""
|
||||
from PIL import Image as _PILImage
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
@@ -1279,7 +1281,6 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
if not el:
|
||||
raise HTTPException(404, "element not found")
|
||||
|
||||
# 优先用 cleaned 版作 source
|
||||
cleaned_path = job_dir(job_id) / "cleaned" / f"{idx:03d}.jpg"
|
||||
src = cleaned_path if cleaned_path.exists() else job_dir(job_id) / "frames" / f"{idx:03d}.jpg"
|
||||
if not src.exists():
|
||||
@@ -1287,10 +1288,11 @@ 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}.jpg"
|
||||
# 新建一个 cutout_id append 到 element.cutouts(而非覆盖)
|
||||
new_cutout_id = uuid.uuid4().hex[:8]
|
||||
out_path = out_dir / f"{idx:03d}_{element_id}_{new_cutout_id}.jpg"
|
||||
|
||||
if el.region:
|
||||
# 路径 1:PIL crop(用户画的框,原汁原味)
|
||||
try:
|
||||
im = _PILImage.open(src).convert("RGB")
|
||||
W, H = im.size
|
||||
@@ -1302,15 +1304,14 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
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 太小,无法裁切")
|
||||
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}")
|
||||
raise HTTPException(500, f"extract 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 = (
|
||||
@@ -1323,32 +1324,67 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
|
||||
src, prompt, models=models, fallback_text=False, max_attempts=3,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(500, f"cutout failed: {e}")
|
||||
raise HTTPException(500, f"extract failed: {e}")
|
||||
out_path.write_bytes(img_bytes)
|
||||
|
||||
# 清理旧版的 .png(旧版"抠图"产物)
|
||||
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
|
||||
e.cutouts = (e.cutouts or []) + [new_cutout_id]
|
||||
# 兼容:若旧字段 cutout_id 未设置,记一下让旧 UI 仍能读到一张
|
||||
if not e.cutout_id:
|
||||
e.cutout_id = new_cutout_id
|
||||
new_frames.append(f)
|
||||
msg_label = "裁切(PIL crop)" if el.region else "抠图(模型)"
|
||||
msg_label = "提取(PIL)" if el.region else "提取(模型)"
|
||||
update(job, frames=new_frames, message=f"{msg_label}完成 · {el.name_zh}")
|
||||
return job
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}", response_model=Job)
|
||||
def delete_cutout(job_id: str, idx: int, element_id: str, cutout_id: str) -> Job:
|
||||
"""删除该元素的某张提取图"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg"
|
||||
if p.exists():
|
||||
try: p.unlink()
|
||||
except OSError: pass
|
||||
|
||||
removed = False
|
||||
new_frames = []
|
||||
for f in job.frames:
|
||||
if f.index == idx:
|
||||
for e in f.elements:
|
||||
if e.id == element_id:
|
||||
if cutout_id in (e.cutouts or []):
|
||||
e.cutouts = [c for c in e.cutouts if c != cutout_id]
|
||||
removed = True
|
||||
# cutout_id 兼容字段:若指向被删的就清空 / 移到 cutouts 第一个
|
||||
if e.cutout_id == cutout_id:
|
||||
e.cutout_id = e.cutouts[0] if e.cutouts else None
|
||||
new_frames.append(f)
|
||||
if not removed:
|
||||
raise HTTPException(404, "cutout not found in element")
|
||||
update(job, frames=new_frames, message=f"删除提取图")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg")
|
||||
def get_cutout_versioned(job_id: str, idx: int, element_id: str, cutout_id: str):
|
||||
p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg"
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "cutout not found")
|
||||
return FileResponse(p, media_type="image/jpeg")
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutout.jpg")
|
||||
def get_cutout(job_id: str, idx: int, element_id: str):
|
||||
"""旧路径兼容(v1 单图)→ 找 elements/{idx}_{element_id}.jpg 或 .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")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop } from "lucide-react"
|
||||
import {
|
||||
frameUrl, cleanedFrameUrl, cutoutUrl,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, deleteCutout,
|
||||
type KeyFrame, type Job,
|
||||
} from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
@@ -225,14 +225,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
try {
|
||||
const updated = await cutoutElement(jobId, f.index, id)
|
||||
onJobUpdate?.(updated)
|
||||
toast.success("裁切完成")
|
||||
toast.success("提取完成")
|
||||
} catch (e) {
|
||||
toast.error("裁切失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setCuttingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCutout = async (elementId: string, cutoutId: string) => {
|
||||
try {
|
||||
const updated = await deleteCutout(jobId, f.index, elementId, cutoutId)
|
||||
onJobUpdate?.(updated)
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
// cleaned_url 是 /jobs/.../cleaned.jpg?t=<timestamp> 形式(后端写时带)
|
||||
// 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust
|
||||
const cleanedSrc = (() => {
|
||||
@@ -605,88 +614,111 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
</div>
|
||||
|
||||
{elements.length > 0 && (
|
||||
<div className="space-y-1.5 mb-2">
|
||||
<div className="space-y-2 mb-2">
|
||||
{elements.map((e) => {
|
||||
const isCutting = cuttingId === e.id
|
||||
const hasCutout = !!e.cutout_id
|
||||
// 合并新旧字段:cutouts 优先,否则 fallback 用 cutout_id
|
||||
const cutouts: string[] = (e.cutouts && e.cutouts.length > 0)
|
||||
? e.cutouts
|
||||
: (e.cutout_id ? [e.cutout_id] : [])
|
||||
const hasAny = cutouts.length > 0
|
||||
const hasRegion = !!e.region
|
||||
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"
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 p-2"
|
||||
>
|
||||
{/* 裁切缩略图 / 占位 */}
|
||||
<div
|
||||
className="flex-shrink-0 rounded border border-white/8 flex items-center justify-center overflow-hidden bg-black/40"
|
||||
style={{ width: 36, height: 36 }}
|
||||
>
|
||||
{hasCutout ? (
|
||||
<a
|
||||
href={cutoutUrl(jobId, f.index, e.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="点击查看原图"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={cutoutUrl(jobId, f.index, e.id)}
|
||||
alt={e.name_zh}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Sparkle className="h-3.5 w-3.5 text-white/25" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pr-5">
|
||||
<div className="flex items-center gap-1 text-white text-[11.5px] font-medium leading-tight truncate">
|
||||
<span className="truncate">{e.name_zh}</span>
|
||||
{e.source === "auto" && (
|
||||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||||
)}
|
||||
{e.source === "region" && (
|
||||
<span className="text-[8.5px] text-cyan-300/80 font-mono uppercase">box</span>
|
||||
)}
|
||||
{/* 顶部:名字 + 操作 */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0 pr-5">
|
||||
<div className="flex items-center gap-1 text-white text-[12px] font-medium leading-tight truncate">
|
||||
<span className="truncate">{e.name_zh}</span>
|
||||
{e.source === "auto" && (
|
||||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||||
)}
|
||||
{e.source === "region" && (
|
||||
<span className="text-[8.5px] text-cyan-300/80 font-mono uppercase">box</span>
|
||||
)}
|
||||
{cutouts.length > 0 && (
|
||||
<span className="text-[8.5px] text-white/40 font-mono">· {cutouts.length} 张</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>
|
||||
<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>
|
||||
|
||||
{/* 裁切 / 抠图按钮(有 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
|
||||
{/* 提取按钮(每次新增一张,不覆盖) */}
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={
|
||||
hasRegion
|
||||
? `${hasAny ? "再提取一张" : "提取"}(从原图裁切框内 · 保留原表情形体 · 瞬时)`
|
||||
: `${hasAny ? "再提取一张" : "提取"}(调 nano-banana 模型生白底图 · 5-15s)`
|
||||
}
|
||||
className={`shrink-0 text-[10.5px] px-2 py-1 rounded inline-flex items-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium ${
|
||||
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>
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
|
||||
{isCutting ? "提取中" : hasAny ? "再提取" : "提取"}
|
||||
</button>
|
||||
|
||||
{/* 删除 */}
|
||||
<button
|
||||
onClick={() => handleDeleteElement(e.id)}
|
||||
className="absolute right-1 top-1 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
|
||||
title="删除该元素"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
{/* 删除整条元素 */}
|
||||
<button
|
||||
onClick={() => handleDeleteElement(e.id)}
|
||||
className="absolute right-1 top-1 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
|
||||
title="删除该元素"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 多张提取图横向 grid */}
|
||||
{hasAny && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{cutouts.map((cid, ci) => {
|
||||
// 旧数据兼容:当 e.cutouts 为空、靠 cutout_id fallback 时,cid 实际是 element_id 而非 versioned id
|
||||
const url = (e.cutouts && e.cutouts.length > 0)
|
||||
? cutoutUrl(jobId, f.index, e.id, cid)
|
||||
: cutoutUrl(jobId, f.index, e.id)
|
||||
return (
|
||||
<div
|
||||
key={cid}
|
||||
className="group/img relative rounded-md border border-white/10 bg-white overflow-hidden"
|
||||
style={{ width: 80, height: 80 }}
|
||||
>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="点击新窗口查看原图"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img src={url} alt={`${e.name_zh} #${ci + 1}`} className="w-full h-full object-contain" />
|
||||
</a>
|
||||
{/* 序号 */}
|
||||
<div className="absolute bottom-0 left-0 text-[8.5px] font-mono text-white bg-black/70 backdrop-blur px-1 rounded-tr">
|
||||
#{ci + 1}
|
||||
</div>
|
||||
{/* 删除该张 — 仅 v2 多图支持,老 fallback 不显示 */}
|
||||
{e.cutouts && e.cutouts.length > 0 && (
|
||||
<button
|
||||
onClick={(ev) => { ev.preventDefault(); handleDeleteCutout(e.id, cid) }}
|
||||
className="absolute right-0.5 top-0.5 h-4 w-4 rounded-sm bg-black/70 text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition"
|
||||
title="删除该张"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -41,7 +41,8 @@ export interface KeyElement {
|
||||
position?: string
|
||||
source: "auto" | "manual" | "region"
|
||||
region?: { x: number; y: number; w: number; h: number } | null
|
||||
cutout_id?: string | null
|
||||
cutouts?: string[] // v2 多张提取图 id 列表
|
||||
cutout_id?: string | null // v1 兼容字段
|
||||
cutout_background?: "white" | "black"
|
||||
created_at?: number
|
||||
}
|
||||
@@ -212,10 +213,24 @@ export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string
|
||||
return bust ? `${u}?t=${bust}` : u
|
||||
}
|
||||
|
||||
export function cutoutUrl(jobId: string, frameIndex: number, elementId: string): string {
|
||||
export function cutoutUrl(jobId: string, frameIndex: number, elementId: string, cutoutId?: string): string {
|
||||
if (cutoutId) {
|
||||
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutouts/${cutoutId}.jpg`
|
||||
}
|
||||
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.jpg`
|
||||
}
|
||||
|
||||
export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`deleteCutout ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cleanupFrame(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
|
||||
Reference in New Issue
Block a user