Files
20260512-skg-tk/web/components/nodes/index.tsx
2026-05-12 17:28:54 +08:00

484 lines
19 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,
} 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>
</>
)}
{/* 已下载:内嵌视频 + 解析按钮 */}
{hasVideo && job && (
<>
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
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>
{/* 手动拖加帧(已抽过帧才出现) */}
{hasFrames && (
<button
type="button"
disabled={addingFrame}
onClick={async (e) => {
e.stopPropagation()
const t = videoRef.current?.currentTime ?? 0
setAddingFrame(true)
try {
await d.onAddManualFrame(t)
} finally {
setAddingFrame(false)
}
}}
className="mt-2 w-full text-[11.5px] py-2 rounded-md border border-dashed border-emerald-400/40 bg-emerald-400/5 hover:bg-emerald-400/10 text-emerald-300 dark:text-emerald-300 disabled:opacity-50 flex items-center justify-center gap-1.5"
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>
)}
<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
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 overflow-hidden rounded-md border transition shadow-lg hover:scale-110 hover:-translate-y-1 ${
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"
/>
{isSel && (
<div className="absolute inset-0 bg-emerald-400/15" />
)}
{/* 时间戳 */}
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none">
{f.timestamp.toFixed(1)}s
</div>
</button>
)
})}
</div>
)}
<NodeShell
type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />}
title="关键帧 · Keyframes"
subtitle={`STEP 4 · ffmpeg · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
width={KEYFRAME_WIDTH}
selected={selected}
>
{frames.length > 0 ? (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{frames.length}</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 API KEYFRAME_COUNT
</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>
)
}