auto-save 2026-05-12 16:02 (+2, ~6)

This commit is contained in:
2026-05-12 16:05:47 +08:00
parent 064083ef0a
commit b0ffd03b72
9 changed files with 1080 additions and 214 deletions

View File

@@ -0,0 +1,377 @@
"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 } from "@/lib/api"
export interface NodeData {
job: Job | null
submitting: boolean
selectedFrames: Set<number>
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => 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 < 20) 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 isLocked = !!d.job && d.job.status !== "failed" && d.job.status !== "transcribed"
return (
<NodeShell
type="input" status={inputStatus(d.job)}
icon={<Link2 className="h-4 w-4" />}
title="输入 · Input"
subtitle="STEP 1"
width={300}
selected={selected}
hasTarget={false}
>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="粘贴 TikTok 链接"
disabled={isLocked}
className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/10 dark:border-white/10 outline-none 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={isLocked || !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 ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
</button>
<button
type="button"
disabled={isLocked}
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>
{d.job && (
<div className="mt-2 text-[10.5px] font-mono text-[var(--text-faint)] truncate">
{d.job.url.startsWith("upload://") ? `📎 ${d.job.url.slice(9)}` : d.job.url}
</div>
)}
</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}/10`}
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"> + 10 </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>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import { type ReactNode } from "react"
import { Handle, Position } from "@xyflow/react"
import { CheckCircle2, Loader2, AlertCircle } from "lucide-react"
export type NodeKind = "input" | "process" | "ai" | "output"
export type NodeStatus = "pending" | "running" | "done" | "failed"
interface Props {
type: NodeKind
status: NodeStatus
icon?: ReactNode
title: string
subtitle?: string
width?: number
selected?: boolean
hasTarget?: boolean
hasSource?: boolean
children?: ReactNode
}
const STATUS_DOT: Record<NodeStatus, string> = {
pending: "status-dot",
running: "status-dot status-dot--running",
done: "status-dot status-dot--done",
failed: "status-dot status-dot--failed",
}
const STATUS_LABEL: Record<NodeStatus, string> = {
pending: "待运行",
running: "运行中",
done: "完成",
failed: "失败",
}
export function NodeShell({
type,
status,
icon,
title,
subtitle,
width = 280,
selected,
hasTarget = true,
hasSource = true,
children,
}: Props) {
return (
<div
className={`glass-node ${selected ? "glass-node--selected" : ""} ${status === "running" ? "glass-node--running" : ""}`}
data-type={type}
style={{ width }}
>
{hasTarget && <Handle type="target" position={Position.Left} />}
<div className="glass-node__header">
{icon ? <span className="inline-flex h-5 w-5 items-center justify-center">{icon}</span> : null}
<span className="text-[13px]">{title}</span>
<span className="ml-auto flex items-center gap-1.5">
{status === "running" ? <Loader2 className="h-3 w-3 animate-spin" /> :
status === "done" ? <CheckCircle2 className="h-3 w-3" /> :
status === "failed" ? <AlertCircle className="h-3 w-3" /> : null}
<span className={STATUS_DOT[status]} />
</span>
</div>
<div className="glass-node__body">
{subtitle && (
<div className="text-[11px] mb-2 glass-node__kbd uppercase tracking-widest">
{subtitle} · {STATUS_LABEL[status]}
</div>
)}
{children}
</div>
{hasSource && <Handle type="source" position={Position.Right} />}
</div>
)
}