Files
20260512-skg-tk/web/components/nodes/index.tsx
2026-05-13 14:27:23 +08:00

767 lines
33 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 } from "@xyflow/react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X, LayoutGrid,
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { type Job, frameUrl, effectiveFrameUrl, videoUrl, generatedImageUrl, cutoutUrl } from "@/lib/api"
export interface NodeData {
job: Job | null // 当前 active job
jobs: Job[] // 所有 job 列表
activeJobId: string | null
submitting: boolean
analyzing: boolean
selectedFrames: Set<number>
expandedFrame: number | null
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: () => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => 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 // 删单张生成图
onOpenStoryboard?: (frameIdx: number) => 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", "frames_extracted", "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: 320 }}>
{/* 多视频缩略图浮条 — 每个 job 一张 + 末尾「+」按钮再上传 */}
{!videoExpanded && d.jobs.length > 0 && (
<div className="absolute left-0 right-0 flex justify-center items-end gap-1.5 flex-wrap" style={{ bottom: "calc(100% + 12px)" }}>
{d.jobs.map((j) => {
const isActive = j.id === d.activeJobId
const ready = !!j.video_url
return (
<button
key={j.id}
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={`group relative rounded-md overflow-hidden border shadow-lg transition hover:-translate-y-0.5 ${
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`}
style={{ width: 80, aspectRatio: ready ? `${j.width}/${j.height}` : "9/16" }}
>
{ready ? (
<video
src={videoUrl(j.id)}
muted
loop
playsInline
preload="metadata"
className="block w-full h-full object-cover bg-black"
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement
v.pause()
v.currentTime = 0
}}
/>
) : (
<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>
)
})}
{/* + 再加一个 */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
title="再上传一个视频"
className="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: 36, height: 64 }}
>
<Plus className="h-4 w-4" />
</button>
</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"}
width={320}
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 [hover, setHover] = useState<{ idx: number; rect: DOMRect } | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return (
<div className="relative" style={{ width: KEYFRAME_WIDTH }}>
{/* 缩略图浮条(节点上方,最多 5 个一行,多行向上扩展) */}
{frames.length > 0 && jobId && (
<div
className="absolute left-0 right-0 grid grid-cols-5 gap-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 rounded-md border 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={{
aspectRatio: d.job && d.job.height > 0
? `${d.job.width}/${d.job.height}`
: "16/9",
}}
>
<button
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
onMouseEnter={(e) => setHover({ idx: f.index, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })}
onMouseLeave={() => setHover(null)}
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) => e.cutout_id))) && (
<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) => e.cutout_id).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>
{/* 删除按钮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>
)}
</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} 选用` : "等待抽取"}`}
width={KEYFRAME_WIDTH}
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) => e.cutout_id).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)]">
/
</span>
</div>
)
})() : (
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
5
</div>
)}
</NodeShell>
{/* Portal hover 大预览 — 浮在缩略图上方(不挡其他界面) */}
{mounted && hover && jobId && (() => {
const hf = frames.find((x) => x.index === hover.idx)
if (!hf) return null
// 大图最大尺寸(按视频比例算)
const vidAspect = d.job && d.job.height > 0 ? d.job.height / d.job.width : 16 / 9
const maxH = Math.min(window.innerHeight * 0.7, hover.rect.top - 16)
const maxW = Math.min(window.innerWidth * 0.6, 600)
let h = maxH, w = h / vidAspect
if (w > maxW) { w = maxW; h = w * vidAspect }
// 水平居中到缩略图clamp 在视口内
const centerX = hover.rect.left + hover.rect.width / 2
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
return createPortal(
<div
className="fixed z-[120] pointer-events-none"
style={{
top: hover.rect.top - h - 12,
left,
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 30px 80px -10px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={effectiveFrameUrl(jobId, hf)}
alt={`preview ${hf.index}`}
className="block"
style={{ width: w, height: h, objectFit: "contain" }}
/>
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
<span className="text-white text-[12.5px] font-medium"> {hf.index + 1} · {hf.timestamp.toFixed(2)}s</span>
<span className="text-white/60 text-[11px] font-mono"></span>
</div>
</div>
</div>,
document.body,
)
})()}
</div>
)
}
/* ============================================================
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 5 · Gemini"
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 6 · 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 7 · 接产品信息"
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>
)
}
/* ============================================================
8. ImageGenNode — 显示 selected frames 的代表生成图
============================================================ */
const IMAGEGEN_WIDTH = 360
export function ImageGenNode({ data, selected }: any) {
const d: NodeData = data
const job = d?.job
const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// 上方浮条 = 所有 frame 的 elements crop"分镜头编排"的输入素材)
type ElPreview = { frameIdx: number; elementId: string; name: string }
const elementCrops: ElPreview[] = job
? job.frames.flatMap((f) =>
(f.elements ?? [])
.filter((e) => !!e.cutout_id)
.map((e) => ({ frameIdx: f.index, elementId: e.id, name: e.name_zh })),
)
: []
const totalElements = elementCrops.length
const status: NodeStatus = !job ? "pending" : totalElements > 0 ? "done" : "pending"
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return (
<div className="relative" style={{ width: IMAGEGEN_WIDTH }}>
{/* 节点上方:所有元素 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="relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-black/40 overflow-hidden"
style={{ aspectRatio: aspect }}
>
<button
onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }}
onMouseEnter={(e) => setHover({ key, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })}
onMouseLeave={() => setHover(null)}
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · hover 看大图 · 点击进入分镜头编排`}
className="absolute inset-0 w-full h-full"
>
<img
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
alt={p.name}
className="absolute inset-0 w-full h-full object-contain"
/>
</button>
</div>
)
})}
</div>
)}
<NodeShell
type="ai" status={status}
icon={<LayoutGrid className="h-4 w-4" />}
title="分镜头编排 · Storyboard"
subtitle={`STEP 6 · 接元素 + 场景${totalElements > 0 ? ` · ${totalElements} 个元素` : ""}`}
width={IMAGEGEN_WIDTH}
selected={selected}
>
{totalElements > 0 ? (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{totalElements}</span> +
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
· / / Phase 2
</span>
</div>
) : (
<div className="text-[11.5px] text-[var(--text-faint)] leading-relaxed">
<span className="text-[var(--text-strong)]"></span>
<br />
<span className="text-[10.5px]"> </span>
</div>
)}
</NodeShell>
{/* Portal hover 大预览 — 浮在缩略图上方 */}
{mounted && hover && job && (() => {
const [fi, ei] = hover.key.split("_")
const frameIdx = parseInt(fi, 10)
const p = elementCrops.find((x) => x.frameIdx === frameIdx && x.elementId === ei)
if (!p) return null
const vidAspect = job.height > 0 ? job.height / job.width : 16 / 9
const maxH = Math.min(window.innerHeight * 0.7, hover.rect.top - 16)
const maxW = Math.min(window.innerWidth * 0.6, 600)
let h = maxH, w = h / vidAspect
if (w > maxW) { w = maxW; h = w * vidAspect }
const centerX = hover.rect.left + hover.rect.width / 2
const left = Math.max(12, Math.min(window.innerWidth - w - 12, centerX - w / 2))
return createPortal(
<div
className="fixed z-[120] pointer-events-none"
style={{
top: hover.rect.top - h - 12,
left,
animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 30px 80px -10px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={cutoutUrl(job.id, p.frameIdx, p.elementId)}
alt={`preview ${p.elementId}`}
className="block"
style={{ width: w, height: h, objectFit: "contain" }}
/>
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
<span className="text-white text-[12.5px] font-medium">{p.name}</span>
<span className="text-white/60 text-[11px] font-mono"> {p.frameIdx + 1}</span>
</div>
</div>
</div>,
document.body,
)
})()}
</div>
)
}
/* ============================================================
9. VideoGenNode (placeholder)
============================================================ */
export function VideoGenNode({ selected }: any) {
return (
<NodeShell
type="ai" status="pending"
icon={<Film className="h-4 w-4" />}
title="生视频 · Video Gen"
subtitle="STEP 9 · 多家可切"
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>
</NodeShell>
)
}
/* ============================================================
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 10 · ffmpeg + TTS"
selected={selected}
hasSource={false}
>
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
+ / TTS<br /> mp4
</div>
</NodeShell>
)
}