auto-save 2026-05-13 14:38 (~4)

This commit is contained in:
2026-05-13 14:38:26 +08:00
parent 4536418c76
commit 9421836a6d
4 changed files with 196 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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