auto-save 2026-05-14 05:27 (~3)

This commit is contained in:
2026-05-14 05:27:24 +08:00
parent 4d02dcb4aa
commit 2c19b52a81
3 changed files with 90 additions and 200 deletions

View File

@@ -3335,6 +3335,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 3 项未提交变更 · 最近提交auto-save 2026-05-14 05:16 (~3)",
"files_changed": 3
},
{
"ts": "2026-05-14T05:21:54+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 05:21 (~3)",
"hash": "4d02dcb",
"files_changed": 3
},
{
"ts": "2026-05-13T21:23:13Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 05:21 (~3)",
"files_changed": 1
}
]
}

View File

@@ -3,9 +3,9 @@ import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, cutoutUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, cutoutElement, deleteCutout,
pushStoryboardImage, generateSceneAsset, generateSubjectAssets,
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind,
} from "@/lib/api"
import { toast } from "sonner"
@@ -52,7 +52,7 @@ type LightboxTab = "clean" | "scene" | "subject" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "scene", label: "场景图" },
{ key: "subject", label: "主体" },
{ key: "subject", label: "主体资产" },
{ key: "review", label: "审核" },
]
@@ -60,7 +60,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [describing, setDescribing] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [applying, setApplying] = useState(false)
const [cuttingId, setCuttingId] = useState<string | null>(null)
const [sceneGenerating, setSceneGenerating] = useState(false)
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
const [addingZh, setAddingZh] = useState(false)
@@ -83,9 +82,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [regions, setRegions] = useState<Region[]>([])
const [draftRegion, setDraftRegion] = useState<Region | null>(null) // 当前正在拖的
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const [extractNamePrompt, setExtractNamePrompt] = useState(false) // 提取模式:要用户填名字
const [extractName, setExtractName] = useState("")
const [extracting, setExtracting] = useState(false)
const [subjectRegionPrompt, setSubjectRegionPrompt] = useState(false)
const [subjectRegionName, setSubjectRegionName] = useState("")
const [addingRegionSubject, setAddingRegionSubject] = useState(false)
const imgWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => setMounted(true), [])
@@ -95,8 +94,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
setRegions([])
setDraftRegion(null)
setDragStart(null)
setExtractNamePrompt(false)
setExtractName("")
setSubjectRegionPrompt(false)
setSubjectRegionName("")
}, [activeIndex])
useEffect(() => {
@@ -130,7 +129,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b)
const sharedSubjectFrameIndices = selectedFrameIndices.length > 1 ? selectedFrameIndices : [f.index]
const subjectAssetCount = elements.reduce((sum, item) => sum + (item.subject_assets?.length ?? 0), 0)
const cutoutCount = elements.reduce((sum, item) => sum + ((item.cutouts?.length ?? 0) || (item.cutout_id ? 1 : 0)), 0)
const qualityWarnings = [
...(f.quality_report?.warnings ?? []),
...(latestSceneAsset?.quality_report?.warnings ?? []),
@@ -208,32 +206,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
})
}
const handleExtractRegion = async () => {
// 提取语义只在恰好 1 个框时支持
if (regions.length !== 1 || !extractName.trim()) return
const handleAddRegionSubject = async () => {
if (regions.length !== 1 || !subjectRegionName.trim()) return
const r = regions[0]
setExtracting(true)
setAddingRegionSubject(true)
try {
const added = await addElement(jobId, f.index, {
name_zh: extractName.trim(),
name_zh: subjectRegionName.trim(),
source: "region",
region: r,
})
onJobUpdate?.(added)
const fr = added.frames.find((x) => x.index === f.index)
const newEl = fr?.elements?.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]
if (newEl) {
const cut = await cutoutElement(jobId, f.index, newEl.id)
onJobUpdate?.(cut)
toast.success(`${extractName.trim()}」已提取并加入元素清单`)
} else {
toast.success(`${extractName.trim()}」已加入元素清单 · 但抠图未触发`)
}
setCropMode(false); setRegions([]); setExtractNamePrompt(false); setExtractName("")
toast.success(`${subjectRegionName.trim()}」已加入主体清单`)
setCropMode(false); setRegions([]); setSubjectRegionPrompt(false); setSubjectRegionName("")
} catch (e) {
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
toast.error("添加主体失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setExtracting(false)
setAddingRegionSubject(false)
}
}
@@ -331,51 +320,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
})
onJobUpdate?.(updated)
setEditingElement(null)
toast.success("元素已更新")
toast.success("主体已更新")
} catch (e) {
toast.error("更新失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const handleCutout = async (id: string) => {
setCuttingId(id)
try {
const updated = await cutoutElement(jobId, f.index, id)
onJobUpdate?.(updated)
toast.success("提取完成")
} catch (e) {
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setCuttingId(null)
}
}
const handlePushCutout = async (elementId: string, cutoutId: string, label: string, isLegacy: boolean) => {
if (activeIndex === null) return
try {
const updated = await pushStoryboardImage(jobId, {
kind: "cutout",
frame_idx: activeIndex,
element_id: elementId,
cutout_id: isLegacy ? elementId : cutoutId, // legacy 兼容
label,
})
onJobUpdate?.(updated)
toast.success("已推送到分镜头编排")
} catch (e) {
toast.error("推送失败:" + (e instanceof Error ? e.message : String(e)))
}
}
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 = (() => {
@@ -462,7 +412,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
</div>
{/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
{/* 主体 — 左:大图 + 清洗 / 目标帧;右:主体识别 + 主体资产 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图区 */}
<div
@@ -527,34 +477,33 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<>
{/* 画框工具栏 */}
{cropMode ? (
extractNamePrompt ? (
// 提取模式:要用户填名字(仅 1 框时进入此模式)
subjectRegionPrompt ? (
<div className="space-y-1.5">
<input
autoFocus
value={extractName}
onChange={(e) => setExtractName(e.target.value)}
value={subjectRegionName}
onChange={(e) => setSubjectRegionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing && extractName.trim()) {
if (e.key === "Enter" && !e.nativeEvent.isComposing && subjectRegionName.trim()) {
e.preventDefault()
handleExtractRegion()
handleAddRegionSubject()
}
if (e.key === "Escape") { setExtractNamePrompt(false); setExtractName("") }
if (e.key === "Escape") { setSubjectRegionPrompt(false); setSubjectRegionName("") }
}}
placeholder="给这个元素起个中文名(如:左下角药瓶"
placeholder="给这个主体起名(如:手持产品的人"
className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-black/40 border border-violet-300/50 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40"
/>
<div className="flex items-center gap-1.5">
<button
onClick={handleExtractRegion}
disabled={extracting || !extractName.trim()}
onClick={handleAddRegionSubject}
disabled={addingRegionSubject || !subjectRegionName.trim()}
className="flex-1 px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{extracting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{extracting ? "提取中…5-15 秒)" : "✓ 提取"}
{addingRegionSubject ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{addingRegionSubject ? "添加中…" : "添加主体"}
</button>
<button
onClick={() => { setExtractNamePrompt(false); setExtractName("") }}
onClick={() => { setSubjectRegionPrompt(false); setSubjectRegionName("") }}
className="px-2 py-1.5 rounded-md text-[11px] bg-white/10 hover:bg-white/20 text-white"
title="返回"
>
@@ -563,12 +512,11 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
</div>
) : (
// 画框模式 — 可连续画多框
<div className="space-y-1.5">
<div className="text-[10px] text-white/55 leading-snug">
{regions.length === 0
? "在图上拖动鼠标 → 框选要处理的区域(可连续画多个)"
: `已框 ${regions.length} 个 · 继续拖鼠标加框 · 「去掉」批量清洗 · 单框可「提取」为元素`}
: `已框 ${regions.length} 个 · 继续加框 · 「去掉」批量清洗 · 单框可加入主体清单`}
</div>
<div className="flex items-center gap-1">
<button
@@ -581,13 +529,13 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{cleaning ? "去掉中" : `✓ 去掉${regions.length > 1 ? ` ${regions.length}` : ""}`}
</button>
<button
onClick={() => { if (regions.length === 1) setExtractNamePrompt(true) }}
onClick={() => { if (regions.length === 1) setSubjectRegionPrompt(true) }}
disabled={regions.length !== 1}
className="flex-1 px-1.5 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-30 disabled:cursor-not-allowed"
title={regions.length === 1 ? "提取框内为元素" : regions.length === 0 ? "先画一个框" : "提取仅在恰好 1 个框时可用"}
title={regions.length === 1 ? "把框内对象加入主体清单" : regions.length === 0 ? "先画一个框" : "添加主体仅在恰好 1 个框时可用"}
>
<Wand2 className="h-3 w-3" />
🪄
</button>
<button
onClick={() => setRegions((prev) => prev.slice(0, -1))}
@@ -611,10 +559,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<button
onClick={() => { setCropMode(true); setRegions([]) }}
className="w-full px-3 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-white/[0.06] hover:bg-cyan-500/30 border border-white/15 hover:border-cyan-300/50 text-white/80 hover:text-white"
title="可连续画多个框 · 批量清洗或单框提取"
title="可连续画多个框 · 批量清洗或单框添加主体"
>
<Crop className="h-3 w-3" />
📐
</button>
)}
@@ -645,10 +593,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
onClick={handleApplyCleaned}
disabled={applying}
className="w-full px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
title="替换原图为这张干净版 · 后续抠图 / 分镜编排都基于干净版"
title="替换原图为这张干净版 · 后续场景图、主体资产和分镜编排都基于干净版"
>
{applying ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{applying ? "替换中…" : "替换原图"}
{applying ? "替换中…" : "替换原图"}
</button>
</div>
)}
@@ -661,7 +609,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
title="清掉水印 / @用户名 / 字幕 / 平台 logo"
>
{cleaning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkle className="h-3.5 w-3.5" />}
{cleaning ? "清洗中…5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "🧹 清洗水印"}
{cleaning ? "清洗中…5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "清洗水印"}
</button>
</>
)}
@@ -755,18 +703,19 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
? "bg-emerald-500 text-white hover:bg-emerald-400"
: "bg-white/10 text-white hover:bg-white/20"
}`}
title="目标关键帧会参与素材准备进度、主体跨帧参考和后续分镜编排"
>
<Check className="h-3.5 w-3.5" />
{isSelected ? "已选用" : "选用此帧"}
{isSelected ? "目标帧 · 点击移出" : "加入目标帧"}
</button>
</div>
{/* 右侧识别 + 元素清单 */}
{/* 右侧主体识别 + 主体资产 */}
<div className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0" style={{ minWidth: 240 }}>
{activeTab === "clean" && (
<section className="rounded-lg border border-cyan-300/15 bg-cyan-500/[0.08] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-1.5 text-[12.5px] font-semibold text-white"></div>
UI
UI
</section>
)}
{activeTab === "scene" && (
@@ -791,27 +740,27 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div className="space-y-1.5">
<div>{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}</div>
<div>{latestSceneAsset ? `${latestSceneAsset.width}×${latestSceneAsset.height}` : "未生成"}</div>
<div>{cutoutCount} </div>
<div>{elements.length} </div>
<div>{subjectAssetCount} </div>
</div>
</section>
)}
{/* 识别 */}
{/* 主体识别 */}
<section className={activeTab === "subject" ? "" : "hidden"}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-white text-[12.5px] font-semibold">
<Eye className="h-3.5 w-3.5" />
Vision
{desc && <span className="text-[10px] text-emerald-400 font-mono ml-1"></span>}
</div>
<button
onClick={handleDescribe}
disabled={describing}
className="text-[10.5px] text-white/70 hover:text-white px-2 py-0.5 rounded border border-white/20 hover:border-white/40 disabled:opacity-50 inline-flex items-center gap-1"
title="调 Gemini Vision 识别画面元素"
title="识别画面主体候选"
>
{describing ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RefreshCw className="h-2.5 w-2.5" />}
{desc ? "重新识别" : "识别"}
{desc ? "重新识别" : "识别主体"}
</button>
</div>
@@ -820,10 +769,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{describing ? (
<div className="flex items-center gap-1.5 text-white/60">
<Loader2 className="h-3 w-3 animate-spin" />
Gemini Vision
</div>
) : (
<> Gemini / / </>
<> Vision </>
)}
</div>
) : (
@@ -843,7 +792,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{desc.objects && desc.objects.length > 0 && (
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300/90 mb-1.5">
·
·
</div>
<div className="space-y-1">
{desc.objects.map((o, i) => {
@@ -853,7 +802,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
key={i}
onClick={() => !alreadyIn && handleAddElement(o.name, o.extract_prompt, o.position, "auto")}
disabled={alreadyIn}
title={alreadyIn ? "已加入元素清单" : (o.position ? `位置:${o.position}` : undefined)}
title={alreadyIn ? "已加入主体清单" : (o.position ? `位置:${o.position}` : undefined)}
className={`w-full text-left rounded border px-2 py-1 transition group/o ${
alreadyIn
? "bg-emerald-500/10 border-emerald-400/30 cursor-default"
@@ -881,29 +830,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
)}
</section>
{/* 元素清单(持久化) */}
{/* 主体清单(持久化) */}
<section className={activeTab === "subject" ? "" : "hidden"}>
<div className="flex items-center gap-1.5 mb-2 text-white text-[12.5px] font-semibold">
<Sparkles className="h-3.5 w-3.5" />
{elements.length > 0 && (
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {elements.length}</span>
)}
<span className="text-[9.5px] text-white/35 font-normal ml-auto"> </span>
<span className="text-[9.5px] text-white/35 font-normal ml-auto"> </span>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-[10.5px] leading-relaxed text-white/45">
Vision
Vision //
</div>
{elements.length > 0 && (
<div className="space-y-2 mb-2">
{elements.map((e) => {
const isCutting = cuttingId === e.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
const isEditing = editingElement?.id === e.id
const currentKind = subjectKinds[e.id] ?? e.subject_kind ?? "object"
@@ -928,13 +871,13 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<input
value={editingElement.name_zh}
onChange={(ev) => setEditingElement({ ...editingElement, name_zh: ev.target.value })}
placeholder="元素中文名"
placeholder="主体名称"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[12px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
<input
value={editingElement.name_en}
onChange={(ev) => setEditingElement({ ...editingElement, name_en: ev.target.value })}
placeholder="英文提取提示,可手动修正"
placeholder="英文主体提示,可手动修正"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] font-mono text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
<input
@@ -949,13 +892,16 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div className="flex items-center gap-1 text-white text-[12px] font-medium leading-tight">
<span className="truncate">{e.name_zh}</span>
{e.source === "auto" && (
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
<span className="text-[8.5px] text-pink-300/70 font-mono"></span>
)}
{e.source === "region" && (
<span className="text-[8.5px] text-cyan-300/80 font-mono uppercase">box</span>
<span className="text-[8.5px] text-cyan-300/80 font-mono"></span>
)}
{cutouts.length > 0 && (
<span className="text-[8.5px] text-white/40 font-mono">· {cutouts.length} </span>
{hasRegion && (
<span className="text-[8.5px] text-white/40 font-mono">· </span>
)}
{subjectAssets.length > 0 && (
<span className="text-[8.5px] text-white/40 font-mono">· {subjectAssets.length} </span>
)}
</div>
<div className="mt-0.5 text-[10px] font-mono leading-tight truncate text-white/45">
@@ -1001,87 +947,19 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<PencilLine className="h-3 w-3" />
</button>
<button
onClick={() => handleCutout(e.id)}
disabled={isCutting}
title={`${hasAny ? "再提取一张" : "AI 提取"} · 模型识别 + 补全缺失部分(如缺手脚 / 半个台灯)→ 完整清晰白底图5-15s`}
className="flex-1 rounded px-2 py-1 text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed bg-violet-500/30 text-white/90 hover:bg-violet-500/50"
>
{isCutting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
{isCutting ? "提取中…" : hasAny ? "再提取" : "AI 提取"}
</button>
<button
onClick={() => handleDeleteElement(e.id)}
className="rounded px-2 py-1 text-[10.5px] inline-flex items-center gap-1 bg-rose-500/15 hover:bg-rose-500/30 text-rose-100 border border-rose-300/20"
title="删除整个元素和它的提取图"
className="ml-auto rounded px-2 py-1 text-[10.5px] inline-flex items-center gap-1 bg-rose-500/15 hover:bg-rose-500/30 text-rose-100 border border-rose-300/20"
title="删除这个主体候选和它的主体资产"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
</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="relative rounded-md border border-white/10 bg-white overflow-hidden"
style={{ width: 92, height: 112 }}
>
<div className="h-[80px] w-full bg-white">
<img src={url} alt={`${e.name_zh} #${ci + 1}`} className="w-full h-full object-contain" />
</div>
{/* 序号 */}
<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>
<div className="flex h-8 border-t border-black/10 bg-black text-white">
{onCopyImage && (
<button
onClick={(ev) => {
ev.preventDefault(); ev.stopPropagation()
const isLegacy = !(e.cutouts && e.cutouts.length > 0)
onCopyImage({
kind: "cutout",
frame_idx: f.index,
element_id: e.id,
cutout_id: isLegacy ? e.id : cid,
label: `${e.name_zh} #${ci + 1}`,
})
}}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-violet-500/70 transition"
title="复制到分镜编排剪贴板"
>
<Copy className="h-2.5 w-2.5" />
</button>
)}
{e.cutouts && e.cutouts.length > 0 && (
<button
onClick={(ev) => { ev.preventDefault(); handleDeleteCutout(e.id, cid) }}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-rose-500/80 transition border-l border-white/10"
title="删除这张错误提取图"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
)
})}
</div>
)}
<div className="mt-2 rounded-md border border-violet-300/15 bg-violet-500/[0.08] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-white/90"></div>
@@ -1194,7 +1072,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
}
}}
placeholder="加自定义元素 · 中文回车自动翻英文"
placeholder="添加主体候选 · 中文回车自动翻英文"
className="flex-1 text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
/>
<button
@@ -1207,14 +1085,14 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</button>
</div>
<div className="mt-1.5 text-[10px] text-white/35 leading-relaxed">
· + / /
</div>
</section>
</div>
</div>
<div className="px-4 py-1.5 text-[10px] text-white/40 font-mono text-center border-t border-white/5 bg-white/[0.02]">
/ · Space · ESC
/ · Space · ESC
</div>
</div>
)

View File

@@ -1807,8 +1807,8 @@ export function KeyframeNode({ data, selected }: any) {
<NodeShell
type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />}
title="镜头拆解 · 元素提取"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
title="镜头拆解 · 素材准备"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 目标帧` : "等待抽取"}`}
selected={selected}
pinned={d.pinnedNodes?.has("keyframe")}
onTogglePin={() => d.onToggleNodePin?.("keyframe")}
@@ -1816,17 +1816,16 @@ export function KeyframeNode({ data, selected }: any) {
{frames.length > 0 ? (() => {
const cleanedCount = frames.filter((x) => x.cleaned_url).length
const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0)
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
return (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{frames.length}</span>
{" · "}
<span className={cleanedCount > 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} </span>
{" · "}
<span className={cutoutCount > 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} </span>
<span className={elementsCount > 0 ? "text-violet-300/90 font-medium" : ""}>{elementsCount} </span>
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
/ SKG
/ SKG
</span>
</div>
)
@@ -1916,7 +1915,7 @@ export function KeyframePanelNode({ data }: any) {
>
<div className="flex min-w-0 items-center gap-2">
<ImageIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-[12px] font-semibold"> · </span>
<span className="truncate text-[12px] font-semibold"></span>
<span className="shrink-0 text-[10px] font-mono text-white/65">
{active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
</span>