419 lines
16 KiB
TypeScript
419 lines
16 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,
|
||
} from "lucide-react"
|
||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||
import { type Job, 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
|
||
}
|
||
|
||
/* ---- 状态映射工具 ---- */
|
||
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 fileRef = useRef<HTMLInputElement>(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 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>
|
||
</>
|
||
)}
|
||
|
||
{/* 已下载:内嵌视频 + 解析按钮 */}
|
||
{hasVideo && job && (
|
||
<>
|
||
<video
|
||
src={videoUrl(job.id)}
|
||
controls
|
||
className="w-full aspect-video rounded-md bg-black border border-black/10 dark:border-white/10"
|
||
/>
|
||
<div className="mt-2 flex items-center justify-between text-[10.5px] font-mono text-[var(--text-faint)]">
|
||
<span>{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
|
||
<span className="truncate ml-2 max-w-[120px]">
|
||
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : "🔗"}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={isAnalyzing || d.analyzing}
|
||
onClick={d.onAnalyze}
|
||
className="mt-2 w-full text-[12px] py-2 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-1.5 font-medium"
|
||
>
|
||
{(isAnalyzing || d.analyzing) ? (
|
||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> 解析中…</>
|
||
) : isDone ? (
|
||
"重新解析"
|
||
) : (
|
||
"解析 →"
|
||
)}
|
||
</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 — 缩略图网格 + 多选
|
||
============================================================ */
|
||
export function KeyframeNode({ data, selected }: any) {
|
||
const d: NodeData = data
|
||
const st = keyframeStatus(d.job)
|
||
return (
|
||
<NodeShell
|
||
type="ai" status={st}
|
||
icon={<ImageIcon className="h-4 w-4" />}
|
||
title="关键帧 · Keyframes"
|
||
subtitle={`STEP 4 · ${d.selectedFrames.size}/${d.job?.frames.length || 5}`}
|
||
width={360}
|
||
selected={selected}
|
||
>
|
||
{d.job?.frames.length ? (
|
||
<div className="grid grid-cols-5 gap-1.5">
|
||
{d.job.frames.map((f) => {
|
||
const isSel = d.selectedFrames.has(f.index)
|
||
return (
|
||
<button
|
||
key={f.index}
|
||
onClick={(e) => { e.stopPropagation(); d.onToggleFrame(f.index) }}
|
||
className={`relative aspect-video overflow-hidden rounded-md border ${
|
||
isSel ? "border-[var(--ring)] ring-2 ring-[var(--ring)]" : "border-black/10 dark:border-white/10"
|
||
}`}
|
||
>
|
||
<img
|
||
src={`${process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"}${f.url}`}
|
||
alt={`frame ${f.index}`}
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
/>
|
||
{isSel && (
|
||
<div className="absolute inset-0 bg-[var(--ring)]/15" />
|
||
)}
|
||
<div className="absolute bottom-0.5 right-0.5 bg-black/60 text-white text-[8px] font-mono px-1 rounded">
|
||
{f.timestamp.toFixed(1)}s
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="text-[11.5px] text-[var(--text-faint)] py-2">等待解析后抽取(默认 5 张)</div>
|
||
)}
|
||
</NodeShell>
|
||
)
|
||
}
|
||
|
||
/* ============================================================
|
||
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>
|
||
)
|
||
}
|