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

1143 lines
50 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 } 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
onOpenPanel?: (key: string) => void // 控制 sidebar 哪个 drawer 展开
onDeleteFrame?: (idx: number) => void // 删整张关键帧
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
onDeleteVideo?: (videoId: string) => void // 删 Video Gen 任务
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
onCopyImage?: (ref: ImageRef) => 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"
}
/* ============================================================
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 fileRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const job = d.job
// 是否已下载 → 显示视频 + 解析按钮
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 className="relative" style={{ width: "100%", height: "100%" }}>
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{!videoExpanded && d.jobs.length > 0 && (
<div
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{/* + 再上传一个(放在最前面) */}
<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: 44, height: 80 }}
>
<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: 80, aspectRatio: aspectStr }}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (isActive && ready) setVideoExpanded(true)
else d.onSwitchJob(j.id)
}}
title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · ${isActive ? "点击展开" : "点击切换"}` : "下载中…"}
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>
{ready && (
<HoverPreview
videoSrc={videoUrl(j.id)}
aspect={aspectStr}
label={`${j.width}×${j.height}`}
caption={`${j.duration.toFixed(1)}s`}
borderClass="border-violet-300/60"
/>
)}
</div>
)
})}
</div>
)}
{/* 展开态 — 稍微放大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}
>
{/* 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
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"
return (
<div className="relative" style={{ width: "100%", height: "100%" }}>
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
{frames.length > 0 && jobId && (
<div
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{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: 80,
aspectRatio: d.job && d.job.height > 0
? `${d.job.width}/${d.job.height}`
: "16/9",
}}
>
<button
onClick={(e) => {
e.stopPropagation()
;(d.onOpenFramePanel ?? d.onExpandFrame)(f.index)
}}
title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · hover 看大图 · 点击打开 / 找回详情面板`}
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 left-1 h-5 w-5 rounded-full bg-violet-500/90 backdrop-blur text-white shadow-md hover:bg-violet-400 hover:scale-110 inline-flex items-center justify-center transition z-[70] text-[10px] leading-none"
>
📋
</button>
)}
{/* 删除按钮hover 时右上角浮出 */}
{d.onDeleteFrame && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`删除分镜 ${f.index + 1}${f.timestamp.toFixed(1)}s相关清洗 / 抠图 / 生成图都会一并清除。`)) {
d.onDeleteFrame?.(f.index)
}
}}
title="删除该关键帧"
className="absolute top-1 right-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70]"
>
<X className="h-3 w-3" />
</button>
)}
{/* hover 预览 — absolute 浮在缩略图上方,跟着 ReactFlow 画布缩放平移 */}
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
style={{
bottom: "calc(100% + 10px)",
left: "50%",
transform: "translateX(-50%)",
transformOrigin: "bottom center",
}}
>
<div className="rounded-lg overflow-hidden border-2 border-orange-300/50 bg-black shadow-2xl" style={{ width: 280 }}>
<div style={{ aspectRatio: aspectStr }}>
<img src={effectiveFrameUrl(jobId, f)} alt="" className="w-full h-full object-cover" />
</div>
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
<span> {f.index + 1}</span>
<span className="text-white/60 font-mono">{f.timestamp.toFixed(2)}s</span>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
<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}
>
{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}
>
<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}
>
<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({ selected }: any) {
return (
<NodeShell
type="ai" status="pending"
icon={<FileEdit className="h-4 w-4" />}
title="产品文案 · Rewrite"
subtitle="STEP 5 · 接 SKG 卖点"
selected={selected}
>
<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>
)
}
/* ============================================================
6. StoryboardNode — 元素改造 + 分镜编排入口
============================================================ */
const IMAGEGEN_WIDTH = 360
export function StoryboardNode({ data, selected }: any) {
const d: NodeData = data
const job = d?.job
// 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材)
type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string; frameSrc: 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,
frameSrc: effectiveFrameUrl(job.id, f),
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 className="relative" style={{ width: "100%", height: "100%" }}>
{/* 节点上方:所有元素 crop 图(编排输入素材)· 跟 keyframe 节点样式一致 */}
{elementCrops.length > 0 && job && (
<div
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{elementCrops.map((p) => {
const key = `${p.frameIdx}_${p.elementId}`
return (
<div
key={key}
className="group relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-white"
style={{ aspectRatio: aspect }}
>
<button
onClick={(e) => {
e.stopPropagation()
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 left-1 h-5 w-5 rounded-full bg-violet-500/90 backdrop-blur text-white shadow-md hover:bg-violet-400 hover:scale-110 inline-flex items-center justify-center transition z-[70] text-[10px] leading-none"
>
📋
</button>
)}
{/* hover 预览 — 和关键帧节点保持一致:只看来源原帧,不额外展示元素面板 */}
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
style={{
bottom: "calc(100% + 10px)",
left: "50%",
transform: "translateX(-50%)",
transformOrigin: "bottom center",
}}
>
<div className="rounded-lg overflow-hidden border-2 border-violet-300/60 bg-black shadow-2xl" style={{ width: 280 }}>
<div style={{ aspectRatio: aspect }}>
<img src={p.frameSrc} alt="" className="w-full h-full object-cover" />
</div>
<div className="px-2 py-1 bg-black/80 text-white text-[10.5px] flex items-center justify-between">
<span> {p.frameIdx + 1}</span>
<span className="shrink-0 font-mono text-white/55">{p.timestamp.toFixed(2)}s</span>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
<NodeShell
type="ai" status={status}
icon={<LayoutGrid className="h-4 w-4" />}
title="元素改造 · Storyboard"
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
selected={selected}
>
<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 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"
}
const readableVideoError = (error?: string) => {
const e = error || "生成失败"
if (e.includes("/videos") && e.includes("404")) {
return "模型已提交,但当前 /videos 入口返回 404需要配置实际视频生成入口"
}
return e
}
return (
<div className="relative" style={{ width: "100%", height: "100%" }}>
{videos.length > 0 && (
<div
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{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: 80, aspectRatio: aspect }}
>
<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 top-1 z-[70] h-5 w-5 rounded-full bg-violet-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-violet-400 hover:scale-110"
title="复制视频 prompt"
>
<Copy className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onDeleteVideo?.(v.id)
}}
className="absolute right-1 top-1 z-[70] h-5 w-5 rounded-full bg-rose-500/90 text-white shadow-md backdrop-blur inline-flex items-center justify-center transition hover:bg-rose-400 hover:scale-110"
title="删除这个视频任务"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-150 z-[60]"
style={{
bottom: "calc(100% + 10px)",
left: "50%",
transform: "translateX(-50%)",
transformOrigin: "bottom center",
}}
>
<div className="rounded-lg overflow-hidden border-2 border-rose-300/60 bg-black shadow-2xl" style={{ width: 300 }}>
<div style={{ aspectRatio: aspect }}>
{ready ? (
<video src={videoSrc} poster={posterSrc} muted loop autoPlay playsInline controls className="h-full w-full object-cover" />
) : posterSrc ? (
<img src={posterSrc} alt="" className="w-full h-full object-cover" />
) : (
<div className="h-full w-full bg-violet-950/60" />
)}
</div>
<div className="space-y-1 bg-black/90 px-2 py-1.5 text-white">
<div className="flex items-center justify-between gap-2 text-[10.5px]">
<span className="truncate"> {v.frame_idx + 1}</span>
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
</div>
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
{v.status === "failed" ? readableVideoError(v.error) : v.prompt}
</div>
</div>
</div>
</div>
</div>
)})}
</div>
)}
<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}
>
<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({ selected }: any) {
return (
<NodeShell
type="output" status="pending"
icon={<FileVideo className="h-4 w-4" />}
title="合成成品 · Compose"
subtitle="STEP 8 · ffmpeg + 字幕"
selected={selected}
hasSource={false}
>
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
+ / TTS<br /> mp4
</div>
</NodeShell>
)
}