767 lines
33 KiB
TypeScript
767 lines
33 KiB
TypeScript
"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>
|
||
)
|
||
}
|