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

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