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

This commit is contained in:
2026-05-14 05:16:23 +08:00
parent c7ca8302a1
commit e46289fd33
3 changed files with 281 additions and 28 deletions

View File

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

View File

@@ -47,6 +47,15 @@ const LIVING_VIEW_OPTIONS = [
["action_use", "使用"],
]
type LightboxTab = "clean" | "scene" | "subject" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "scene", label: "场景图" },
{ key: "subject", label: "主体包" },
{ key: "review", label: "审核" },
]
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaning, setCleaning] = useState(false)
@@ -60,6 +69,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [subjectKinds, setSubjectKinds] = useState<Record<string, SubjectKind>>({})
const [subjectBackgrounds, setSubjectBackgrounds] = useState<Record<string, AssetBackground>>({})
const [subjectViews, setSubjectViews] = useState<Record<string, string[]>>({})
const [activeTab, setActiveTab] = useState<LightboxTab>("clean")
const [editingElement, setEditingElement] = useState<{
id: string
name_zh: string
@@ -119,6 +129,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null
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 ?? []),
]
const handleDescribe = async () => {
setDescribing(true)
@@ -424,6 +440,28 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
)}
<div className="flex items-center gap-1 border-b border-white/10 bg-black/28 px-3 py-2">
{LIGHTBOX_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition ${
activeTab === tab.key
? "bg-white text-black shadow"
: "bg-white/[0.06] text-white/58 hover:bg-white/[0.12] hover:text-white"
}`}
>
{tab.label}
</button>
))}
<div className="ml-auto hidden items-center gap-2 text-[10px] text-white/42 sm:flex">
<span>{latestSceneAsset ? "场景已生成" : "场景待生成"}</span>
<span>·</span>
<span>{subjectAssetCount > 0 ? `${subjectAssetCount} 主体资产` : "主体待生成"}</span>
</div>
</div>
{/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图区 */}
@@ -485,6 +523,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
)}
</div>
{activeTab === "clean" && (
<>
{/* 画框工具栏 */}
{cropMode ? (
extractNamePrompt ? (
@@ -623,7 +663,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{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 ? "再次清洗" : "🧹 清洗水印"}
</button>
</>
)}
{activeTab === "scene" && (
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11.5px] font-semibold text-white"></div>
@@ -673,6 +716,37 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成场景图"}
</button>
</div>
)}
{activeTab === "review" && (
<div className="space-y-2 rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="text-[11.5px] font-semibold text-white"></div>
<div className="grid grid-cols-2 gap-1.5 text-[10.5px]">
<div className={`rounded border px-2 py-1 ${f.cleaned_applied || hasCleaned ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{f.cleaned_applied ? "已应用清洗" : hasCleaned ? "清洗待确认" : "未清洗"}
</div>
<div className={`rounded border px-2 py-1 ${latestSceneAsset ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{latestSceneAsset ? "场景图已生成" : "场景图未生成"}
</div>
<div className={`rounded border px-2 py-1 ${subjectAssetCount > 0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{subjectAssetCount > 0 ? `${subjectAssetCount} 张主体资产` : "主体包未生成"}
</div>
<div className={`rounded border px-2 py-1 ${qualityWarnings.length ? "border-amber-300/35 bg-amber-500/12 text-amber-100" : "border-emerald-300/30 bg-emerald-500/10 text-emerald-100"}`}>
{qualityWarnings.length ? `${qualityWarnings.length} 个风险` : "质量可用"}
</div>
</div>
{qualityWarnings.length > 0 && (
<div className="rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/85">
{qualityWarnings.slice(0, 3).map((warning, i) => (
<div key={i}>{warning}</div>
))}
</div>
)}
<div className="text-[10px] leading-relaxed text-white/42">
</div>
</div>
)}
<button
onClick={() => onToggleSelect(f.index)}

View File

@@ -11,6 +11,7 @@ import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Maximize2,
Copy, Trash2, Move, PanelLeft, PanelRight, PanelBottom, ChevronLeft, ChevronRight, SlidersHorizontal,
CheckCircle2, AlertTriangle, Sparkles, Package,
} from "lucide-react"
import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
@@ -1129,6 +1130,8 @@ const THUMB_W = 64
const THUMB_GAP = 6
type ElementPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
type SceneAssetPreview = { frameIdx: number; assetId: string; label: string; src: string; width: number; height: number; risk?: string }
type SubjectAssetPreview = { frameIdx: number; elementId: string; assetId: string; label: string; src: string; width: number; height: number; view: string }
function collectElementCrops(job: Job | null): ElementPreview[] {
return job
@@ -1154,6 +1157,41 @@ function collectElementCrops(job: Job | null): ElementPreview[] {
: []
}
function collectSceneAssets(job: Job | null): SceneAssetPreview[] {
return job
? job.frames.flatMap((f) =>
(f.scene_assets ?? []).map((asset) => ({
frameIdx: f.index,
assetId: asset.id,
label: asset.label || `分镜 ${f.index + 1} 场景图`,
src: apiAssetUrl(asset.url),
width: asset.width,
height: asset.height,
risk: asset.quality_report?.risk,
})).filter((p) => p.src),
)
: []
}
function collectSubjectAssets(job: Job | null): SubjectAssetPreview[] {
return job
? job.frames.flatMap((f) =>
(f.elements ?? []).flatMap((element) =>
(element.subject_assets ?? []).map((asset) => ({
frameIdx: f.index,
elementId: element.id,
assetId: asset.id,
label: asset.label || `${element.name_zh} · ${asset.view}`,
src: apiAssetUrl(asset.url),
width: asset.width,
height: asset.height,
view: asset.view,
})),
).filter((p) => p.src),
)
: []
}
function videoModelLabel(model: string) {
const m = model.toLowerCase()
if (m.includes("kling")) return "Kling"
@@ -1175,10 +1213,18 @@ export function VisualLabNode({ data, selected }: any) {
? `${job.width}/${job.height}`
: "9/16"
const elementCrops = collectElementCrops(job)
const sceneAssets = collectSceneAssets(job)
const subjectAssets = collectSubjectAssets(job)
const cleanedCount = frames.filter((x) => x.cleaned_url).length
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
const sceneAssetCount = frames.reduce((s, x) => s + (x.scene_assets?.length ?? 0), 0)
const subjectAssetCount = frames.reduce((s, x) => s + (x.elements?.reduce((n, e) => n + (e.subject_assets?.length ?? 0), 0) ?? 0), 0)
const sceneAssetCount = sceneAssets.length
const subjectAssetCount = subjectAssets.length
const selectedFrameCount = frames.filter((f) => d.selectedFrames.has(f.index)).length
const targetFrameCount = selectedFrameCount || frames.length
const qualityRiskCount = frames.filter((f) => f.quality_report?.risk && f.quality_report.risk !== "ok").length
const preparedUnits = Math.min(targetFrameCount, sceneAssetCount) + (subjectAssetCount > 0 ? 1 : 0)
const totalUnits = Math.max(1, targetFrameCount + 1)
const prepPct = Math.min(100, Math.round((preparedUnits / totalUnits) * 100))
const runningVideo = videos.some((v) => v.status === "queued" || v.status === "in_progress")
const completedVideos = videos.filter((v) => v.status === "completed" && v.url)
const failedVideo = videos.some((v) => v.status === "failed")
@@ -1191,9 +1237,11 @@ export function VisualLabNode({ data, selected }: any) {
: keyframeStatus(job)
type VisualPreview =
| { id: string; kind: "frame"; frameIdx: number; src: string; label: string; caption: string; borderClass: string }
| { id: string; kind: "cutout"; frameIdx: number; elementId: string; cutoutId: string; src: string; label: string; caption: string; borderClass: string }
| { id: string; kind: "video"; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string }
| { id: string; kind: "frame"; group: string; frameIdx: number; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "scene"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "subject"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "cutout"; group: string; frameIdx: number; elementId: string; cutoutId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "video"; group: string; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string; aspect: string }
const [hoverPreview, setHoverPreview] = useState<PreviewAnchor<string> | null>(null)
const [pinnedPreview, setPinnedPreview] = useState<PreviewAnchor<string> | null>(null)
@@ -1204,15 +1252,42 @@ export function VisualLabNode({ data, selected }: any) {
...(job && jobId ? frames.map((f) => ({
id: `frame:${f.index}`,
kind: "frame" as const,
group: "关键帧",
frameIdx: f.index,
src: effectiveFrameUrl(jobId, f),
label: `分镜 ${f.index + 1}`,
caption: `${f.timestamp.toFixed(2)}s`,
borderClass: "border-orange-300/50",
caption: `${f.timestamp.toFixed(2)}s${f.quality_report?.risk && f.quality_report.risk !== "ok" ? " · 风险" : ""}`,
borderClass: f.quality_report?.risk === "bad" ? "border-rose-300/70" : f.quality_report?.risk === "warn" ? "border-amber-300/70" : "border-orange-300/50",
aspect,
})) : []),
...sceneAssets.map((p) => ({
id: `scene:${p.frameIdx}:${p.assetId}`,
kind: "scene" as const,
group: "场景图",
frameIdx: p.frameIdx,
assetId: p.assetId,
src: p.src,
label: p.label,
caption: `${p.width}×${p.height}`,
borderClass: p.risk === "bad" ? "border-rose-300/70" : p.risk === "warn" ? "border-amber-300/70" : "border-emerald-300/60",
aspect: p.width && p.height ? `${p.width}/${p.height}` : aspect,
})),
...subjectAssets.map((p) => ({
id: `subject:${p.frameIdx}:${p.assetId}`,
kind: "subject" as const,
group: "主体包",
frameIdx: p.frameIdx,
assetId: p.assetId,
src: p.src,
label: p.label,
caption: `${p.width}×${p.height}`,
borderClass: "border-violet-300/65",
aspect: p.width && p.height ? `${p.width}/${p.height}` : "1/1",
})),
...elementCrops.map((p) => ({
id: `cutout:${p.frameIdx}:${p.elementId}:${p.cid}`,
kind: "cutout" as const,
group: "普通抠图",
frameIdx: p.frameIdx,
elementId: p.elementId,
cutoutId: p.cid,
@@ -1220,6 +1295,7 @@ export function VisualLabNode({ data, selected }: any) {
label: p.name,
caption: `分镜 ${p.frameIdx + 1}`,
borderClass: "border-violet-300/60",
aspect: "1/1",
})),
...videos.map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
@@ -1227,12 +1303,14 @@ export function VisualLabNode({ data, selected }: any) {
return {
id: `video:${v.id}`,
kind: "video" as const,
group: "视频任务",
videoId: v.id,
videoSrc: v.status === "completed" && videoSrc ? videoSrc : undefined,
posterSrc: posterSrc || undefined,
label: `视频 ${i + 1}`,
caption: `${videoModelLabel(v.model)} · ${v.status}`,
borderClass: v.status === "completed" ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55",
aspect,
}
}),
]
@@ -1266,11 +1344,14 @@ export function VisualLabNode({ data, selected }: any) {
p.kind === "frame"
? isSelected ? "border-emerald-400 ring-2 ring-emerald-400/60" : "border-white/30 dark:border-white/20"
: p.borderClass
} ${p.kind === "cutout" ? "bg-white" : "bg-black"}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
} ${p.kind === "cutout" || p.kind === "subject" ? "bg-white" : "bg-black"}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: p.aspect }}
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreview(null)}
>
<div className="absolute -top-6 left-0 z-[68] rounded bg-black/75 px-1.5 py-0.5 text-[9px] font-medium text-white/80 backdrop-blur">
{p.group}
</div>
<button
type="button"
onClick={(e) => {
@@ -1279,6 +1360,16 @@ export function VisualLabNode({ data, selected }: any) {
setPinnedPreview((prev) => (prev?.id === p.id ? null : { id: p.id, ...anchor }))
if (p.kind === "frame") {
;(d.onOpenFramePanel ?? d.onExpandFrame)(p.frameIdx)
} else if (p.kind === "scene" || p.kind === "subject") {
d.onCopyImage?.({
kind: "asset",
frame_idx: p.frameIdx,
element_id: p.assetId,
cutout_id: p.assetId,
label: p.label,
})
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenWorkbench?.(p.frameIdx)
} else if (p.kind === "cutout") {
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenStoryboard?.(p.frameIdx)
@@ -1291,7 +1382,7 @@ export function VisualLabNode({ data, selected }: any) {
}
}
}}
title={`${p.label} · 单击钉住预览${p.kind === "frame" ? " / 打开镜头处理" : p.kind === "cutout" ? " / 进入分镜编排" : " / 复制 prompt"}`}
title={`${p.label} · 单击钉住预览${p.kind === "frame" ? " / 打开素材审核" : p.kind === "video" ? " / 复制 prompt" : " / 复制到分镜编排"}`}
className="absolute inset-0 h-full w-full overflow-hidden rounded-md"
>
{p.kind === "video" ? (
@@ -1303,13 +1394,13 @@ export function VisualLabNode({ data, selected }: any) {
<div className="absolute inset-0 bg-violet-950/50" />
)
) : (
<img src={p.src} alt={p.label} className={`absolute inset-0 h-full w-full ${p.kind === "cutout" ? "object-contain" : "object-cover"}`} />
<img src={p.src} alt={p.label} className={`absolute inset-0 h-full w-full ${p.kind === "frame" ? "object-cover" : "object-contain"}`} />
)}
{p.kind === "frame" && isSelected && (
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
)}
<div className="absolute bottom-0 right-0 bg-black/70 px-1 py-0.5 text-[8.5px] font-mono leading-none text-white rounded-bl rounded-br-md">
{p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "cutout" ? "元素" : "视频"}
{p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "scene" ? "场景" : p.kind === "subject" ? "主体" : p.kind === "cutout" ? "抠图" : "视频"}
</div>
</button>
@@ -1327,6 +1418,26 @@ export function VisualLabNode({ data, selected }: any) {
</button>
)}
{(p.kind === "scene" || p.kind === "subject") && d.onCopyImage && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onCopyImage?.({
kind: "asset",
frame_idx: p.frameIdx,
element_id: p.assetId,
cutout_id: p.assetId,
label: p.label,
})
}}
title="复制此素材(到分镜头编排工作台插槽粘贴)"
className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
{p.kind === "cutout" && d.onCopyImage && (
<button
type="button"
@@ -1435,7 +1546,7 @@ export function VisualLabNode({ data, selected }: any) {
imgSrc={p.kind === "video" ? p.posterSrc : p.src}
videoSrc={p.kind === "video" ? p.videoSrc : undefined}
poster={p.kind === "video" ? p.posterSrc : undefined}
aspect={aspect}
aspect={p.aspect}
label={p.label}
caption={p.caption}
borderClass={p.borderClass}
@@ -1458,39 +1569,94 @@ export function VisualLabNode({ data, selected }: any) {
pinned={d.pinnedNodes?.has("visual")}
onTogglePin={() => d.onToggleNodePin?.("visual")}
>
<div className="grid grid-cols-3 gap-2 text-[10.5px] text-[var(--text-soft)]">
<div className="rounded-md border border-white/10 bg-black/20 p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-[var(--text-strong)]"></div>
<div className="font-mono text-[10px] text-[var(--text-faint)]">{prepPct}%</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-emerald-400 transition-all"
style={{ width: `${prepPct}%` }}
/>
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[9.5px] text-[var(--text-faint)]">
<span>{targetFrameCount} </span>
{qualityRiskCount > 0 ? (
<span className="inline-flex items-center gap-1 text-amber-300/85">
<AlertTriangle className="h-2.5 w-2.5" />
{qualityRiskCount}
</span>
) : (
<span className="inline-flex items-center gap-1 text-emerald-300/80">
<CheckCircle2 className="h-2.5 w-2.5" />
</span>
)}
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] text-[var(--text-soft)]">
<button
type="button"
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
disabled={frames.length === 0}
className="rounded-md border border-white/10 px-2 py-1.5 text-left transition hover:border-orange-300/50 hover:bg-orange-400/10 disabled:opacity-35"
title="打开镜头处理面板"
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-orange-300/50 hover:bg-orange-400/10 disabled:opacity-35"
title="打开素材准备 / 审核面板"
>
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{frames.length}</div>
<div></div>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<ImageIcon className="h-3 w-3 text-orange-300" />
{targetFrameCount}/{frames.length}
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
disabled={!job || frames.length === 0}
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-emerald-300/50 hover:bg-emerald-400/10 disabled:opacity-35"
title="生成 / 审核每张关键帧的场景图"
>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<Sparkles className="h-3 w-3 text-emerald-300" />
{sceneAssetCount}/{targetFrameCount}
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
disabled={!job || frames.length === 0}
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-violet-300/50 hover:bg-violet-400/10 disabled:opacity-35"
title="生成主体多视角 / 动作 / 表情资产包"
>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<Package className="h-3 w-3 text-violet-300" />
{subjectAssetCount || cutoutCount}
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.(frames.find((f) => d.selectedFrames.has(f.index))?.index ?? frames[0]?.index) }}
disabled={!job || frames.length === 0}
className="rounded-md border border-white/10 px-2 py-1.5 text-left transition hover:border-violet-300/50 hover:bg-violet-400/10 disabled:opacity-35"
title="进入分镜编排"
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-pink-300/50 hover:bg-pink-400/10 disabled:opacity-35"
title="进入分镜编排和视频生成"
>
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{elementCrops.length}</div>
<div> / </div>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<Film className="h-3 w-3 text-pink-300" />
{videos.length}
</div>
<div> / </div>
</button>
<div className="rounded-md border border-white/10 px-2 py-1.5">
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{videos.length}</div>
<div></div>
</div>
</div>
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
{frames.length > 0 ? (
<>
{cleanedCount} · {sceneAssetCount} · {subjectAssetCount || cutoutCount} · {d.selectedFrames.size}/{frames.length} · {completedVideos.length}
{cleanedCount} · {sceneAssetCount} · {subjectAssetCount || cutoutCount} · {selectedFrameCount}/{frames.length} · {completedVideos.length}
</>
) : (
"解析后这里展示关键帧、元素和视频任务;具体处理仍在点击后的工作台完成。"
"解析后这里变成素材准备看板:先审关键帧,再生成场景图和主体资产包。"
)}
</div>
</NodeShell>