Files
20260512-skg-tk/web/components/nodes/index.tsx
2026-05-14 03:04:09 +08:00

1966 lines
83 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, Pin, Maximize2,
Copy, Trash2,
} 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,
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
export interface NodeData {
job: Job | null // 当前 active job
jobs: Job[] // 所有 job 列表
activeJobId: string | null
submitting: boolean
analyzing: boolean
selectedFrames: Set<number>
expandedFrame: number | null
framePanelScale?: number
framePanelPinned?: boolean
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: () => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
onFramePanelScaleChange?: (scale: number) => void
onFramePanelPinnedChange?: (pinned: boolean) => void
onCloseExpandedFrame: () => void
onAddManualFrame: (t: number) => void
onOpenVideoLightbox: () => void
onSwitchJob: (id: string) => void
onJobUpdate: (j: Job) => void
onDeleteJob?: (id: string) => void
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
onDeleteFrame?: (idx: number) => 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 // 展开顶部分镜编排内嵌面板
onCopyImage?: (ref: ImageRef) => 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 = 176
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,
}: {
children: ReactNode
label?: string
}) {
const scrollRef = useRef<HTMLDivElement>(null)
return (
<div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
<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,
)
}
/* ============================================================
1. InputNode — TK 链接 / 上传
============================================================ */
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
const d: NodeData = data
const [url, setUrl] = useState("")
const [videoT, setVideoT] = useState(0)
const [addingFrame, setAddingFrame] = useState(false)
const [videoExpanded, setVideoExpanded] = useState(false)
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 videoRef = useRef<HTMLVideoElement>(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 isDone = job?.status === "transcribed"
const hasFrames = (job?.frames.length ?? 0) > 0
const inputLocked = isDownloading || d.submitting
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{!videoExpanded && 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"
return (
<div
key={j.id}
className={`group relative shrink-0 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={{ 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()
// 单击:钉住 / 取消钉住大预览 + 切换 active若需要
const anchor = canvasThumbnailAnchor(rootRef.current, e.currentTarget)
setPinnedPreviewJob((prev) => (prev?.id === j.id ? null : { id: j.id, ...anchor }))
if (!isActive && ready) d.onSwitchJob(j.id)
}}
onDoubleClick={(e) => {
e.stopPropagation()
if (ready) setVideoExpanded(true)
}}
title={ready ? `${j.width}×${j.height} · ${j.duration.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.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>
)
})}
</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 || videoExpanded) 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)}
/>
)
})()}
{/* 展开态 — 稍微放大360 宽),含 controls + 加帧按钮,不全屏 */}
{hasVideo && job && videoExpanded && (
<div
className="absolute left-0 right-0 flex justify-center"
style={{ bottom: "calc(100% + 12px)" }}
>
<div
onClick={(e) => e.stopPropagation()}
className="relative rounded-xl overflow-hidden border border-white/25 shadow-2xl bg-black"
style={{ width: 360, animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
>
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
autoPlay
playsInline
preload="auto"
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
className="block w-full bg-black"
style={{ aspectRatio: `${job.width}/${job.height}`, maxHeight: "60vh" }}
/>
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md gap-2">
<button
type="button"
disabled={addingFrame}
onClick={async (e) => {
e.stopPropagation()
const t = videoRef.current?.currentTime ?? videoT
setAddingFrame(true)
try { await d.onAddManualFrame(t) } finally { setAddingFrame(false) }
}}
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 inline-flex items-center justify-center gap-1.5 font-medium"
>
{addingFrame ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
+ {videoT.toFixed(1)}s
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setVideoExpanded(false) }}
className="px-2.5 py-1.5 text-[11px] rounded-md bg-white/10 hover:bg-white/20 text-white"
>
</button>
</div>
</div>
</div>
)}
<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>
<button
type="button"
disabled={isAnalyzing || d.analyzing}
onClick={d.onAnalyze}
className={`mt-2 w-full text-[14px] py-3 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-semibold shadow-lg shadow-violet-500/30 ${
!isAnalyzing && !d.analyzing && !isDone && !hasFrames ? "animate-[pulse_2s_ease-in-out_infinite] ring-2 ring-violet-400/40 ring-offset-2 ring-offset-transparent" : ""
}`}
>
{(isAnalyzing || d.analyzing) ? (
<><Loader2 className="h-4 w-4 animate-spin" /> </>
) : isDone || hasFrames ? (
"重新解析"
) : (
<> </>
)}
</button>
</>
)}
</NodeShell>
</div>
)
}
/* ============================================================
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 }
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 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 elementCrops = collectElementCrops(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 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 || elementCrops.length > 0 || completedVideos.length > 0
? "done"
: 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 }
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,
frameIdx: f.index,
src: effectiveFrameUrl(jobId, f),
label: `分镜 ${f.index + 1}`,
caption: `${f.timestamp.toFixed(2)}s`,
borderClass: "border-orange-300/50",
})) : []),
...elementCrops.map((p) => ({
id: `cutout:${p.frameIdx}:${p.elementId}:${p.cid}`,
kind: "cutout" as const,
frameIdx: p.frameIdx,
elementId: p.elementId,
cutoutId: p.cid,
src: p.src,
label: p.name,
caption: `分镜 ${p.frameIdx + 1}`,
borderClass: "border-violet-300/60",
})),
...videos.map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
return {
id: `video:${v.id}`,
kind: "video" as const,
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",
}
}),
]
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 === "cutout" ? "bg-white" : "bg-black"}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspect }}
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreview(null)}
>
<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 === "cutout") {
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenStoryboard?.(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 === "cutout" ? " / 进入分镜编排" : " / 复制 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 === "cutout" ? "object-contain" : "object-cover"}`} />
)}
{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" ? "元素" : "视频"}
</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 === "cutout" && d.onCopyImage && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onCopyImage?.({
kind: "cutout",
frame_idx: p.frameIdx,
element_id: p.elementId,
cutout_id: p.cutoutId,
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 === "cutout" && d.onDeleteCutout && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cutoutId)
}}
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={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="grid grid-cols-3 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="打开镜头处理面板"
>
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{frames.length}</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="进入分镜编排"
>
<div className="text-[var(--text-strong)] text-[12px] font-semibold">{elementCrops.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} · {cutoutCount} · {d.selectedFrames.size}/{frames.length} · {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 ? `${d.selectedFrames.size}/${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)
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
return (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{frames.length}</span>
{" · "}
<span className={cleanedCount > 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} </span>
{" · "}
<span className={cutoutCount > 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} </span>
<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 [pinRect, setPinRect] = useState<{ left: number; top: number }>({ left: 24, top: 72 })
const scale = d.framePanelScale ?? 1
const pinned = d.framePanelPinned ?? false
const getStoryboardDockTop = () => {
if (typeof window === "undefined") return 64
const dock = document.querySelector<HTMLElement>('[data-storyboard-dock="true"]')
const bar = document.querySelector<HTMLElement>('[data-storyboard-bar="true"]')
const bottom = (dock ?? bar)?.getBoundingClientRect().bottom ?? 52
return Math.max(56, Math.min(window.innerHeight - 120, bottom + 10))
}
useEffect(() => {
if (!pinned || typeof window === "undefined") return
const syncDock = () => {
setPinRect({ left: 16, top: getStoryboardDockTop() })
}
syncDock()
const bar = document.querySelector<HTMLElement>('[data-storyboard-dock="true"]')
?? document.querySelector<HTMLElement>('[data-storyboard-bar="true"]')
let observer: ResizeObserver | null = null
if (bar && "ResizeObserver" in window) {
observer = new ResizeObserver(syncDock)
observer.observe(bar)
}
window.addEventListener("resize", syncDock)
return () => {
observer?.disconnect()
window.removeEventListener("resize", syncDock)
}
}, [pinned])
if (!d.job || d.expandedFrame === null) return null
const active = d.job.frames.find((f) => f.index === d.expandedFrame)
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 togglePinned = () => {
if (!pinned) {
const zoom = getZoom()
setScale(scale * zoom)
setPinRect({ left: 16, top: getStoryboardDockTop() })
}
d.onFramePanelPinnedChange?.(!pinned)
}
const startResize = (e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const startScale = scale
const zoom = pinned ? 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 rounded-2xl border border-white/15 bg-black/70 shadow-2xl overflow-hidden"
style={{ width: panelWidth, height: panelHeight, 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 bg-gradient-to-r from-orange-500 to-red-500 px-3 text-white ${pinned ? "cursor-default" : "cursor-move"}`}>
<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>
<div className="flex items-center gap-1.5">
<span className="mr-1 text-[10px] text-white/60">
{pinned ? "已钉住左侧 · 不跟画布" : "拖动标题栏移动 · 可钉住"}
</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); togglePinned() }}
className={`nodrag h-5 w-5 rounded inline-flex items-center justify-center transition ${
pinned
? "bg-white text-orange-600 shadow"
: "bg-white/10 text-white/85 hover:bg-white/20 hover:text-white"
}`}
title={pinned ? "取消钉住,回到画布节点" : "钉住到左侧,脱离画布缩放"}
>
<Pin className="h-3 w-3" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
title="缩小面板"
>
-
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setScale(1) }}
className="nodrag h-5 min-w-9 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-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
title="放大面板"
>
+
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
className="nodrag h-5 w-5 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 w-3" />
</button>
</div>
</div>
<div className="nodrag nowheel" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
<FrameLightbox
embedded
jobId={d.job.id}
frames={d.job.frames}
activeIndex={d.expandedFrame}
selected={d.selectedFrames}
onClose={d.onCloseExpandedFrame}
onChange={d.onExpandFrame}
onToggleSelect={d.onToggleFrame}
onJobUpdate={d.onJobUpdate}
onCopyImage={d.onCopyImage}
/>
</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-orange-400/35 hover:text-white inline-flex items-center justify-center"
title="拖动右下角缩放面板"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
)
if (pinned && typeof document !== "undefined") {
return createPortal(
<div
className="fixed z-[240]"
style={{ left: pinRect.left, top: pinRect.top }}
>
{panel}
</div>,
document.body,
)
}
return panel
}
/* ============================================================
5. ASRNode — Gemini 转录
============================================================ */
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)]">
Gemini 2.5 ·
</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 (placeholder)
============================================================ */
export function RewriteNode({ data, selected }: any) {
const d: NodeData = data
return (
<NodeShell
type="ai" status="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")}
>
<textarea
placeholder="粘贴 SKG 产品信息 / 关键卖点(可作为视频脚本和镜头动作参考)"
rows={3}
disabled
className="w-full text-[11.5px] px-2.5 py-2 rounded-md bg-white/30 dark:bg-white/[0.03] border border-dashed border-black/15 dark:border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
/>
<div className="mt-1.5 text-[10px] text-[var(--text-faint)]"></div>
</NodeShell>
)
}
/* ============================================================
5b. AudioNode — 合并 ASR + 翻译 + 改写为一个"音频处理"节点(占位卡片,无填充)
============================================================ */
export function AudioNode({ data, selected }: any) {
const d: NodeData = data
const job = d.job
const transcript = job?.transcript ?? []
const hasASR = transcript.length > 0
const status: NodeStatus = !job
? "pending"
: job.status === "transcribing"
? "running"
: 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="text-[11px] text-[var(--text-soft)] leading-snug">
ASR SKG <br />
<span className="text-[var(--text-faint)] font-mono">Gemini 2.5 Flash</span>
</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>
)
}