Files
20260512-skg-tk/web/components/nodes/index.tsx
2026-05-13 10:44:25 +08:00

770 lines
34 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 { useRef, useState } from "react"
import { type NodeProps } from "@xyflow/react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X,
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { type Job, frameUrl, videoUrl, generatedImageUrl } 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 // 删单张生成图
}
/* ---- 状态映射工具 ---- */
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
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) }}
title={`${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 点击放大`}
className="absolute inset-0 w-full h-full"
>
<img
src={frameUrl(jobId, f.index)}
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>
{/* Hover 静态大图预览(关键帧是给下游生图垫图的素材,不需要视频) */}
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
style={{
bottom: "calc(100% + 10px)",
left: "50%",
transform: "translateX(-50%)",
transformOrigin: "bottom center",
}}
>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={frameUrl(jobId, f.index)}
alt={`preview ${f.index}`}
className="block"
style={{
width: KEYFRAME_WIDTH * 2,
maxWidth: "min(720px, 80vw)",
height: "auto",
maxHeight: "82vh",
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"> {f.index + 1}</span>
<span className="text-white/60 text-[11px] font-mono">{f.timestamp.toFixed(2)}s · </span>
</div>
</div>
</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>
</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 = 320
export function ImageGenNode({ data, selected }: any) {
const d: NodeData = data
const job = d?.job
const selectedIdxs = Array.from(d?.selectedFrames ?? []).sort((a: number, b: number) => a - b) as number[]
// 每个 selected frame 取一张代表图:优先 selected gen否则最新一张
const previews = selectedIdxs
.map((idx) => {
const f = job?.frames.find((x) => x.index === idx)
if (!f || !f.generated_images || f.generated_images.length === 0) return null
const sel = f.generated_images.find((g) => g.selected)
const pick = sel ?? f.generated_images[f.generated_images.length - 1]
return { frameIdx: idx, gen: pick, hasSelected: !!sel, total: f.generated_images.length }
})
.filter((p): p is { frameIdx: number; gen: NonNullable<ReturnType<typeof Object>>; hasSelected: boolean; total: number } => p !== null)
const totalGens = job?.frames.reduce((sum, f) => sum + (f.generated_images?.length ?? 0), 0) ?? 0
const selectedCount = previews.filter((p) => p.hasSelected).length
const status: NodeStatus = !job ? "pending" : totalGens > 0 ? "done" : "pending"
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return (
<div className="relative" style={{ width: IMAGEGEN_WIDTH }}>
{/* 节点上方:每帧 1 张缩略图 */}
{previews.length > 0 && job && (
<div
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
style={{ bottom: "calc(100% + 12px)" }}
>
{previews.map((p) => (
<div
key={p.frameIdx}
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
p.hasSelected
? "border-emerald-400 ring-2 ring-emerald-400/60"
: "border-pink-300/40 dark:border-pink-300/30"
}`}
style={{ aspectRatio: aspect }}
>
<button
onClick={(e) => { e.stopPropagation(); d.onOpenPanel?.("imagegen") }}
title={`分镜 ${p.frameIdx + 1} · ${p.total}${p.hasSelected ? " · 已选用" : ""} · 打开「生图」面板`}
className="absolute inset-0 w-full h-full"
>
<img
src={generatedImageUrl(job.id, p.frameIdx, p.gen.id)}
alt={`gen ${p.gen.id}`}
className="absolute inset-0 w-full h-full object-cover rounded-md"
/>
{p.total > 1 && (
<div className="absolute top-0 left-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-tl-md rounded-br">
{p.total}
</div>
)}
{p.hasSelected && (
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
)}
{/* Hover 大图预览 */}
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
style={{
bottom: "calc(100% + 10px)",
left: "50%",
transform: "translateX(-50%)",
transformOrigin: "bottom center",
}}
>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={generatedImageUrl(job.id, p.frameIdx, p.gen.id)}
alt={`preview ${p.gen.id}`}
className="block"
style={{
width: IMAGEGEN_WIDTH * 2,
maxWidth: "min(720px, 80vw)",
height: "auto",
maxHeight: "82vh",
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.frameIdx + 1} · </span>
<span className="text-white/60 text-[11px] font-mono">{p.gen.mode === "edit" ? "i2i" : "text"} · {p.total} </span>
</div>
</div>
</div>
</button>
{/* 删除按钮hover 时右上角浮出 */}
{d.onDeleteGenerated && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`删除分镜 ${p.frameIdx + 1} 的这张生成图?`)) {
d.onDeleteGenerated?.(p.frameIdx, p.gen.id)
}
}}
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="ai" status={status}
icon={<Sparkles className="h-4 w-4" />}
title="生图 · Image Gen"
subtitle={`STEP 6 · nano-banana ${totalGens > 0 ? `· ${totalGens}` : ""}`}
width={IMAGEGEN_WIDTH}
selected={selected}
>
{totalGens > 0 ? (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{totalGens}</span> · {selectedCount}/{previews.length}
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
· sidebar
</span>
</div>
) : (
<div className="grid grid-cols-2 gap-1.5">
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
nano-banana-pro<br /><span className="text-[var(--text-strong)] text-[11px]">Gemini 3 Image</span>
</div>
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
GPT Image<br /><span className="text-[var(--text-strong)] text-[11px]">OpenAI</span>
</div>
</div>
)}
</NodeShell>
</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>
)
}