513 lines
21 KiB
TypeScript
513 lines
21 KiB
TypeScript
"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,
|
||
} from "lucide-react"
|
||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||
import { type Job, frameUrl, videoUrl } from "@/lib/api"
|
||
|
||
export interface NodeData {
|
||
job: Job | null
|
||
submitting: boolean
|
||
analyzing: boolean
|
||
selectedFrames: Set<number>
|
||
onSubmitUrl: (url: string) => void
|
||
onUploadFile: (file: File) => void
|
||
onAnalyze: () => void
|
||
onToggleFrame: (idx: number) => void
|
||
onExpandFrame: (idx: number) => void
|
||
onAddManualFrame: (t: 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 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 (
|
||
<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 + 上传入口 */}
|
||
{!hasVideo && (
|
||
<>
|
||
<input
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
placeholder="粘贴 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())}
|
||
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 ? "下载中…" : "提交链接"}
|
||
</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" /> 上传
|
||
</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>
|
||
</>
|
||
)}
|
||
|
||
{/* 已下载:仅元数据 + 解析按钮(视频播放器和加帧 controls 在左侧看板 Keyframe section) */}
|
||
{hasVideo && job && (
|
||
<>
|
||
<div className="rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2.5">
|
||
<div className="flex items-center justify-between text-[10.5px] font-mono text-[var(--text-faint)] mb-1">
|
||
<span>视频已下载</span>
|
||
<span>{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
|
||
</div>
|
||
<div className="text-[var(--text-strong)] text-[13px] font-mono">
|
||
{job.width}×{job.height} · {job.duration.toFixed(1)}s
|
||
</div>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
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 job = d.job
|
||
const hasVideo = !!job?.video_url
|
||
const videoRef = useRef<HTMLVideoElement>(null)
|
||
const [videoT, setVideoT] = useState(0)
|
||
const [addingFrame, setAddingFrame] = useState(false)
|
||
|
||
return (
|
||
<div className="relative" style={{ width: KEYFRAME_WIDTH }}>
|
||
{/* 缩略图浮条(节点上方) */}
|
||
{frames.length > 0 && jobId && (
|
||
<div
|
||
className="absolute left-0 right-0 -top-[68px] flex items-end justify-center"
|
||
style={{ gap: THUMB_GAP }}
|
||
>
|
||
{frames.map((f) => {
|
||
const isSel = d.selectedFrames.has(f.index)
|
||
return (
|
||
<button
|
||
key={f.index}
|
||
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
|
||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 点击放大`}
|
||
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={{ width: THUMB_W, height: Math.round(THUMB_W * 9 / 16) }}
|
||
>
|
||
<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" />
|
||
)}
|
||
{/* 时间戳 */}
|
||
<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>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<NodeShell
|
||
type="process" status={st}
|
||
icon={<ImageIcon className="h-4 w-4" />}
|
||
title="关键帧 · Keyframes"
|
||
subtitle={`STEP 2 · ffmpeg · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
|
||
width={KEYFRAME_WIDTH}
|
||
selected={selected}
|
||
>
|
||
{hasVideo && jobId ? (
|
||
<div className="space-y-2">
|
||
<video
|
||
ref={videoRef}
|
||
src={videoUrl(jobId)}
|
||
controls
|
||
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
|
||
className="block w-full rounded-md bg-black border border-black/10 dark:border-white/10"
|
||
/>
|
||
<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="w-full text-[12px] py-2 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"
|
||
title="把视频当前时间点抽为新关键帧"
|
||
>
|
||
{addingFrame ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||
{addingFrame ? "抽帧中…" : `+ 把 ${videoT.toFixed(1)}s 加为关键帧`}
|
||
</button>
|
||
<div className="text-[10.5px] text-[var(--text-faint)] text-center">
|
||
{frames.length > 0
|
||
? `自动 ${frames.length} 张 · 选中 ${d.selectedFrames.size} 张 · 上方缩略图可点击放大`
|
||
: "等待解析后抽取(默认 5 张)"}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
|
||
等待视频下载完成
|
||
</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 (placeholder)
|
||
============================================================ */
|
||
export function ImageGenNode({ selected }: any) {
|
||
return (
|
||
<NodeShell
|
||
type="ai" status="pending"
|
||
icon={<Sparkles className="h-4 w-4" />}
|
||
title="生图 · Image Gen"
|
||
subtitle="STEP 8 · nano-banana / GPT"
|
||
selected={selected}
|
||
>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
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>
|
||
)
|
||
}
|