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