Files
20260512-skg-tk/web/components/nodes/index.tsx

2584 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import {
useEffect, useRef, useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type RefObject,
} from "react"
import { createPortal } from "react-dom"
import { type NodeProps, useReactFlow } from "@xyflow/react"
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, PlayCircle, RotateCcw,
} from "lucide-react"
import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { HoverPreview } from "./hover-preview"
import {
type Job, type ImageRef, type ProductFusionShot, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
export type CanvasPanelDock = "canvas" | "left" | "right" | "bottom"
export interface NodeData {
job: Job | null // 当前 active job
jobs: Job[] // 所有 job 列表
activeJobId: string | null
submitting: boolean
analyzing: boolean
frameTargets: Record<string, FrameExtractTarget>
frameCounts: Record<string, number>
frameQualities: Record<string, FrameExtractQuality>
selectedFrames: Set<number>
expandedFrame: number | null
framePanelScale?: number
framePanelPinned?: boolean
framePanelDock?: CanvasPanelDock
videoPanelJobId?: string | null
videoPanelScale?: number
videoPanelDock?: CanvasPanelDock
onSubmitUrl: (url: string) => Promise<Job | void> | Job | void
onStartProduction?: (url?: string) => Promise<void> | void
onUploadFile: (file: File) => void
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void
onFrameCountChange: (jobId: string, count: number) => void
onFrameQualityChange: (jobId: string, quality: FrameExtractQuality) => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
onFramePanelScaleChange?: (scale: number) => void
onFramePanelPinnedChange?: (pinned: boolean) => void
onFramePanelDockChange?: (dock: CanvasPanelDock) => void
onCloseExpandedFrame: () => void
onAddManualFrame: (t: number) => void
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
onOpenVideoPanel?: (jobId: string) => void
onCloseVideoPanel?: () => void
onVideoPanelScaleChange?: (scale: number) => void
onVideoPanelDockChange?: (dock: CanvasPanelDock) => void
onSwitchJob: (id: string) => void
onJobUpdate: (j: Job) => void
onDeleteJob?: (id: string) => void
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
onDeleteFrame?: (idx: number) => void // 删整张关键帧
onDeleteFrameForJob?: (jobId: string, idx: number) => Promise<void> | void
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务
onDeleteCutout?: (frameIdx: number, elementId: string, cutoutId: string) => void // 删元素提取图
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
clipboard?: ImageRef | null
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise<void> | void
onTranscribeAudio?: (jobId?: string) => Promise<void> | void
onOpenAudioStrip?: (jobId?: string) => void
pinnedNodes?: Set<string> // 已钉住的节点 id 集合 — 钉住后位置 + 尺寸锁定
onToggleNodePin?: (id: string) => void
}
/* ---- 状态映射工具 ---- */
function inputStatus(job: Job | null): NodeStatus {
if (!job) return "pending"
return "done"
}
function downloadStatus(job: Job | null): NodeStatus {
if (!job) return "pending"
if (job.status === "failed" && job.progress < 30) return "failed"
if (job.status === "downloading") return "running"
if (job.video_url) return "done"
return "pending"
}
function splitStatus(job: Job | null): NodeStatus {
if (!job || !job.video_url) return "pending"
if (job.status === "failed" && job.progress >= 20 && job.progress < 50) return "failed"
if (job.status === "splitting") return "running"
if (["frames_extracted", "transcribing", "transcribed"].includes(job.status)) return "done"
return "pending"
}
function keyframeStatus(job: Job | null): NodeStatus {
if (!job) return "pending"
if (job.status === "failed" && job.progress >= 50 && job.progress < 70) return "failed"
if (job.frames.length === 0 && job.status === "splitting") return "running"
if (job.frames.length > 0) return "done"
return "pending"
}
function asrStatus(job: Job | null): NodeStatus {
if (!job) return "pending"
if (job.status === "transcribing") return "running"
if (job.transcript.length > 0) return "done"
if (job.status === "failed" && job.progress >= 70) return "failed"
return "pending"
}
type PreviewAnchor<T extends string | number> = { id: T; x: number; y: number }
type ScrollRailState = {
visible: boolean
leftPct: number
widthPct: number
now: number
max: number
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
const THUMBNAIL_HEIGHT = 192
const FLOATING_PANEL_EDGE_INSET = 8
const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [
{ value: "transparent_human", label: "透明骨架人", hint: "本地算力筛清晰主体,不逐帧调用 Vision" },
{ value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" },
{ value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" },
{ value: "transition", label: "转场变化", hint: "切镜和画面变化优先" },
{ value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" },
{ value: "motion", label: "动作峰值", hint: "动作变化更明显" },
]
const FRAME_COUNT_OPTIONS = [12, 8, 5, 3]
const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [
{ value: "auto", label: "自动", hint: "展示友好:按电脑性能选择,最高只到精细" },
{ value: "fast", label: "快速", hint: "2fps / 360px长视频省电" },
{ value: "accurate", label: "精细", hint: "8fps / 720pxM2 Max 轻松可用" },
{ value: "ultra", label: "极准", hint: "12fps / 960px本机约 3 秒扫描 1 分钟视频" },
]
function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return { x: 160, y: 0 }
const rootRect = root.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 }
const xRatio = (targetRect.left + targetRect.width / 2 - rootRect.left) / rootRect.width
const yRatio = (targetRect.top - rootRect.top) / rootRect.height
return {
x: xRatio * root.clientWidth,
y: yRatio * root.clientHeight,
}
}
function ThumbnailScrollRail({
scrollRef,
label = "缩略图横向滑动条",
}: {
scrollRef: RefObject<HTMLDivElement | null>
label?: string
}) {
const railRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{
pointerId: number
startX: number
startScrollLeft: number
maxScroll: number
trackRange: number
} | null>(null)
const [dragging, setDragging] = useState(false)
const [rail, setRail] = useState<ScrollRailState>({
visible: false,
leftPct: 0,
widthPct: 100,
now: 0,
max: 0,
})
useEffect(() => {
const el = scrollRef.current
if (!el) return
const update = () => {
const max = Math.max(0, el.scrollWidth - el.clientWidth)
const visible = max > 2
const widthPct = visible
? Math.max(26, Math.min(92, (el.clientWidth / Math.max(el.scrollWidth, 1)) * 100))
: 100
const leftPct = visible ? (el.scrollLeft / max) * (100 - widthPct) : 0
const next = {
visible,
leftPct: Number(leftPct.toFixed(2)),
widthPct: Number(widthPct.toFixed(2)),
now: Math.round(el.scrollLeft),
max: Math.round(max),
}
setRail((prev) => (
prev.visible === next.visible &&
Math.abs(prev.leftPct - next.leftPct) < 0.1 &&
Math.abs(prev.widthPct - next.widthPct) < 0.1 &&
prev.now === next.now &&
prev.max === next.max
) ? prev : next)
}
const raf = window.requestAnimationFrame(update)
el.addEventListener("scroll", update, { passive: true })
const resizeObserver = new ResizeObserver(update)
resizeObserver.observe(el)
const mutationObserver = new MutationObserver(update)
mutationObserver.observe(el, { childList: true, subtree: true, attributes: true })
return () => {
window.cancelAnimationFrame(raf)
el.removeEventListener("scroll", update)
resizeObserver.disconnect()
mutationObserver.disconnect()
}
}, [scrollRef])
if (!rail.visible) return null
const syncScrollFromPointer = (e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const el = scrollRef.current
if (!drag || !el) return
const delta = e.clientX - drag.startX
el.scrollLeft = clamp(
drag.startScrollLeft + (delta / drag.trackRange) * drag.maxScroll,
0,
drag.maxScroll,
)
}
return (
<div
ref={railRef}
role="scrollbar"
aria-label={label}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={rail.max}
aria-valuenow={rail.now}
tabIndex={0}
className={`thumbnail-scroll-rail nodrag nopan relative mt-2.5 h-9 rounded-full border shadow-[0_14px_28px_rgba(0,0,0,0.34),0_0_0_1px_rgba(255,255,255,0.08)] outline-none transition ${
dragging
? "cursor-grabbing border-violet-100/95 bg-violet-500/55 ring-2 ring-violet-100/80"
: "cursor-grab border-violet-200/70 bg-violet-500/32 hover:border-violet-100/90 hover:bg-violet-400/45 focus-visible:border-violet-100 focus-visible:ring-2 focus-visible:ring-violet-100/80"
}`}
onPointerDown={(e) => {
const el = scrollRef.current
const track = railRef.current
if (!el || !track) return
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth)
if (maxScroll <= 0) return
e.preventDefault()
e.stopPropagation()
const rect = track.getBoundingClientRect()
const thumbWidth = rect.width * (rail.widthPct / 100)
const trackRange = Math.max(1, rect.width - thumbWidth)
const pointerX = e.clientX - rect.left
const thumbLeft = (el.scrollLeft / maxScroll) * trackRange
let startScrollLeft = el.scrollLeft
if (pointerX < thumbLeft || pointerX > thumbLeft + thumbWidth) {
startScrollLeft = clamp(((pointerX - thumbWidth / 2) / trackRange) * maxScroll, 0, maxScroll)
el.scrollLeft = startScrollLeft
}
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startScrollLeft,
maxScroll,
trackRange,
}
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
if (dragRef.current?.pointerId !== e.pointerId) return
e.preventDefault()
e.stopPropagation()
syncScrollFromPointer(e)
}}
onPointerUp={(e) => {
if (dragRef.current?.pointerId !== e.pointerId) return
e.preventDefault()
e.stopPropagation()
dragRef.current = null
setDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
}}
onPointerCancel={(e) => {
if (dragRef.current?.pointerId !== e.pointerId) return
dragRef.current = null
setDragging(false)
}}
onKeyDown={(e) => {
const el = scrollRef.current
if (!el) return
const page = Math.max(80, el.clientWidth * 0.65)
const small = Math.max(32, el.clientWidth * 0.18)
let next: number | null = null
if (e.key === "ArrowLeft") next = el.scrollLeft - small
if (e.key === "ArrowRight") next = el.scrollLeft + small
if (e.key === "PageUp") next = el.scrollLeft - page
if (e.key === "PageDown") next = el.scrollLeft + page
if (e.key === "Home") next = 0
if (e.key === "End") next = rail.max
if (next === null) return
e.preventDefault()
e.stopPropagation()
el.scrollTo({ left: clamp(next, 0, rail.max), behavior: "smooth" })
}}
>
<div
className="absolute bottom-[6px] top-[6px] rounded-full bg-white shadow-[0_0_0_1px_rgba(255,255,255,0.78),0_0_24px_rgba(216,180,254,0.86)] transition-colors"
style={{ left: `${rail.leftPct}%`, width: `${rail.widthPct}%` }}
/>
</div>
)
}
function FloatingThumbnailStrip({
children,
label,
toolbar,
}: {
children: ReactNode
label?: string
toolbar?: ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)
return (
<div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
{toolbar && <div className="mb-2">{toolbar}</div>}
<div ref={scrollRef} className="thumbnail-strip flex items-end gap-1.5 overflow-x-auto">
{children}
</div>
<ThumbnailScrollRail scrollRef={scrollRef} label={label} />
</div>
)
}
function DeleteConfirmDialog({
title,
description,
confirmLabel = "删除",
onCancel,
onConfirm,
}: {
title: string
description: string
confirmLabel?: string
onCancel: () => void
onConfirm: () => void
}) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel()
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [onCancel])
if (typeof document === "undefined") return null
return createPortal(
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm"
onMouseDown={(e) => {
e.stopPropagation()
if (e.target === e.currentTarget) onCancel()
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-sm rounded-xl border border-white/14 bg-zinc-950/95 p-4 text-white shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-2 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/16 text-rose-200">
<Trash2 className="h-4 w-4" />
</div>
<div className="text-sm font-semibold">{title}</div>
</div>
<p className="text-xs leading-5 text-white/68">{description}</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCancel() }}
className="rounded-md border border-white/12 bg-white/[0.04] px-3 py-1.5 text-xs font-medium text-white/78 transition hover:bg-white/[0.08] hover:text-white"
>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onConfirm() }}
className="rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg shadow-rose-950/35 transition hover:bg-rose-400"
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
)
}
function FrameExtractQuickBar({
target,
count,
quality,
disabled,
running,
hasFrames,
onTargetChange,
onCountChange,
onQualityChange,
onAnalyze,
}: {
target: FrameExtractTarget
count: number
quality: FrameExtractQuality
disabled: boolean
running: boolean
hasFrames: boolean
onTargetChange: (target: FrameExtractTarget) => void
onCountChange: (count: number) => void
onQualityChange: (quality: FrameExtractQuality) => void
onAnalyze: () => void
}) {
const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0]
const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[0]
const [settingsOpen, setSettingsOpen] = useState(false)
return (
<div
className="nodrag nopan w-full rounded-lg border border-white/16 bg-zinc-950/90 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex gap-1">
<select
value={target}
disabled={disabled}
onChange={(e) => onTargetChange(e.target.value as FrameExtractTarget)}
className="h-8 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-2 text-[10.5px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
aria-label="选择自动抽帧目标"
title={option.hint}
>
{FRAME_TARGET_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<button
type="button"
disabled={disabled}
onClick={onAnalyze}
title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
className="inline-flex h-8 shrink-0 items-center justify-center gap-1 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-2.5 text-[10.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Scissors className="h-3 w-3" />}
{running ? "抽取" : hasFrames ? "追加" : "抽帧"}
</button>
<button
type="button"
disabled={disabled}
onClick={() => setSettingsOpen((open) => !open)}
title={`设置 · ${count} 张 · ${qualityOption.label}`}
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-white/12 transition disabled:cursor-not-allowed disabled:opacity-45 ${
settingsOpen ? "bg-white text-violet-700" : "bg-white/[0.06] text-white/75 hover:bg-white/[0.12] hover:text-white"
}`}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
</button>
</div>
{settingsOpen && (
<div className="mt-1.5 flex gap-1">
<select
value={count}
disabled={disabled}
onChange={(e) => onCountChange(Number(e.target.value))}
className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
aria-label="选择抽帧张数"
>
{FRAME_COUNT_OPTIONS.map((item) => (
<option key={item} value={item}>{item} </option>
))}
</select>
<select
value={quality}
disabled={disabled}
onChange={(e) => onQualityChange(e.target.value as FrameExtractQuality)}
title={qualityOption.hint}
className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
aria-label="选择抽帧精度"
>
{FRAME_QUALITY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
</div>
)}
</div>
)
}
/* ============================================================
1. InputNode — TK 链接 / 上传
============================================================ */
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
const d: NodeData = data
const [url, setUrl] = useState("")
const [hoverPreviewJob, setHoverPreviewJob] = useState<PreviewAnchor<string> | null>(null)
const [pinnedPreviewJob, setPinnedPreviewJob] = useState<PreviewAnchor<string> | null>(null)
const [deleteJobTarget, setDeleteJobTarget] = useState<Job | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const job = d.job
// 点击 input 节点外的任何位置 → 取消 pincapture 阶段,避免 ReactFlow pane 拦截)
useEffect(() => {
if (pinnedPreviewJob === null) return
const handler = (e: MouseEvent) => {
const t = e.target as HTMLElement
if (t.closest('.react-flow__node[data-id="input"]')) return
setPinnedPreviewJob(null)
}
document.addEventListener("mousedown", handler, true)
return () => document.removeEventListener("mousedown", handler, true)
}, [pinnedPreviewJob])
// 是否已下载 → 显示视频 + 解析按钮
const hasVideo = !!job?.video_url
const isDownloading = job?.status === "downloading" || job?.status === "created"
const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status)
const hasFrames = (job?.frames.length ?? 0) > 0
const inputLocked = isDownloading || d.submitting
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),每个视频上方绑定独立抽帧快捷条。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{d.jobs.length > 0 && (
<FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
{/* + 再上传一个(放在最前面) */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
title="再上传一个视频"
className="shrink-0 rounded-md border border-dashed border-white/30 hover:border-white/50 bg-white/[0.04] hover:bg-white/[0.08] inline-flex items-center justify-center text-white/60 hover:text-white transition"
style={{ width: 96, height: THUMBNAIL_HEIGHT }}
>
<Plus className="h-4 w-4" />
</button>
{[...d.jobs].reverse().map((j) => {
const isActive = j.id === d.activeJobId
const ready = !!j.video_url
const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
const thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96
const toolWidth = Math.max(148, thumbNaturalWidth)
const target = d.frameTargets[j.id] ?? "transparent_human"
const count = d.frameCounts[j.id] ?? 12
const quality = d.frameQualities[j.id] ?? "auto"
const jHasFrames = j.frames.length > 0
const jRunning = ["splitting", "transcribing"].includes(j.status)
return (
<div
key={j.id}
className="group relative flex shrink-0 flex-col gap-1.5"
style={{ width: toolWidth }}
>
{ready ? (
<FrameExtractQuickBar
target={target}
count={count}
quality={quality}
disabled={jRunning}
running={jRunning}
hasFrames={jHasFrames}
onTargetChange={(next) => d.onFrameTargetChange(j.id, next)}
onCountChange={(next) => d.onFrameCountChange(j.id, next)}
onQualityChange={(next) => d.onFrameQualityChange(j.id, next)}
onAnalyze={() => d.onAnalyzeJob(j.id, { mode: jHasFrames ? "append" : "replace" })}
/>
) : (
<div className="h-[44px] rounded-lg border border-white/10 bg-white/[0.03]" />
)}
<div
className={`relative self-center rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`}
style={{ width: thumbNaturalWidth, height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewJob(null)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (!ready) return
setPinnedPreviewJob(null)
if (!isActive) d.onSwitchJob(j.id)
d.onOpenVideoPanel?.(j.id)
}}
onDoubleClick={(e) => {
e.stopPropagation()
if (ready) {
setPinnedPreviewJob(null)
if (!isActive) d.onSwitchJob(j.id)
d.onOpenVideoPanel?.(j.id)
}
}}
title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).toFixed(1)}s · 单击打开抽帧面板` : "下载中…"}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
>
{ready ? (
<video
src={videoUrl(j.id)}
muted
loop
playsInline
preload="metadata"
poster=""
className="block w-full h-full object-cover bg-black"
/>
) : (
<div className="w-full h-full bg-black/60 flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
</div>
)}
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
{ready ? `${(j.duration ?? 0).toFixed(1)}s` : "…"}
</div>
</button>
{d.onDeleteJob && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteJobTarget(j)
}}
title="删除这个输入视频"
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)
})}
</FloatingThumbnailStrip>
)}
{deleteJobTarget && (
<DeleteConfirmDialog
title="删除这个输入视频?"
description={`会同时删除 ${deleteJobTarget.id.slice(0, 8)} 的源视频、关键帧、元素提取图、生成视频和本地任务目录。`}
confirmLabel="删除视频"
onCancel={() => setDeleteJobTarget(null)}
onConfirm={() => {
const id = deleteJobTarget.id
setDeleteJobTarget(null)
d.onDeleteJob?.(id)
}}
/>
)}
{(() => {
const anchor = pinnedPreviewJob ?? hoverPreviewJob
if (!anchor) return null
const previewJob = d.jobs.find((j) => j.id === anchor.id)
if (!previewJob?.video_url) return null
const aspectStr = previewJob.height ? `${previewJob.width}/${previewJob.height}` : "9/16"
return (
<HoverPreview
videoSrc={videoUrl(previewJob.id)}
aspect={aspectStr}
label={previewJob.width && previewJob.height ? `${previewJob.width}×${previewJob.height}` : "原视频"}
caption={previewJob.duration ? `${previewJob.duration.toFixed(1)}s` : undefined}
borderClass="border-violet-300/60"
visible={!!hoverPreviewJob && !pinnedPreviewJob}
anchorX={anchor.x}
anchorY={anchor.y}
pinned={!!pinnedPreviewJob}
onClose={() => setPinnedPreviewJob(null)}
/>
)
})()}
<NodeShell
type="input" status={inputStatus(job)}
icon={<Link2 className="h-4 w-4" />}
title="输入 · Input"
subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
selected={selected}
hasTarget={false}
pinned={d.pinnedNodes?.has("input")}
onTogglePin={() => d.onToggleNodePin?.("input")}
>
{/* URL + 上传入口 — 一直显示(即使已有视频,也可以继续加新的) */}
<>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={hasVideo ? "再加一个 TK 链接" : "粘贴 TikTok 链接"}
disabled={inputLocked}
className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/60 dark:bg-black/40 border border-black/10 dark:border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
/>
<div className="mt-2 flex gap-1.5">
<button
type="button"
disabled={inputLocked || !url.trim()}
onClick={() => { d.onSubmitUrl(url.trim()); setUrl("") }}
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-black text-white dark:bg-white dark:text-black hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center gap-1"
>
{(d.submitting || isDownloading) ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
{isDownloading ? "下载中…" : hasVideo ? "+ 加链接" : "提交链接"}
</button>
<button
type="button"
disabled={inputLocked}
onClick={() => fileRef.current?.click()}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/60 dark:bg-white/[0.06] border border-black/10 dark:border-white/15 hover:bg-white/80 dark:hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
>
<Upload className="h-3 w-3" /> {hasVideo ? "再传一个" : "上传"}
</button>
<input
ref={fileRef}
type="file"
accept="video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) d.onUploadFile(f)
e.target.value = ""
}}
/>
</div>
</>
{/* 已下载:仅元数据(视频缩略图浮在节点上方,点击进 lightbox */}
{hasVideo && job && (
<>
<div className="rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2 flex items-center justify-between text-[10.5px] font-mono">
<span className="text-[var(--text-strong)]">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
<span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
</div>
{hasFrames && (
<div className="mt-2 rounded-md border border-violet-400/20 bg-violet-500/10 px-3 py-2 text-[11px] font-medium text-violet-100">
{job.frames.length} ·
</div>
)}
</>
)}
</NodeShell>
</div>
)
}
/* ============================================================
1b. VideoFramePanelNode — 画布内视频抽帧工作面板
============================================================ */
export function VideoFramePanelNode({ data }: any) {
const d: NodeData = data
const { getZoom } = useReactFlow()
const panelJob = d.videoPanelJobId
? d.jobs.find((j) => j.id === d.videoPanelJobId) ?? null
: null
const videoRef = useRef<HTMLVideoElement>(null)
const scale = d.videoPanelScale ?? 1
const dock = d.videoPanelDock ?? "canvas"
const docked = dock !== "canvas"
const [currentT, setCurrentT] = useState(0)
const [adding, setAdding] = useState(false)
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
useEffect(() => {
setCurrentT(0)
setAdding(false)
setDeletingFrame(null)
}, [panelJob?.id])
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") d.onCloseVideoPanel?.()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [d])
if (!panelJob?.video_url) return null
const panelWidth = Math.round(760 * scale)
const panelHeight = Math.round(620 * scale)
const bodyHeight = Math.max(500, panelHeight - 28)
const duration = panelJob.duration ?? 0
const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp)
const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16"
const panelTarget = d.frameTargets[panelJob.id] ?? "transparent_human"
const panelCount = d.frameCounts[panelJob.id] ?? 12
const panelQuality = d.frameQualities[panelJob.id] ?? "auto"
const panelRunning = ["splitting", "transcribing"].includes(panelJob.status)
const dockText: Record<CanvasPanelDock, string> = {
canvas: "画布模式",
left: "吸附左侧",
right: "吸附右侧",
bottom: "吸附底部",
}
const setScale = (next: number) => {
const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
d.onVideoPanelScaleChange?.(clamped)
}
const startResize = (e: ReactPointerEvent) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const startScale = scale
const zoom = docked ? 1 : getZoom()
const onMove = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) / zoom
const dy = (ev.clientY - startY) / zoom
const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 620
setScale(startScale + delta)
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
const seekTo = (next: number) => {
const t = clamp(next, 0, Math.max(duration, 0))
setCurrentT(t)
if (videoRef.current) videoRef.current.currentTime = t
}
const addCurrentFrame = async () => {
const t = videoRef.current?.currentTime ?? currentT
setAdding(true)
try {
if (d.onAddManualFrameForJob) await d.onAddManualFrameForJob(panelJob.id, t)
else await d.onAddManualFrame(t)
} finally {
setAdding(false)
}
}
const deleteFrameFromPanel = async (idx: number) => {
setDeletingFrame(idx)
try {
if (d.onDeleteFrameForJob) await d.onDeleteFrameForJob(panelJob.id, idx)
else await d.onDeleteFrame?.(idx)
} finally {
setDeletingFrame(null)
}
}
const dockButtonClass = (value: CanvasPanelDock) =>
`nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
dock === value
? "bg-white text-violet-700 shadow"
: "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
}`
const panel = (
<div
className="relative overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
style={{
width: panelWidth,
height: panelHeight,
maxWidth: "calc(100vw - 32px)",
maxHeight: `calc(100vh - ${FLOATING_PANEL_EDGE_INSET * 2}px)`,
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)",
}}
>
<div
className={`video-frame-panel-drag flex h-7 items-center justify-between px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}
style={{ background: "var(--grad-input)" }}
>
<div className="flex min-w-0 items-center gap-2">
<FileVideo className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-[12px] font-semibold"> · Input</span>
<span className="shrink-0 text-[10px] font-mono text-white/65">
{panelJob.width}×{panelJob.height} · {duration.toFixed(1)}s
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="mr-1 text-[10px] text-white/60">
{dockText[dock]}
</span>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
<Move className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
<PanelLeft className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
<PanelRight className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
<PanelBottom className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="缩小面板">
-
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(1) }} className="nodrag h-6 min-w-10 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center" title="重置为 100%">
{Math.round(scale * 100)}%
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="放大面板">
+
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onCloseVideoPanel?.() }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center" title="关闭">
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="nodrag nowheel grid gap-3 p-3 lg:grid-cols-[minmax(260px,0.8fr)_minmax(280px,1fr)]" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
<div className="flex min-h-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black">
<video
ref={videoRef}
src={videoUrl(panelJob.id)}
controls
autoPlay
muted
playsInline
preload="auto"
onTimeUpdate={(e) => setCurrentT((e.target as HTMLVideoElement).currentTime)}
className="max-h-full max-w-full bg-black"
style={{ aspectRatio: aspect }}
/>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-hidden">
<FrameExtractQuickBar
target={panelTarget}
count={panelCount}
quality={panelQuality}
disabled={panelRunning}
running={panelRunning}
hasFrames={frames.length > 0}
onTargetChange={(next) => d.onFrameTargetChange(panelJob.id, next)}
onCountChange={(next) => d.onFrameCountChange(panelJob.id, next)}
onQualityChange={(next) => d.onFrameQualityChange(panelJob.id, next)}
onAnalyze={() => d.onAnalyzeJob(panelJob.id, { mode: frames.length > 0 ? "append" : "replace" })}
/>
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="font-mono text-[12px] text-white/72">
<span className="text-white">{currentT.toFixed(2)}s</span>
</div>
<div className="font-mono text-[11px] text-white/45">
{frames.length}
</div>
</div>
<input
type="range"
min={0}
max={Math.max(duration, 0.1)}
step={0.01}
value={clamp(currentT, 0, Math.max(duration, 0.1))}
onChange={(e) => seekTo(Number(e.target.value))}
className="w-full accent-violet-400"
aria-label="视频时间轴"
/>
<button
type="button"
disabled={adding}
onClick={(e) => { e.stopPropagation(); void addCurrentFrame() }}
className="mt-3 w-full rounded-lg bg-emerald-500 px-4 py-2.5 text-[13px] font-semibold text-white shadow-lg shadow-emerald-950/35 transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-55 inline-flex items-center justify-center gap-2"
>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "抽帧中…" : `${currentT.toFixed(1)}s 加为关键帧`}
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-white/10 bg-white/[0.035] p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[12px] font-semibold text-white/85"></div>
<div className="text-[10px] font-mono text-white/40"></div>
</div>
{frames.length > 0 ? (
<div className="grid max-h-full grid-cols-3 gap-2 overflow-y-auto pr-1">
{frames.map((f) => (
<div
key={f.index}
className="group relative overflow-hidden rounded-md border border-white/10 bg-black text-left transition hover:border-violet-300/70"
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); seekTo(f.timestamp) }}
className="block w-full text-left"
title={`跳到 ${f.timestamp.toFixed(2)}s`}
>
<img src={effectiveFrameUrl(panelJob.id, f)} alt="" className="aspect-[9/16] w-full object-cover opacity-90 transition group-hover:opacity-100" />
<div className="px-1.5 py-1 font-mono text-[9.5px] text-white/65">
{f.timestamp.toFixed(1)}s
</div>
</button>
{(d.onDeleteFrameForJob || d.onDeleteFrame) && (
<button
type="button"
disabled={deletingFrame === f.index}
onClick={(e) => {
e.stopPropagation()
void deleteFrameFromPanel(f.index)
}}
className="absolute right-1 top-1 z-10 inline-flex h-6 w-6 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400 disabled:cursor-wait disabled:opacity-70"
title={`删除 ${f.timestamp.toFixed(1)}s 关键帧`}
aria-label={`删除 ${f.timestamp.toFixed(1)}s 关键帧`}
>
{deletingFrame === f.index ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</button>
)}
</div>
))}
</div>
) : (
<div className="flex h-full min-h-32 items-center justify-center rounded-lg border border-dashed border-white/10 text-[12px] text-white/35">
</div>
)}
</div>
</div>
</div>
<button
type="button"
onPointerDown={startResize}
className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-violet-400/35 hover:text-white inline-flex items-center justify-center"
title="拖动右下角缩放面板"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
)
if (docked && typeof document !== "undefined") {
const fixedStyle =
dock === "left"
? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
: dock === "right"
? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
: { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" }
return createPortal(
<div className="fixed z-[240]" style={fixedStyle}>
{panel}
</div>,
document.body,
)
}
return panel
}
/* ============================================================
2. DownloadNode
============================================================ */
export function DownloadNode({ data, selected }: any) {
const d: NodeData = data
const st = downloadStatus(d.job)
return (
<NodeShell
type="process" status={st}
icon={<Download className="h-4 w-4" />}
title="下载 · Download"
subtitle="STEP 2 · yt-dlp"
selected={selected}
>
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
{d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
</div>
{d.job && st === "done" && (
<div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] font-mono text-[var(--text-faint)]">
<div><br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.width}×{d.job.height}</span></div>
<div><br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.duration.toFixed(1)}s</span></div>
</div>
)}
</NodeShell>
)
}
/* ============================================================
3. SplitNode
============================================================ */
export function SplitNode({ data, selected }: any) {
const d: NodeData = data
return (
<NodeShell
type="process" status={splitStatus(d.job)}
icon={<Scissors className="h-4 w-4" />}
title="拆分 · Split"
subtitle="STEP 3 · ffmpeg"
selected={selected}
>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]"></div>
<div className="text-[var(--text-strong)] mt-0.5"> </div>
</div>
<div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]"></div>
<div className="text-[var(--text-strong)] mt-0.5"> ASR</div>
</div>
</div>
</NodeShell>
)
}
/* ============================================================
4. KeyframeNode — 缩略图横排浮在节点上方,点击展开 lightbox
============================================================ */
const KEYFRAME_WIDTH = 360
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
? job.frames.flatMap((f) =>
(f.elements ?? [])
.filter((e) => hasCutout(e))
.map((e) => {
const src = representativeCutoutUrl(job.id, f.index, e) || ""
const cid = (e.cutouts && e.cutouts.length > 0)
? e.cutouts[e.cutouts.length - 1]
: (e.cutout_id ?? "")
return {
frameIdx: f.index,
elementId: e.id,
name: e.name_zh,
src,
cid,
timestamp: f.timestamp,
}
})
.filter((p) => p.src),
)
: []
}
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"
if (m.includes("veo")) return "Veo 3"
if (m.includes("seedance")) return "Seedance"
return model || "Video"
}
/* ============================================================
4. VisualLabNode — 合并镜头拆解 / 元素改造 / 生视频展示入口
============================================================ */
export function VisualLabNode({ data, selected }: any) {
const d: NodeData = data
const job = d.job
const frames = job?.frames ?? []
const videos = job?.generated_videos ?? []
const jobId = job?.id
const aspect = job && (job.width ?? 0) > 0 && (job.height ?? 0) > 0
? `${job.width}/${job.height}`
: "9/16"
const sceneAssets = collectSceneAssets(job)
const subjectAssets = collectSubjectAssets(job)
const cleanedCount = frames.filter((x) => x.cleaned_url).length
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")
const status: NodeStatus = runningVideo
? "running"
: failedVideo
? "failed"
: frames.length > 0 || subjectAssets.length > 0 || completedVideos.length > 0
? "done"
: keyframeStatus(job)
type VisualPreview =
| { 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: "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)
const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
const previews: VisualPreview[] = [
...(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${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,
})) : []),
...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",
})),
...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,
})),
...videos.map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
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,
}
}),
]
useEffect(() => {
if (pinnedPreview === null) return
const handler = (e: MouseEvent) => {
const t = e.target as HTMLElement
if (t.closest('.react-flow__node[data-id="visual"]')) return
setPinnedPreview(null)
}
document.addEventListener("mousedown", handler, true)
return () => document.removeEventListener("mousedown", handler, true)
}, [pinnedPreview])
const openFirstFrame = () => {
const idx = frames[0]?.index
if (typeof idx === "number") (d.onOpenFramePanel ?? d.onExpandFrame)(idx)
}
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{previews.length > 0 && (
<FloatingThumbnailStrip label="画面工作台缩略图横向滑动条">
{previews.map((p) => {
const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx)
return (
<div
key={p.id}
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
p.kind === "frame"
? isSelected ? "border-emerald-400 ring-2 ring-emerald-400/60" : "border-white/30 dark:border-white/20"
: p.borderClass
} ${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) => {
e.stopPropagation()
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
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 {
const video = videos.find((v) => v.id === p.videoId)
if (video) {
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
toast.success("已复制视频 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" ? (
p.videoSrc ? (
<video src={p.videoSrc} poster={p.posterSrc} muted loop playsInline preload="metadata" className="absolute inset-0 h-full w-full object-cover" />
) : p.posterSrc ? (
<img src={p.posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
) : (
<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 === "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 === "scene" ? "场景" : p.kind === "subject" ? "主体" : "视频"}
</div>
</button>
{p.kind === "frame" && d.onCopyImage && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onCopyImage?.({ kind: "keyframe", frame_idx: p.frameIdx, 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 === "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 === "video" && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
const video = videos.find((v) => v.id === p.videoId)
if (!video) return
void navigator.clipboard?.writeText(video.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
className="absolute left-1.5 top-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"
title="复制视频 prompt"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
{p.kind === "frame" && d.onDeleteFrame && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onDeleteFrame?.(p.frameIdx)
}}
title="删除该关键帧"
className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{p.kind === "video" && d.onDeleteVideo && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteVideoTarget({ id: p.videoId, label: p.label, caption: p.caption })
}}
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
title="删除这个视频任务"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</FloatingThumbnailStrip>
)}
{deleteVideoTarget && (
<DeleteConfirmDialog
title="删除这个视频任务?"
description={`会删除${deleteVideoTarget.label}的生成任务和本地视频文件。${deleteVideoTarget.caption}`}
confirmLabel="删除视频任务"
onCancel={() => setDeleteVideoTarget(null)}
onConfirm={() => {
const id = deleteVideoTarget.id
setDeleteVideoTarget(null)
d.onDeleteVideo?.(id)
}}
/>
)}
{(() => {
const anchor = pinnedPreview ?? hoverPreview
if (!anchor) return null
const p = previews.find((x) => x.id === anchor.id)
if (!p) return null
return (
<HoverPreview
imgSrc={p.kind === "video" ? p.posterSrc : p.src}
videoSrc={p.kind === "video" ? p.videoSrc : undefined}
poster={p.kind === "video" ? p.posterSrc : undefined}
aspect={p.aspect}
label={p.label}
caption={p.caption}
borderClass={p.borderClass}
visible={!!hoverPreview && !pinnedPreview}
anchorX={anchor.x}
anchorY={anchor.y}
pinned={!!pinnedPreview}
onClose={() => setPinnedPreview(null)}
/>
)
})()}
<NodeShell
type="ai"
status={status}
icon={<LayoutGrid className="h-4 w-4" />}
title="画面工作台 · Visual Lab"
subtitle={`STEP 2-7 · ${frames.length ? `${frames.length}` : "等待解析"}${videos.length ? ` · ${videos.length} 视频` : ""}`}
selected={selected}
pinned={d.pinnedNodes?.has("visual")}
onTogglePin={() => d.onToggleNodePin?.("visual")}
>
<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="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="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<ImageIcon className="h-3 w-3 text-orange-300" />
{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-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}
</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(); d.onOpenWorkbench?.(frames.find((f) => d.selectedFrames.has(f.index))?.index ?? frames[0]?.index) }}
disabled={!job || frames.length === 0}
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="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>
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
{frames.length > 0 ? (
<>
{cleanedCount} · {subjectAssetCount} · {sceneAssetCount} · {targetFrameCount} · {completedVideos.length}
</>
) : (
"解析后这里变成素材准备看板:先审关键帧,再生成主体资产包和去主体场景图。"
)}
</div>
</NodeShell>
</div>
)
}
export function KeyframeNode({ data, selected }: any) {
const d: NodeData = data
const st = keyframeStatus(d.job)
const frames = d.job?.frames ?? []
const jobId = d.job?.id
const aspectStr = d.job && d.job.height > 0 ? `${d.job.width}/${d.job.height}` : "9/16"
const [hoverPreviewFrame, setHoverPreviewFrame] = useState<PreviewAnchor<number> | null>(null)
const [pinnedPreviewFrame, setPinnedPreviewFrame] = useState<PreviewAnchor<number> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
// 点击 keyframe 节点外的任何位置 → 取消 pincapture 阶段,避免 ReactFlow pane 拦截)
useEffect(() => {
if (pinnedPreviewFrame === null) return
const handler = (e: MouseEvent) => {
const t = e.target as HTMLElement
if (t.closest('.react-flow__node[data-id="keyframe"]')) return
setPinnedPreviewFrame(null)
}
document.addEventListener("mousedown", handler, true)
return () => document.removeEventListener("mousedown", handler, true)
}, [pinnedPreviewFrame])
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
{frames.length > 0 && jobId && (
<FloatingThumbnailStrip label="关键帧缩略图横向滑动条">
{frames.map((f) => {
const isSel = d.selectedFrames.has(f.index)
return (
<div
key={f.index}
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 ${
isSel
? "border-emerald-400 ring-2 ring-emerald-400/60"
: "border-white/30 dark:border-white/20"
}`}
style={{
height: THUMBNAIL_HEIGHT,
aspectRatio: d.job && d.job.height > 0
? `${d.job.width}/${d.job.height}`
: "16/9",
}}
onMouseEnter={(e) => setHoverPreviewFrame({ id: f.index, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewFrame(null)}
>
<button
onClick={(e) => {
e.stopPropagation()
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
setPinnedPreviewFrame((prev) => (prev?.id === f.index ? null : { id: f.index, ...anchor }))
;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
}}
title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 单击钉住大预览 / 打开详情面板`}
className="absolute inset-0 w-full h-full"
>
<img
src={effectiveFrameUrl(jobId, f)}
alt={`frame ${f.index}`}
className="absolute inset-0 w-full h-full object-cover rounded-md"
/>
{isSel && (
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
)}
{(f.cleaned_url || (f.elements?.some((e) => hasCutout(e)))) && (
<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) => hasCutout(e)).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
</div>
</button>
{/* 复制按钮:常驻可见 — 复制该关键帧到剪贴板 */}
{d.onCopyImage && (
<button
onClick={(e) => {
e.stopPropagation()
d.onCopyImage?.({
kind: "keyframe",
frame_idx: f.index,
label: `分镜 ${f.index + 1} 关键帧`,
})
}}
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>
)}
{/* 删除按钮:常驻可见 */}
{d.onDeleteFrame && (
<button
onClick={(e) => {
e.stopPropagation()
d.onDeleteFrame?.(f.index)
}}
title="删除该关键帧"
className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</FloatingThumbnailStrip>
)}
{(() => {
const anchor = pinnedPreviewFrame ?? hoverPreviewFrame
if (!anchor || !jobId) return null
const frame = frames.find((f) => f.index === anchor.id)
if (!frame) return null
return (
<HoverPreview
imgSrc={effectiveFrameUrl(jobId, frame)}
aspect={aspectStr}
label={`分镜 ${frame.index + 1}`}
caption={`${frame.timestamp.toFixed(2)}s`}
borderClass="border-orange-300/50"
visible={!!hoverPreviewFrame && !pinnedPreviewFrame}
anchorX={anchor.x}
anchorY={anchor.y}
pinned={!!pinnedPreviewFrame}
onClose={() => setPinnedPreviewFrame(null)}
/>
)
})()}
<NodeShell
type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />}
title="镜头拆解 · 素材准备"
subtitle={`STEP 2 · ${frames.length ? `${frames.length} 素材帧` : "等待抽取"}`}
selected={selected}
pinned={d.pinnedNodes?.has("keyframe")}
onTogglePin={() => d.onToggleNodePin?.("keyframe")}
>
{frames.length > 0 ? (() => {
const cleanedCount = frames.filter((x) => x.cleaned_url).length
const elementsCount = frames.reduce((s, x) => s + (x.elements?.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={elementsCount > 0 ? "text-violet-300/90 font-medium" : ""}>{elementsCount} </span>
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
/ SKG
</span>
</div>
)
})() : (
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
5
</div>
)}
</NodeShell>
</div>
)
}
/* ============================================================
4b. KeyframePanelNode — 画布内可移动详情面板
============================================================ */
export function KeyframePanelNode({ data }: any) {
const d: NodeData = data
const { getZoom } = useReactFlow()
const panelRef = useRef<HTMLDivElement>(null)
const scale = d.framePanelScale ?? 1
const dock = d.framePanelDock ?? (d.framePanelPinned ? "left" : "canvas")
const docked = dock !== "canvas"
if (!d.job || d.expandedFrame === null) return null
const active = d.job.frames.find((f) => f.index === d.expandedFrame)
const arrayPos = active ? d.job.frames.findIndex((f) => f.index === active.index) : -1
const prevFrame = arrayPos > 0 ? d.job.frames[arrayPos - 1] : null
const nextFrame = arrayPos >= 0 && arrayPos < d.job.frames.length - 1 ? d.job.frames[arrayPos + 1] : null
const panelWidth = Math.round(760 * scale)
const panelHeight = Math.round(746 * scale)
const bodyHeight = Math.max(520, panelHeight - 27)
const setScale = (next: number) => {
const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
d.onFramePanelScaleChange?.(clamped)
}
const dockText: Record<CanvasPanelDock, string> = {
canvas: "画布模式",
left: "吸附左侧",
right: "吸附右侧",
bottom: "吸附底部",
}
const dockButtonClass = (value: CanvasPanelDock) =>
`nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
dock === value
? "bg-white text-violet-700 shadow"
: "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
}`
const startResize = (e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const startScale = scale
const zoom = docked ? 1 : getZoom()
const onMove = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) / zoom
const dy = (ev.clientY - startY) / zoom
const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 746
setScale(startScale + delta)
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
const panel = (
<div
ref={panelRef}
className="relative overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
style={{
width: panelWidth,
height: panelHeight,
maxWidth: "calc(100vw - 32px)",
maxHeight: `calc(100vh - ${FLOATING_PANEL_EDGE_INSET * 2}px)`,
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)",
}}
>
<div
className={`keyframe-panel-drag flex h-7 items-center justify-between px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}
style={{ background: "var(--grad-ai)" }}
>
<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="shrink-0 text-[10px] font-mono text-white/65">
{active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
</span>
<div className="ml-1 flex items-center gap-1">
<button
type="button"
disabled={!prevFrame}
onClick={(e) => {
e.stopPropagation()
if (prevFrame) d.onExpandFrame(prevFrame.index)
}}
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 inline-flex items-center justify-center"
title="上一张关键帧"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
disabled={!nextFrame}
onClick={(e) => {
e.stopPropagation()
if (nextFrame) d.onExpandFrame(nextFrame.index)
}}
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 inline-flex items-center justify-center"
title="下一张关键帧"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
<span className="shrink-0 text-[10px] font-mono text-white/50">
{arrayPos >= 0 ? `${String(arrayPos + 1).padStart(2, "0")} / ${String(d.job.frames.length).padStart(2, "0")}` : ""}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="mr-1 text-[10px] text-white/60">
{dockText[dock]}
</span>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
<Move className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
<PanelLeft className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
<PanelRight className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onFramePanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
<PanelBottom className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none"
title="缩小面板"
>
-
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setScale(1) }}
className="nodrag h-6 min-w-10 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
title="重置为 100%"
>
{Math.round(scale * 100)}%
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }}
className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none"
title="放大面板"
>
+
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
className="nodrag h-6 w-6 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center"
title="关闭"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="nodrag nowheel" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
<FrameLightbox
embedded
jobId={d.job.id}
frames={d.job.frames}
generatedVideos={d.job.generated_videos ?? []}
activeIndex={d.expandedFrame}
selected={d.selectedFrames}
onClose={d.onCloseExpandedFrame}
onChange={d.onExpandFrame}
onToggleSelect={d.onToggleFrame}
onJobUpdate={d.onJobUpdate}
clipboard={d.clipboard}
onCopyImage={d.onCopyImage}
onGenerateProductFusionVideo={d.onGenerateProductFusionVideo}
onDeleteVideo={d.onDeleteVideo}
/>
</div>
<button
type="button"
onPointerDown={startResize}
className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-pink-400/35 hover:text-white inline-flex items-center justify-center"
title="拖动右下角缩放面板"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
)
if (docked && typeof document !== "undefined") {
const fixedStyle =
dock === "left"
? { left: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
: dock === "right"
? { right: FLOATING_PANEL_EDGE_INSET, top: FLOATING_PANEL_EDGE_INSET }
: { left: "50%", bottom: FLOATING_PANEL_EDGE_INSET, transform: "translateX(-50%)" }
return createPortal(
<div
className="fixed z-[240]"
style={fixedStyle}
>
{panel}
</div>,
document.body,
)
}
return panel
}
/* ============================================================
5. ASRNode — 音频转写
============================================================ */
export function ASRNode({ data, selected }: any) {
const d: NodeData = data
return (
<NodeShell
type="ai" status={asrStatus(d.job)}
icon={<Mic className="h-4 w-4" />}
title="声音文案 · ASR"
subtitle="STEP 3 · 可选文案轨"
selected={selected}
pinned={d.pinnedNodes?.has("asr")}
onTogglePin={() => d.onToggleNodePin?.("asr")}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
OpenAI-compatible ASR ·
</div>
{d.job && d.job.transcript.length > 0 && (
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
{d.job.transcript.slice(0, 3).map((s) => (
<div key={s.index} className="leading-snug">
<span className="text-[var(--text-faint)] font-mono text-[10px] mr-1">
{s.start.toFixed(1)}s
</span>
{s.en.slice(0, 60)}
{s.en.length > 60 && "…"}
</div>
))}
{d.job.transcript.length > 3 && (
<div className="text-[var(--text-faint)] text-[10px]"> {d.job.transcript.length - 3} </div>
)}
</div>
)}
</NodeShell>
)
}
/* ============================================================
6. TranslateNode
============================================================ */
export function TranslateNode({ data, selected }: any) {
const d: NodeData = data
const hasZh = d.job?.transcript.some((s) => s.zh) ?? false
const st: NodeStatus = !d.job ? "pending" :
d.job.status === "transcribing" ? "running" :
hasZh ? "done" :
d.job.status === "failed" ? "failed" : "pending"
return (
<NodeShell
type="ai" status={st}
icon={<Languages className="h-4 w-4" />}
title="翻译理解 · Translate"
subtitle="STEP 4 · EN → ZH"
selected={selected}
pinned={d.pinnedNodes?.has("translate")}
onTogglePin={() => d.onToggleNodePin?.("translate")}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
· ·
</div>
{hasZh && d.job && (
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
{d.job.transcript.slice(0, 3).map((s) => (
<div key={s.index} className="leading-snug">{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}</div>
))}
</div>
)}
</NodeShell>
)
}
/* ============================================================
7. RewriteNode
============================================================ */
export function RewriteNode({ data, selected }: any) {
const d: NodeData = data
const rewrittenText = d.job?.audio_script?.rewritten_text?.trim() ?? ""
return (
<NodeShell
type="ai" status={rewrittenText ? "done" : d.job?.audio_script?.status === "rewriting" ? "running" : "pending"}
icon={<FileEdit className="h-4 w-4" />}
title="产品文案 · Rewrite"
subtitle="STEP 5 · 接 SKG 卖点"
selected={selected}
pinned={d.pinnedNodes?.has("rewrite")}
onTogglePin={() => d.onToggleNodePin?.("rewrite")}
>
{rewrittenText ? (
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2 text-[11.5px] leading-relaxed text-[var(--text-strong)]">
{rewrittenText}
</div>
) : (
<div className="text-[11px] text-[var(--text-soft)]"> SKG </div>
)}
<div className="mt-1.5 text-[10px] text-[var(--text-faint)]">{d.job?.audio_script?.rewrite_model || "AUDIO_REWRITE_MODEL"}</div>
</NodeShell>
)
}
/* ============================================================
5b. AudioNode — 合并 ASR + 翻译 + 改写 + Azure OpenAI 配音
============================================================ */
export function AudioNode({ data, selected }: any) {
const d: NodeData = data
const job = d.job
const transcript = job?.transcript ?? []
const audioScript = job?.audio_script
const rewrittenText = audioScript?.rewritten_text?.trim() ?? ""
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
const hasASR = transcript.length > 0
const isRewriting = audioScript?.status === "rewriting"
const hasVideo = !!job?.video_url
const isAudioBusy = !!job && (job.status === "transcribing" || isRewriting)
const audioButtonDisabled = !job || !hasVideo || isAudioBusy
const audioButtonLabel = !hasVideo
? "等待视频就绪"
: isAudioBusy
? "正在提取音频"
: hasASR || rewrittenText
? "重新提取音频"
: "提取音频"
const originalPreview = transcript
.slice(0, 2)
.map((s) => (s.zh || s.en).trim())
.filter(Boolean)
.join(" ")
const status: NodeStatus = !job
? "pending"
: job.status === "transcribing" || isRewriting
? "running"
: rewrittenText || hasASR
? "done"
: "pending"
return (
<NodeShell
type="ai" status={status}
icon={<Mic className="h-4 w-4" />}
title="音频处理 · Audio"
subtitle={hasASR ? `STEP 3 · ${transcript.length}` : "STEP 3"}
selected={selected}
pinned={d.pinnedNodes?.has("audio")}
onTogglePin={() => d.onToggleNodePin?.("audio")}
>
<div
className="cursor-pointer space-y-2 text-[11px] text-[var(--text-soft)] leading-snug"
onClick={() => {
if (job?.video_url) d.onOpenAudioStrip?.(job.id)
}}
>
<div>
/ SKG Azure OpenAI <br />
<span className="text-[var(--text-faint)] font-mono">
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} {audioScript?.voice_model || "Azure OpenAI TTS"}
</span>
</div>
{job && (
<button
type="button"
disabled={audioButtonDisabled}
onClick={(e) => {
e.stopPropagation()
d.onOpenAudioStrip?.(job.id)
if (audioButtonDisabled) return
void d.onTranscribeAudio?.(job.id)
}}
className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18 disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/[0.03] disabled:text-[var(--text-faint)]"
>
{isAudioBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : hasASR || rewrittenText ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{audioButtonLabel}
</button>
)}
{(originalPreview || rewrittenText) && (
<div className="grid gap-2">
{originalPreview && (
<div className="rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-2">
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-[var(--text-faint)]"> · </div>
<div className="line-clamp-3 text-[11px] leading-relaxed text-[var(--text-soft)] break-words">{originalPreview}</div>
</div>
)}
{rewrittenText && (
<div className="rounded-md border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-2">
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-emerald-200/80"> · SKG Product VO</div>
<div className="line-clamp-4 text-[11.5px] leading-relaxed text-[var(--text-strong)] break-words">{rewrittenText}</div>
</div>
)}
</div>
)}
{voiceUrl && <div className="text-[10.5px] text-emerald-200/85">Azure OpenAI English voice ready · </div>}
{isRewriting && (
<div className="text-[10.5px] text-[var(--text-faint)]"></div>
)}
{audioScript?.error && rewrittenText && !voiceUrl && (
<div className="text-[10.5px] text-amber-300/85">{audioScript.error}</div>
)}
</div>
</NodeShell>
)
}
/* ============================================================
6. StoryboardNode — 元素改造 + 分镜编排入口
============================================================ */
const IMAGEGEN_WIDTH = 360
export function StoryboardNode({ data, selected }: any) {
const d: NodeData = data
const job = d?.job
const [hoverPreviewCutout, setHoverPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
const [pinnedPreviewCutout, setPinnedPreviewCutout] = useState<PreviewAnchor<string> | null>(null)
const rootRef = useRef<HTMLDivElement>(null)
// 点击 storyboard 节点外 → 取消 pin
useEffect(() => {
if (pinnedPreviewCutout === null) return
const handler = (e: MouseEvent) => {
const t = e.target as HTMLElement
if (t.closest('.react-flow__node[data-id="storyboard"]')) return
setPinnedPreviewCutout(null)
}
document.addEventListener("mousedown", handler, true)
return () => document.removeEventListener("mousedown", handler, true)
}, [pinnedPreviewCutout])
// 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材)
type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; timestamp: number }
const elementCrops: ElPreview[] = job
? job.frames.flatMap((f) =>
(f.elements ?? [])
.filter((e) => hasCutout(e))
.map((e) => {
const src = representativeCutoutUrl(job.id, f.index, e) || ""
const cid = (e.cutouts && e.cutouts.length > 0)
? e.cutouts[e.cutouts.length - 1]
: (e.cutout_id ?? "")
return {
frameIdx: f.index,
elementId: e.id,
name: e.name_zh,
src,
cid,
timestamp: f.timestamp,
}
})
.filter((p) => p.src),
)
: []
const totalElements = elementCrops.length
const storyboardCount = job?.frames.filter((f) => d.selectedFrames.has(f.index)).length ?? 0
const status: NodeStatus = !job ? "pending" : storyboardCount > 0 || totalElements > 0 ? "done" : "pending"
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 节点上方:所有元素 crop 图(编排输入素材)— 视觉类节点统一样板:单行横滚 + 左上复制 + 右上删除 + hover/click pin 大预览 */}
{elementCrops.length > 0 && job && (
<FloatingThumbnailStrip label="元素缩略图横向滑动条">
{elementCrops.map((p) => {
const key = `${p.frameIdx}_${p.elementId}`
return (
<div
key={key}
className="group relative shrink-0 rounded-md border border-violet-300/50 overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-white"
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreviewCutout({ id: key, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewCutout(null)}
>
<button
onClick={(e) => {
e.stopPropagation()
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
setPinnedPreviewCutout((prev) => (prev?.id === key ? null : { id: key, ...anchor }))
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenStoryboard?.(p.frameIdx)
d.onOpenWorkbench?.(p.frameIdx)
}}
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 单击钉住大预览 / 进入分镜编排`}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-white"
>
<img
src={p.src}
alt={p.name}
className="absolute inset-0 w-full h-full object-contain"
/>
</button>
{/* 左上:复制 */}
{d.onCopyImage && (
<button
onClick={(e) => {
e.stopPropagation()
d.onCopyImage?.({
kind: "cutout",
frame_idx: p.frameIdx,
element_id: p.elementId,
cutout_id: p.cid,
label: p.name,
})
}}
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>
)}
{/* 右上:删除 */}
{d.onDeleteCutout && (
<button
onClick={(e) => {
e.stopPropagation()
d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cid)
}}
title="删除该提取图"
className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)
})}
</FloatingThumbnailStrip>
)}
{(() => {
const anchor = pinnedPreviewCutout ?? hoverPreviewCutout
if (!anchor) return null
const crop = elementCrops.find((p) => `${p.frameIdx}_${p.elementId}` === anchor.id)
if (!crop) return null
return (
<HoverPreview
imgSrc={crop.src}
aspect={aspect}
label={`分镜 ${crop.frameIdx + 1} · ${crop.name}`}
caption={`${crop.timestamp.toFixed(2)}s`}
borderClass="border-violet-300/60"
visible={!!hoverPreviewCutout && !pinnedPreviewCutout}
anchorX={anchor.x}
anchorY={anchor.y}
pinned={!!pinnedPreviewCutout}
onClose={() => setPinnedPreviewCutout(null)}
/>
)
})()}
<NodeShell
type="ai" status={status}
icon={<LayoutGrid className="h-4 w-4" />}
title="元素改造 · Storyboard"
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
selected={selected}
pinned={d.pinnedNodes?.has("storyboard")}
onTogglePin={() => d.onToggleNodePin?.("storyboard")}
>
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
/ / / SKG
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
{totalElements} · {storyboardCount}
</span>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.() }}
disabled={!job || storyboardCount === 0}
className="mt-2 w-full rounded-md bg-gradient-to-r from-violet-500 to-pink-500 px-3 py-2 text-[12px] font-semibold text-white shadow-lg shadow-violet-500/25 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-35"
title={storyboardCount === 0 ? "先准备关键帧素材" : "进入 4 图槽分镜编排"}
>
</button>
</NodeShell>
</div>
)
}
/* ============================================================
9. VideoGenNode (placeholder)
============================================================ */
export function VideoGenNode({ data, selected }: any) {
const d: NodeData = data
const videos = d.job?.generated_videos ?? []
const rootRef = useRef<HTMLDivElement>(null)
const [hoverPreviewVideo, setHoverPreviewVideo] = useState<PreviewAnchor<string> | null>(null)
const [deleteVideoTarget, setDeleteVideoTarget] = useState<{ id: string; label: string; caption: string } | null>(null)
const running = videos.some((v) => v.status === "queued" || v.status === "in_progress")
const completed = videos.filter((v) => v.status === "completed" && v.url)
const failed = videos.some((v) => v.status === "failed")
const status: NodeStatus = running ? "running" : completed.length > 0 ? "done" : failed ? "failed" : "pending"
const aspect = d.job && (d.job.width ?? 0) > 0 && (d.job.height ?? 0) > 0
? `${d.job.width}/${d.job.height}`
: "9/16"
const modelLabel = (model: string) => {
const m = model.toLowerCase()
if (m.includes("kling")) return "Kling"
if (m.includes("veo")) return "Veo 3"
if (m.includes("seedance")) return "Seedance"
return model || "Video"
}
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{videos.length > 0 && (
<FloatingThumbnailStrip label="生成视频缩略图横向滑动条">
{videos.map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
const ready = v.status === "completed" && !!videoSrc
const progress = Math.max(0, Math.min(100, v.progress || 0))
return (
<div
key={v.id}
className={`group relative shrink-0 rounded-md border overflow-visible transition shadow-lg hover:-translate-y-0.5 bg-black ${
ready ? "border-emerald-300/60" : v.status === "failed" ? "border-rose-300/70" : "border-violet-300/55"
}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreviewVideo({ id: v.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewVideo(null)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
title={`分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · 点击复制 prompt`}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md bg-black"
>
{ready ? (
<video
src={videoSrc}
poster={posterSrc}
muted
loop
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-cover"
/>
) : posterSrc ? (
<img src={posterSrc} alt="" className="absolute inset-0 h-full w-full object-cover opacity-75" />
) : (
<div className="absolute inset-0 bg-violet-950/50" />
)}
{!ready && (
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
{v.status === "failed" ? (
<X className="h-4 w-4 text-rose-200" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-white/85" />
)}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-1.5 py-1 text-left">
<div className="truncate text-[9.5px] font-semibold text-white"> {i + 1}</div>
<div className="truncate text-[8.5px] font-mono text-white/60">
{ready ? `${v.duration.toFixed(0)}s` : v.status === "failed" ? "failed" : `${progress}%`}
</div>
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void navigator.clipboard?.writeText(v.prompt).catch(() => {})
toast.success("已复制视频 prompt")
}}
className="absolute left-1.5 top-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"
title="复制视频 prompt"
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteVideoTarget({
id: v.id,
label: `视频 ${i + 1}`,
caption: `分镜 ${v.frame_idx + 1} · ${modelLabel(v.model)} · ${v.status}`,
})
}}
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
title="删除这个视频任务"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)})}
</FloatingThumbnailStrip>
)}
{deleteVideoTarget && (
<DeleteConfirmDialog
title="删除这个视频任务?"
description={`会删除${deleteVideoTarget.label}的生成任务和本地视频文件。${deleteVideoTarget.caption}`}
confirmLabel="删除视频任务"
onCancel={() => setDeleteVideoTarget(null)}
onConfirm={() => {
const id = deleteVideoTarget.id
setDeleteVideoTarget(null)
d.onDeleteVideo?.(id)
}}
/>
)}
{(() => {
if (!hoverPreviewVideo) return null
const item = videos.find((v) => v.id === hoverPreviewVideo.id)
if (!item) return null
const videoSrc = apiAssetUrl(item.url)
const posterSrc = apiAssetUrl(item.poster_url)
const ready = item.status === "completed" && !!videoSrc
if (!ready && !posterSrc) return null
return (
<HoverPreview
videoSrc={ready ? videoSrc : undefined}
imgSrc={!ready ? posterSrc : undefined}
poster={posterSrc}
aspect={aspect}
label={`分镜 ${item.frame_idx + 1}`}
caption={`${modelLabel(item.model)} · ${item.status}`}
borderClass={ready ? "border-emerald-300/60" : "border-rose-300/60"}
visible
anchorX={hoverPreviewVideo.x}
anchorY={hoverPreviewVideo.y}
/>
)
})()}
<NodeShell
type="ai" status={status}
icon={<Film className="h-4 w-4" />}
title="生成视频 · Video Gen"
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
selected={selected}
pinned={d.pinnedNodes?.has("videogen")}
onTogglePin={() => d.onToggleNodePin?.("videogen")}
>
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
{["Seedance", "Kling", "Veo 3"].map((m) => (
<div key={m} className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-center text-[var(--text-faint)]">
<span className="text-[var(--text-strong)] text-[11px]">{m}</span>
</div>
))}
</div>
{videos.length > 0 && (
<div className="mt-2 rounded-md border border-rose-300/25 bg-rose-500/10 px-2 py-1.5 text-[10.5px] text-[var(--text-soft)]">
{videos.length} · {completed.length} {running ? " · 生成中" : ""}
</div>
)}
</NodeShell>
</div>
)
}
/* ============================================================
10. ComposeNode (placeholder)
============================================================ */
export function ComposeNode({ data, selected }: any) {
const d: NodeData = data
return (
<NodeShell
type="output" status="pending"
icon={<FileVideo className="h-4 w-4" />}
title="合成成品 · Compose"
subtitle="STEP 8 · ffmpeg + 字幕"
selected={selected}
hasSource={false}
pinned={d.pinnedNodes?.has("compose")}
onTogglePin={() => d.onToggleNodePin?.("compose")}
>
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
+ / TTS<br /> mp4
</div>
</NodeShell>
)
}