auto-save 2026-05-13 10:38 (~5)

This commit is contained in:
2026-05-13 10:38:52 +08:00
parent 3fee4a4b7f
commit 98d4ecb281
5 changed files with 365 additions and 174 deletions

View File

@@ -1237,6 +1237,19 @@
"message": "auto-save 2026-05-13 10:27 (~1)",
"hash": "e154f8b",
"files_changed": 1
},
{
"ts": "2026-05-13T10:33:17+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 10:33 (~2)",
"hash": "3fee4a4",
"files_changed": 2
},
{
"ts": "2026-05-13T02:37:36Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 5 项未提交变更 · 最近提交auto-save 2026-05-13 10:33 (~2)",
"files_changed": 5
}
]
}

View File

@@ -453,11 +453,17 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
) : (
job!.frames.map((f) => {
const isSel = data.selectedFrames.has(f.index)
const cleaned = !!f.cleaned_url
const elCount = f.elements?.length ?? 0
const cutCount = f.elements?.filter((e) => e.cutout_id).length ?? 0
const tags = [`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]
if (cleaned) tags.push("已清洗")
if (cutCount > 0) tags.push(`${cutCount}/${elCount} 抠图`)
return (
<KanbanCard
key={f.index}
tone={isSel ? "green" : "pink"}
tags={[`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]}
tags={tags}
className={isSel ? "ring-2 ring-emerald-400/60" : ""}
meta={
<button
@@ -478,7 +484,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
type="button"
onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }}
className="block w-full rounded-md overflow-hidden bg-black"
title="点击放大"
title="点击放大 · 清洗 / 提取元素"
>
<img
src={frameUrl(job!.id, f.index)}

View File

@@ -1,12 +1,14 @@
"use client"
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus } from "lucide-react"
import { frameUrl, describeFrame, translateText, generateImage, type KeyFrame, type Job } from "@/lib/api"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, cutoutUrl,
describeFrame, cleanupFrame, addElement, deleteElement, cutoutElement,
type KeyFrame, type Job,
} from "@/lib/api"
import { toast } from "sonner"
type CustomItem = { id: string; zh: string; en: string; translating: boolean }
interface Props {
jobId: string
frames: KeyFrame[]
@@ -16,44 +18,20 @@ interface Props {
onChange: (idx: number) => void
onToggleSelect: (idx: number) => void
onJobUpdate?: (job: Job) => void
onSwitchPanel?: (key: string) => void // 生成成功后切到目标 sidebar 节点(如 "imagegen"
embedded?: boolean // true=嵌入到容器里(无 fixedfalse=独立浮动卡(默认)
onSwitchPanel?: (key: string) => void
embedded?: boolean
}
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [generating, setGenerating] = useState(false)
const [mounted, setMounted] = useState(false)
// 自定义提取元素 — 按 frame 隔离,切换 frame 后回到同一帧时还能看到之前加的
const [customsByFrame, setCustomsByFrame] = useState<Record<number, CustomItem[]>>({})
const [cleaning, setCleaning] = useState(false)
const [cuttingId, setCuttingId] = useState<string | null>(null)
const [addingZh, setAddingZh] = useState(false)
const [viewCleaned, setViewCleaned] = useState(true) // 默认显示干净版(若有)
const [addInput, setAddInput] = useState("")
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const customs = activeIndex !== null ? (customsByFrame[activeIndex] || []) : []
const updateCustoms = (updater: (prev: CustomItem[]) => CustomItem[]) => {
if (activeIndex === null) return
const key = activeIndex
setCustomsByFrame((prev) => ({ ...prev, [key]: updater(prev[key] || []) }))
}
const addCustom = async (zhRaw: string, presetEn?: string) => {
const zh = zhRaw.trim()
if (!zh || activeIndex === null) return
const id = Math.random().toString(36).slice(2, 8)
const skipTranslate = !!presetEn
updateCustoms((prev) => [...prev, { id, zh, en: presetEn || "", translating: !skipTranslate }])
if (skipTranslate) return
try {
const en = await translateText(zh, "en")
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en, translating: false } : c))
} catch (e) {
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en: "", translating: false } : c))
toast.error("翻译失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const removeCustom = (id: string) => updateCustoms((prev) => prev.filter((c) => c.id !== id))
useEffect(() => {
if (activeIndex === null) return
const onKey = (e: KeyboardEvent) => {
@@ -73,44 +51,15 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
return () => window.removeEventListener("keydown", onKey)
}, [activeIndex, frames.length, onClose, onChange, onToggleSelect])
// activeIndex 是 KeyFrame.index 稳定 ID而 frames 数组按 timestamp 排序——必须用 find 不能用 [index]
const f = activeIndex !== null ? frames.find((x) => x.index === activeIndex) : undefined
const arrayPos = f ? frames.findIndex((x) => x.index === f.index) : -1
if (activeIndex === null || !f || !mounted) return null
const isSelected = selected.has(f.index)
const desc = f.description
const handleGenerateNext = async () => {
if (activeIndex === null || !f) return
const base = f.description?.suggested_prompt?.trim()
if (!base) {
toast.error("请先识别此分镜(右上角『识别』按钮)")
return
}
if (!selected.has(f.index)) onToggleSelect(f.index)
const extraEn = customs.filter((c) => c.en).map((c) => c.en).join(", ")
setGenerating(true)
try {
const updated = await generateImage(jobId, f.index, {
prompt: base,
extra_prompt: extraEn,
negative_prompt: "watermark, username text, social media handle, platform logo, overlay text, captions",
model: "gemini-3-pro-image-preview",
mode: "edit",
from_selected: true, // 优先用上一轮 selected 的生成图作 reference迭代
})
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 生成完成 → 「生图」`)
// 自动切到「生图」节点 drawer用户立刻看到新图
onSwitchPanel?.("imagegen")
} catch (e) {
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setGenerating(false)
}
}
const elements = f.elements ?? []
const hasCleaned = !!f.cleaned_url
const showCleaned = hasCleaned && viewCleaned
const handleDescribe = async () => {
setDescribing(true)
@@ -125,6 +74,64 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
}
const handleCleanup = async () => {
setCleaning(true)
try {
const updated = await cleanupFrame(jobId, f.index)
onJobUpdate?.(updated)
setViewCleaned(true)
toast.success(`分镜 ${f.index + 1} 清洗完成`)
} catch (e) {
toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setCleaning(false)
}
}
const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => {
const zh = name_zh.trim()
if (!zh) return
setAddingZh(true)
try {
const updated = await addElement(jobId, f.index, { name_zh: zh, name_en, position, source })
onJobUpdate?.(updated)
} catch (e) {
toast.error("加入失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setAddingZh(false)
}
}
const handleDeleteElement = async (id: string) => {
try {
const updated = await deleteElement(jobId, f.index, id)
onJobUpdate?.(updated)
} 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)
}
}
// cleaned_url 是 /jobs/.../cleaned.jpg?t=<timestamp> 形式(后端写时带)
// 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust
const cleanedSrc = (() => {
if (!f.cleaned_url) return null
const ts = f.cleaned_url.match(/t=(\d+)/)?.[1]
return cleanedFrameUrl(jobId, f.index, ts)
})()
const content = (
<div
onClick={(e) => e.stopPropagation()}
@@ -141,7 +148,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
{/* 顶部工具栏 — 切换 / 关闭,用 keyframe 橙红配色 */}
{/* 顶部工具栏 */}
<div
className="flex items-center justify-between px-4 py-2 text-white"
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}
@@ -175,16 +182,51 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</button>
</div>
{/* 主体 — 左大图 + 右识别面板 */}
{/* 主体 — 左大图 + 清洗 / 选用;右:识别 + 元素清单 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图 */}
<div className="flex flex-col items-center gap-2 flex-shrink-0">
<img
src={frameUrl(jobId, f.index)}
alt={`frame ${f.index}`}
className="rounded-lg object-contain"
style={{ maxWidth: 320, maxHeight: "70vh" }}
/>
<div className="flex flex-col items-stretch gap-2 flex-shrink-0" style={{ width: 320 }}>
<div className="relative">
<img
src={showCleaned && cleanedSrc ? cleanedSrc : frameUrl(jobId, f.index)}
alt={`frame ${f.index}`}
className="rounded-lg object-contain w-full"
style={{ maxHeight: "62vh" }}
/>
{/* 显示版本切换 + 状态标 */}
{hasCleaned && (
<div className="absolute top-2 left-2 right-2 flex items-center gap-1">
<button
onClick={() => setViewCleaned(false)}
className={`text-[10px] px-2 py-1 rounded-md backdrop-blur transition ${
!showCleaned
? "bg-white/20 text-white"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white"
}`}
></button>
<button
onClick={() => setViewCleaned(true)}
className={`text-[10px] px-2 py-1 rounded-md backdrop-blur transition ${
showCleaned
? "bg-emerald-500/80 text-white"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white"
}`}
> </button>
</div>
)}
</div>
{/* 清洗按钮 */}
<button
onClick={handleCleanup}
disabled={cleaning}
className="w-full px-3 py-1.5 rounded-md text-[11.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-gradient-to-r from-cyan-500/80 to-emerald-500/80 hover:from-cyan-500 hover:to-emerald-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
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 ? "重新清洗" : "🧹 清洗水印"}
</button>
<button
onClick={() => onToggleSelect(f.index)}
className={`w-full px-3 py-1.5 rounded-md text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition ${
@@ -198,23 +240,21 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</button>
</div>
{/* 右侧识别面板 */}
<div
className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0"
>
{/* 识别到的元素 */}
{/* 右侧识别 + 元素清单 */}
<div className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0">
{/* 识别 */}
<section>
<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="调 Gemini Vision 识别画面元素"
>
{describing ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RefreshCw className="h-2.5 w-2.5" />}
{desc ? "重新识别" : "识别"}
@@ -229,7 +269,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
Gemini Vision
</div>
) : (
<>Gemini 2.5 / / / prompt</>
<> Gemini / / </>
)}
</div>
) : (
@@ -249,25 +289,37 @@ 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) => (
<button
key={i}
onClick={() => addCustom(o.name, o.extract_prompt)}
title={o.position ? `位置:${o.position}` : undefined}
className="w-full text-left rounded bg-white/[0.03] hover:bg-white/[0.08] border border-white/8 hover:border-pink-300/40 px-2 py-1 transition group/o"
>
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
<span className="truncate">{o.name}</span>
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
</div>
{o.extract_prompt && (
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
)}
</button>
))}
{desc.objects.map((o, i) => {
const alreadyIn = elements.some((e) => e.name_zh === o.name)
return (
<button
key={i}
onClick={() => !alreadyIn && handleAddElement(o.name, o.extract_prompt, o.position, "auto")}
disabled={alreadyIn}
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"
: "bg-white/[0.03] hover:bg-white/[0.08] border-white/8 hover:border-pink-300/40"
}`}
>
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
<span className="truncate">{o.name}</span>
{alreadyIn ? (
<Check className="h-2.5 w-2.5 text-emerald-300 shrink-0 ml-auto" />
) : (
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
)}
</div>
{o.extract_prompt && (
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
)}
</button>
)
})}
</div>
</div>
)}
@@ -275,93 +327,123 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
)}
</section>
{/* 自定义提取 — 多条 + 中英对照 */}
{/* 元素清单(持久化) */}
<section>
<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" />
{customs.length > 0 && (
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {customs.length}</span>
{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>
</div>
{customs.length > 0 && (
{elements.length > 0 && (
<div className="space-y-1.5 mb-2">
{customs.map((c) => (
<div
key={c.id}
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5"
>
<div className="pr-5 text-white text-[11.5px] font-medium leading-tight truncate">{c.zh}</div>
<div className="pr-5 mt-0.5 text-[10px] font-mono leading-tight truncate">
{c.translating ? (
<span className="inline-flex items-center gap-1 text-white/40">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
) : c.en ? (
<span className="text-white/45">{c.en}</span>
) : (
<span className="text-rose-300/60"> · </span>
)}
</div>
<button
onClick={() => removeCustom(c.id)}
className="absolute right-1.5 top-1.5 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="删除"
{elements.map((e) => {
const isCutting = cuttingId === e.id
const hasCutout = !!e.cutout_id
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"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
{/* 抠图缩略图 / 占位 */}
<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 }}
>
{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-contain"
style={{ background: "repeating-conic-gradient(rgba(255,255,255,0.04) 0 25%, transparent 0 50%) 0 0 / 8px 8px" }}
/>
</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>
)}
</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>
{/* 删除 */}
<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>
)
})}
</div>
)}
{/* 添加 */}
<div className="flex items-center gap-1.5">
<input
value={addInput}
onChange={(e) => setAddInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault()
if (addInput.trim()) {
addCustom(addInput)
onChange={(ev) => setAddInput(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter" && !ev.nativeEvent.isComposing) {
ev.preventDefault()
if (addInput.trim() && !addingZh) {
handleAddElement(addInput)
setAddInput("")
}
}
}}
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
onClick={() => { if (addInput.trim()) { addCustom(addInput); setAddInput("") } }}
disabled={!addInput.trim()}
onClick={() => { if (addInput.trim() && !addingZh) { handleAddElement(addInput); setAddInput("") } }}
disabled={!addInput.trim() || addingZh}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-violet-500/60 hover:bg-violet-500/80 text-white inline-flex items-center justify-center gap-1 disabled:opacity-30 disabled:cursor-not-allowed transition shrink-0"
title="添加"
>
<Plus className="h-3.5 w-3.5" />
{addingZh ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
</div>
<button
onClick={handleGenerateNext}
disabled={generating || !desc?.suggested_prompt}
className="mt-2 w-full text-[12px] py-2 rounded-md bg-gradient-to-r from-rose-500 to-pink-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5 font-semibold transition"
title={!desc?.suggested_prompt ? "先识别此分镜" : `结合 ${customs.length} 条提取元素与已选图,生成下一张`}
>
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
{generating ? "生成中…5-15 秒)" : "⚡ 生成下一张"}
</button>
{!desc?.suggested_prompt ? (
<div className="mt-1 text-[10px] text-white/40 text-center"></div>
) : (
<div className="mt-1 text-[10px] text-white/40 text-center">
{f.generated_images?.some((g) => g.selected) ? "上一轮已选生成图" : "原关键帧"}
{customs.length > 0 && ` + ${customs.length} 条提取元素`}
</div>
)}
<div className="mt-1.5 text-[10px] text-white/35 leading-relaxed">
·
</div>
</section>
</div>
</div>

View File

@@ -377,6 +377,22 @@ export function KeyframeNode({ data, selected }: any) {
{isSel && (
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
)}
{/* 左上角:已清洗 + 抠图数 */}
{(f.cleaned_url || (f.elements?.some((e) => e.cutout_id))) && (
<div className="absolute top-0 left-0 flex items-center gap-0.5 px-1 py-0.5 rounded-br-md leading-none">
{f.cleaned_url && (
<span title="已清洗" className="bg-cyan-500/85 text-white text-[8px] font-bold px-1 py-0.5 rounded-sm"></span>
)}
{(() => {
const cutN = f.elements?.filter((e) => e.cutout_id).length ?? 0
return cutN > 0 ? (
<span title={`${cutN} 个元素已抠图`} className="bg-violet-500/85 text-white text-[8px] font-mono font-bold px-1 py-0.5 rounded-sm">
{cutN}
</span>
) : null
})()}
</div>
)}
{/* 时间戳 */}
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-bl rounded-br-md">
{f.timestamp.toFixed(1)}s
@@ -420,20 +436,29 @@ export function KeyframeNode({ data, selected }: any) {
<NodeShell
type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />}
title="关键帧 · Keyframes"
subtitle={`STEP 2 · ffmpeg · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
title="关键帧 · 清洗 + 提取"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
width={KEYFRAME_WIDTH}
selected={selected}
>
{frames.length > 0 ? (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{frames.length}</span> · {d.selectedFrames.size}
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
· Input
</span>
</div>
) : (
{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) => e.cutout_id).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>
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
/
</span>
</div>
)
})() : (
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
5
</div>

View File

@@ -34,11 +34,23 @@ export interface GeneratedImage {
created_at: number
}
export interface KeyElement {
id: string
name_zh: string
name_en: string
position?: string
source: "auto" | "manual"
cutout_id?: string | null
created_at?: number
}
export interface KeyFrame {
index: number
timestamp: number
url: string
description?: FrameDescription | null
cleaned_url?: string | null
elements?: KeyElement[]
generated_images?: GeneratedImage[]
}
@@ -185,3 +197,56 @@ export function frameUrl(jobId: string, frameIndex: number): string {
export function videoUrl(jobId: string): string {
return `${API_BASE}/jobs/${jobId}/video.mp4`
}
export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string {
const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg`
return bust ? `${u}?t=${bust}` : u
}
export function cutoutUrl(jobId: string, frameIndex: number, elementId: string): string {
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/elements/${elementId}/cutout.png`
}
export async function cleanupFrame(jobId: string, frameIdx: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/cleanup`, { method: "POST" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`cleanup ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function addElement(
jobId: string,
frameIdx: number,
body: { name_zh: string; name_en?: string; position?: string; source?: "auto" | "manual" },
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: "manual", ...body }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`addElement ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function deleteElement(jobId: string, frameIdx: number, elementId: string): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}`, { method: "DELETE" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`deleteElement ${res.status} ${txt.slice(0, 300)}`)
}
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" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`cutout ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}