auto-save 2026-05-14 05:27 (~3)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user