diff --git a/.memory/worklog.json b/.memory/worklog.json index 93b2bae..cfefb15 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -27,6 +27,13 @@ "message": "auto-save 2026-05-12 15:47 (+2, ~3)", "hash": "2e45ad9", "files_changed": 96 + }, + { + "ts": "2026-05-12T15:57:18+08:00", + "type": "commit", + "message": "auto-save 2026-05-12 15:57 (~5)", + "hash": "064083e", + "files_changed": 5 } ] } diff --git a/web/app/globals.css b/web/app/globals.css index 532f8c6..39022c9 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,131 +1,292 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "@xyflow/react/dist/style.css"; @custom-variant dark (&:is(.dark *)); -/* Updated color tokens for dark gallery aesthetic */ +/* ============================================================ + 双主题 · 玻璃拟物(Rivet/Flowise 风) + ============================================================ */ + :root { - --background: oklch(0.08 0 0); - --foreground: oklch(0.95 0 0); - --card: oklch(0.12 0 0); - --card-foreground: oklch(0.95 0 0); - --popover: oklch(0.1 0 0); - --popover-foreground: oklch(0.95 0 0); - --primary: oklch(0.95 0 0); - --primary-foreground: oklch(0.1 0 0); - --secondary: oklch(0.2 0 0); - --secondary-foreground: oklch(0.95 0 0); - --muted: oklch(0.2 0 0); - --muted-foreground: oklch(0.6 0 0); - --accent: oklch(0.25 0 0); - --accent-foreground: oklch(0.95 0 0); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); - --border: oklch(0.25 0 0); - --input: oklch(0.2 0 0); - --ring: oklch(0.5 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --radius: 0.75rem; - --sidebar: oklch(0.1 0 0); - --sidebar-foreground: oklch(0.95 0 0); - --sidebar-primary: oklch(0.95 0 0); - --sidebar-primary-foreground: oklch(0.1 0 0); - --sidebar-accent: oklch(0.2 0 0); - --sidebar-accent-foreground: oklch(0.95 0 0); - --sidebar-border: oklch(0.25 0 0); - --sidebar-ring: oklch(0.5 0 0); + /* ---- Light · 暖白底 ---- */ + --bg-canvas-1: #f6f4ed; + --bg-canvas-2: #ece6d8; + --bg-grid: rgba(0, 0, 0, 0.06); + --bg-aurora-1: rgba(120, 100, 220, 0.15); + --bg-aurora-2: rgba(220, 120, 180, 0.12); + + --node-bg: rgba(255, 255, 255, 0.7); + --node-bg-hover: rgba(255, 255, 255, 0.82); + --node-border: rgba(0, 0, 0, 0.08); + --node-border-hover: rgba(0, 0, 0, 0.18); + --node-ring: rgba(255, 255, 255, 0.9); + --node-shadow: + 0 1px 1px rgba(0, 0, 0, 0.04), + 0 8px 24px -8px rgba(0, 0, 0, 0.12), + 0 30px 60px -30px rgba(40, 30, 80, 0.18); + --node-shadow-hover: + 0 1px 1px rgba(0, 0, 0, 0.06), + 0 12px 32px -10px rgba(40, 30, 80, 0.22), + 0 40px 80px -30px rgba(40, 30, 80, 0.3); + + --text-strong: oklch(0.18 0.02 280); + --text-soft: oklch(0.45 0.02 280); + --text-faint: oklch(0.6 0.02 280); + + --edge-stroke: rgba(60, 50, 120, 0.35); + --edge-glow: rgba(120, 100, 220, 0.4); + + --divider: rgba(0, 0, 0, 0.08); + + --background: oklch(0.97 0.005 80); + --foreground: oklch(0.18 0.02 280); + --border: var(--node-border); + --ring: rgba(120, 100, 220, 0.5); + --radius: 1rem; + + /* 节点头渐变(4 类型)*/ + --grad-input: linear-gradient(135deg, #6366f1, #a855f7); + --grad-process: linear-gradient(135deg, #f59e0b, #ef4444); + --grad-ai: linear-gradient(135deg, #d946ef, #ec4899); + --grad-output: linear-gradient(135deg, #10b981, #06b6d4); + + /* 状态色 */ + --status-pending: oklch(0.7 0.02 280); + --status-running: oklch(0.7 0.18 260); + --status-done: oklch(0.7 0.18 160); + --status-failed: oklch(0.65 0.22 25); +} + +.dark { + /* ---- Dark · 深蓝紫 ---- */ + --bg-canvas-1: #0a0d1c; + --bg-canvas-2: #14172e; + --bg-grid: rgba(255, 255, 255, 0.04); + --bg-aurora-1: rgba(120, 100, 220, 0.22); + --bg-aurora-2: rgba(220, 80, 180, 0.16); + + --node-bg: rgba(22, 26, 48, 0.62); + --node-bg-hover: rgba(30, 35, 60, 0.76); + --node-border: rgba(255, 255, 255, 0.08); + --node-border-hover: rgba(255, 255, 255, 0.18); + --node-ring: rgba(255, 255, 255, 0.05); + --node-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 1px 2px rgba(0, 0, 0, 0.4), + 0 16px 40px -12px rgba(0, 0, 0, 0.6), + 0 30px 80px -20px rgba(80, 50, 200, 0.3); + --node-shadow-hover: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 1px 2px rgba(0, 0, 0, 0.5), + 0 20px 50px -10px rgba(0, 0, 0, 0.7), + 0 40px 100px -20px rgba(120, 80, 240, 0.4); + + --text-strong: oklch(0.96 0.005 280); + --text-soft: oklch(0.7 0.015 280); + --text-faint: oklch(0.5 0.015 280); + + --edge-stroke: rgba(180, 170, 240, 0.45); + --edge-glow: rgba(120, 100, 220, 0.7); + + --divider: rgba(255, 255, 255, 0.08); + + --background: oklch(0.1 0.02 280); + --foreground: oklch(0.96 0.005 280); + --border: var(--node-border); + --ring: rgba(180, 160, 255, 0.5); } -/* Added Playfair Display font for serif headings */ @theme inline { --font-sans: "Geist", "Geist Fallback"; --font-serif: "Playfair Display", Georgia, serif; --font-mono: "Geist Mono", "Geist Mono Fallback"; --color-background: var(--background); --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); - --color-input: var(--input); --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --radius-md: calc(var(--radius) - 0.25rem); + --radius-sm: calc(var(--radius) - 0.5rem); + --radius-xl: calc(var(--radius) + 0.25rem); } @layer base { * { - @apply border-border outline-ring/50; + border-color: var(--border); } - body { - @apply bg-background text-foreground; + html, body { + background: var(--background); + color: var(--foreground); + min-height: 100vh; } } -/* Workbench: keep dark gallery aesthetic but allow scroll */ -html, body { - background: #0a0a0a; - min-height: 100vh; +/* ============================================================ + 画布背景:渐变 + 极光 + 颗粒 + ============================================================ */ +.canvas-bg { + position: fixed; + inset: 0; + z-index: 0; + background: + radial-gradient(ellipse 80% 60% at 20% 10%, var(--bg-aurora-1), transparent 60%), + radial-gradient(ellipse 70% 50% at 80% 90%, var(--bg-aurora-2), transparent 60%), + linear-gradient(180deg, var(--bg-canvas-1), var(--bg-canvas-2)); } -::-webkit-scrollbar { - width: 8px; - height: 8px; +/* ============================================================ + 节点:玻璃拟物 + 头部 4 色渐变 + ============================================================ */ +.glass-node { + position: relative; + background: var(--node-bg); + backdrop-filter: blur(20px) saturate(140%); + -webkit-backdrop-filter: blur(20px) saturate(140%); + border: 1px solid var(--node-border); + border-radius: var(--radius); + box-shadow: var(--node-shadow); + transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1), + box-shadow 0.25s cubic-bezier(0.32, 0.72, 0, 1), + border-color 0.2s, + background 0.2s; + color: var(--text-strong); + overflow: hidden; } -::-webkit-scrollbar-thumb { - background: oklch(0.25 0 0); - border-radius: 4px; +.glass-node:hover { + background: var(--node-bg-hover); + border-color: var(--node-border-hover); + box-shadow: var(--node-shadow-hover); + transform: translateY(-1px); } -::-webkit-scrollbar-track { - background: transparent; +.glass-node--selected { + border-color: var(--ring); + box-shadow: var(--node-shadow-hover), 0 0 0 3px var(--ring); +} +.glass-node--running::after { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + pointer-events: none; + background: conic-gradient(from 0deg, transparent 70%, var(--edge-glow), transparent); + animation: spin 3s linear infinite; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + padding: 1.5px; + opacity: 0.8; +} +@keyframes spin { + to { transform: rotate(360deg); } } -/* Ambient glow utility — 04 风格核心 */ -.ambient-glow { +.glass-node__header { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--divider); + color: white; + font-weight: 600; + letter-spacing: 0.01em; + position: relative; +} +.glass-node__header::before { + content: ""; position: absolute; inset: 0; - pointer-events: none; - background: radial-gradient(circle at 30% 30%, oklch(0.35 0.1 250 / 0.25), transparent 60%), - radial-gradient(circle at 70% 70%, oklch(0.35 0.08 300 / 0.18), transparent 55%); - filter: blur(80px); - z-index: 0; + opacity: 0.92; +} +.glass-node__header > * { position: relative; z-index: 1; } + +.glass-node[data-type="input"] .glass-node__header::before { background: var(--grad-input); } +.glass-node[data-type="process"] .glass-node__header::before { background: var(--grad-process); } +.glass-node[data-type="ai"] .glass-node__header::before { background: var(--grad-ai); } +.glass-node[data-type="output"] .glass-node__header::before { background: var(--grad-output); } + +.glass-node__body { padding: 0.85rem 1rem 1rem; } + +.glass-node__row { + display: flex; align-items: center; gap: 0.5rem; + font-size: 12px; color: var(--text-soft); +} +.glass-node__kbd { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--text-faint); } -/* Glass card — 04 风格 */ -.glass-card { - background: rgba(255, 255, 255, 0.04); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--radius); +/* 状态点 */ +.status-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--status-pending); + box-shadow: 0 0 0 0 currentColor; } +.status-dot--running { + background: var(--status-running); + animation: pulse-dot 1.4s ease-in-out infinite; +} +.status-dot--done { background: var(--status-done); } +.status-dot--failed { background: var(--status-failed); } +@keyframes pulse-dot { + 0%,100% { box-shadow: 0 0 0 0 var(--status-running); opacity: 1; } + 50% { box-shadow: 0 0 0 6px transparent; opacity: 0.7; } +} + +/* 节点 handle(ReactFlow) */ +.react-flow__handle { + width: 12px !important; + height: 12px !important; + background: var(--node-bg) !important; + border: 2px solid var(--edge-stroke) !important; + box-shadow: 0 0 0 3px var(--node-ring) !important; +} +.react-flow__handle:hover { + background: var(--edge-glow) !important; + transform: scale(1.2); +} +.react-flow__edge .react-flow__edge-path { + stroke: var(--edge-stroke); + stroke-width: 1.6; +} +.react-flow__edge.animated .react-flow__edge-path { + stroke: var(--edge-glow); + stroke-width: 2; + filter: drop-shadow(0 0 6px var(--edge-glow)); +} +.react-flow__controls, +.react-flow__panel { + background: var(--node-bg) !important; + border: 1px solid var(--node-border) !important; + border-radius: 12px !important; + backdrop-filter: blur(16px); + box-shadow: var(--node-shadow) !important; +} +.react-flow__controls button { + background: transparent !important; + border-bottom: 1px solid var(--divider) !important; + color: var(--text-strong) !important; +} +.react-flow__controls button:hover { + background: var(--node-bg-hover) !important; +} +.react-flow__minimap { + background: var(--node-bg) !important; + border-radius: 12px !important; + border: 1px solid var(--node-border) !important; + box-shadow: var(--node-shadow) !important; +} +.react-flow__background pattern circle { + fill: var(--bg-grid) !important; +} +.react-flow__attribution { display: none !important; } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { background: var(--divider); border-radius: 4px; } +::-webkit-scrollbar-track { background: transparent; } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 2ca2bb6..b337dce 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,6 +1,7 @@ import type React from "react" import type { Metadata } from "next" import { Geist, Geist_Mono, Playfair_Display } from "next/font/google" +import { ThemeProvider } from "@/components/theme-provider" import "./globals.css" const _geist = Geist({ subsets: ["latin"] }) @@ -12,7 +13,7 @@ const _playfairDisplay = Playfair_Display({ export const metadata: Metadata = { title: "SKG TK 二创工作台", - description: "SKG AI 素材生产管线 · TK 链接 → 关键帧 + 双语转录 → 改写 / 生图 / 生视频", + description: "SKG AI 素材生产管线 · 节点工作流", } export default function RootLayout({ @@ -21,8 +22,12 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - {children} + + + + {children} + + ) } diff --git a/web/app/page.tsx b/web/app/page.tsx index 192a706..303fdc1 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,23 +1,71 @@ "use client" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + ReactFlow, Background, BackgroundVariant, Controls, MiniMap, + type Node, type Edge, +} from "@xyflow/react" import { Toaster, toast } from "sonner" -import { UrlInput } from "@/components/url-input" -import { JobStatusBar } from "@/components/job-status" -import { KeyframeGallery } from "@/components/keyframe-gallery" -import { TranscriptPanel } from "@/components/transcript-panel" -import { createJob, getJob, triggerTranscribe, uploadJob, videoUrl, type Job } from "@/lib/api" +import { + InputNode, DownloadNode, SplitNode, KeyframeNode, ASRNode, + TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode, + type NodeData, +} from "@/components/nodes" +import { ThemeToggle } from "@/components/theme-toggle" +import { createJob, getJob, triggerTranscribe, uploadJob, type Job } from "@/lib/api" + +const NODE_TYPES = { + input: InputNode, + download: DownloadNode, + split: SplitNode, + keyframe: KeyframeNode, + asr: ASRNode, + translate: TranslateNode, + rewrite: RewriteNode, + imagegen: ImageGenNode, + videogen: VideoGenNode, + compose: ComposeNode, +} + +// 手布局:DAG,从左到右 +// 拆分后两路:上路 video → keyframe → imagegen → videogen ↘ +// 下路 audio → asr → translate → rewrite ────→ compose +const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number }> = [ + { id: "input", type: "input", x: 40, y: 240 }, + { id: "download", type: "download", x: 400, y: 240 }, + { id: "split", type: "split", x: 720, y: 240 }, + { id: "keyframe", type: "keyframe", x: 1060, y: 60 }, + { id: "asr", type: "asr", x: 1060, y: 440 }, + { id: "translate", type: "translate", x: 1440, y: 440 }, + { id: "imagegen", type: "imagegen", x: 1480, y: 60 }, + { id: "rewrite", type: "rewrite", x: 1820, y: 440 }, + { id: "videogen", type: "videogen", x: 1860, y: 60 }, + { id: "compose", type: "compose", x: 2240, y: 240 }, +] + +const EDGES_RAW: Array<[string, string]> = [ + ["input", "download"], + ["download", "split"], + ["split", "keyframe"], + ["split", "asr"], + ["asr", "translate"], + ["translate", "rewrite"], + ["keyframe", "imagegen"], + ["rewrite", "imagegen"], + ["imagegen", "videogen"], + ["videogen", "compose"], + ["rewrite", "compose"], +] export default function Home() { const [job, setJob] = useState(null) const [submitting, setSubmitting] = useState(false) - const [selected, setSelected] = useState>(new Set()) - const videoRef = useRef(null) + const [selectedFrames, setSelectedFrames] = useState>(new Set()) const pollRef = useRef | null>(null) const transcribeTriggeredRef = useRef(null) const handleSubmit = useCallback(async (url: string) => { setSubmitting(true) - setSelected(new Set()) + setSelectedFrames(new Set()) transcribeTriggeredRef.current = null try { const created = await createJob(url) @@ -32,13 +80,13 @@ export default function Home() { const handleUpload = useCallback(async (file: File) => { setSubmitting(true) - setSelected(new Set()) + setSelectedFrames(new Set()) transcribeTriggeredRef.current = null try { - toast.info(`正在上传 ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) + toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) const created = await uploadJob(file) setJob(created) - toast.success(`已上传,任务 ${created.id.slice(0, 8)}`) + toast.success(`已上传 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -46,27 +94,32 @@ export default function Home() { } }, []) - // 轮询 job 状态 + const handleToggleFrame = useCallback((idx: number) => { + setSelectedFrames((prev) => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx) + else if (next.size < 10) next.add(idx) + return next + }) + }, []) + + // 轮询 Job useEffect(() => { if (!job) return if (job.status === "transcribed" || job.status === "failed") { - if (pollRef.current) clearInterval(pollRef.current) + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } return } pollRef.current = setInterval(async () => { try { const latest = await getJob(job.id) setJob(latest) - } catch { - // silent - } + } catch { /* silent */ } }, 1500) - return () => { - if (pollRef.current) clearInterval(pollRef.current) - } + return () => { if (pollRef.current) clearInterval(pollRef.current) } }, [job?.id, job?.status]) - // 抽帧完成后自动触发 ASR + // 抽帧完后自动触发 transcribe useEffect(() => { if (!job) return if (job.status !== "frames_extracted") return @@ -75,108 +128,94 @@ export default function Home() { triggerTranscribe(job.id).catch((e) => toast.error("启动转录失败:" + e.message)) }, [job?.id, job?.status]) - const toggleFrame = (idx: number) => { - setSelected((prev) => { - const next = new Set(prev) - if (next.has(idx)) next.delete(idx) - else if (next.size < 10) next.add(idx) - return next - }) - } + const nodeData: NodeData = useMemo(() => ({ + job, + submitting, + selectedFrames, + onSubmitUrl: handleSubmit, + onUploadFile: handleUpload, + onToggleFrame: handleToggleFrame, + }), [job, submitting, selectedFrames, handleSubmit, handleUpload, handleToggleFrame]) - const handleSeek = (sec: number) => { - if (videoRef.current) { - videoRef.current.currentTime = sec - videoRef.current.play().catch(() => {}) + const nodes: Node[] = useMemo( + () => LAYOUT.map((n) => ({ + id: n.id, + type: n.type, + position: { x: n.x, y: n.y }, + data: nodeData, + draggable: true, + })), + [nodeData], + ) + + // 边状态:source 节点 done 时 animated + const edges: Edge[] = useMemo(() => { + const doneOf: Record = { + input: !!job, + download: !!job?.video_url, + split: !!job && ["frames_extracted", "transcribing", "transcribed"].includes(job.status), + keyframe: !!job && job.frames.length > 0, + asr: !!job && job.transcript.length > 0, + translate: !!job && (job.transcript.some((s) => s.zh) ?? false), } - } + return EDGES_RAW.map(([from, to], i) => ({ + id: `e${i}`, + source: from, + target: to, + animated: !!doneOf[from], + type: "default", + })) + }, [job]) return ( -
-
-
- {/* Header */} -
-
SKG · AI Material Pipeline
-

- TK 二创工作台 - / Verification Prototype -

-

- 粘贴 TikTok 链接 → 自动抽取关键帧 + Gemini 双语转录 → 后续接入文案改写 / 生图 / 生视频。 -

+ <> +
+
+ {/* 顶部栏 */} +
+
+
SKG · AI Material Pipeline
+

+ TK 二创工作台 + / Node Workflow +

+
+
+ {job && ( +
+ JOB + {job.id.slice(0, 8)} + · + {job.message || job.status} +
+ )} + +
- {/* URL 输入 */} -
- -
+ {/* 画布 */} + + + + + - {job && ( - <> - {/* 状态条 */} -
- -
- - {/* 视频预览 + 关键帧 */} - {job.video_url && ( -
-
-
-
-
-
关键帧 · Keyframes
-
自动抽取,点击勾选最多 10 张作为生图参考
-
- -
-
- )} - - {/* 双语转录 */} - {(job.frames.length > 0 || job.transcript.length > 0) && ( -
-
-
双语转录 · Transcript
-
点击段落跳转视频时间点
-
- -
- )} - - )} - - {!job && ( -
-
↑ 粘贴 TikTok 链接,或拖入 / 上传本地视频
-
- )} - - {/* Footer */} -
-
SKG TK 二创验证 · MVP 第一冲刺(步骤 1-4)
-
4290 · {process.env.NEXT_PUBLIC_API_BASE ?? "localhost:4291"}
+ {/* 底部说明 */} +
+ MVP 第一冲刺:步骤 1-6 已通 · 7-10 占位 · 拖拽节点 · 滚轮缩放
-
- -
+ + + + ) } diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx new file mode 100644 index 0000000..af24c06 --- /dev/null +++ b/web/components/nodes/index.tsx @@ -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 + 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(null) + const isLocked = !!d.job && d.job.status !== "failed" && d.job.status !== "transcribed" + return ( + } + title="输入 · Input" + subtitle="STEP 1" + width={300} + selected={selected} + hasTarget={false} + > + 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" + /> +
+ + + { + const f = e.target.files?.[0] + if (f) d.onUploadFile(f) + e.target.value = "" + }} + /> +
+ {d.job && ( +
+ {d.job.url.startsWith("upload://") ? `📎 ${d.job.url.slice(9)}` : d.job.url} +
+ )} +
+ ) +} + +/* ============================================================ + 2. DownloadNode + ============================================================ */ +export function DownloadNode({ data, selected }: any) { + const d: NodeData = data + const st = downloadStatus(d.job) + return ( + } + title="下载 · Download" + subtitle="STEP 2 · yt-dlp" + selected={selected} + > +
+ {d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"} +
+ {d.job && st === "done" && ( +
+
分辨率
{d.job.width}×{d.job.height}
+
时长
{d.job.duration.toFixed(1)}s
+
+ )} +
+ ) +} + +/* ============================================================ + 3. SplitNode + ============================================================ */ +export function SplitNode({ data, selected }: any) { + const d: NodeData = data + return ( + } + title="拆分 · Split" + subtitle="STEP 3 · ffmpeg" + selected={selected} + > +
+
+
视频流
+
→ 关键帧
+
+
+
音频流
+
→ ASR
+
+
+
+ ) +} + +/* ============================================================ + 4. KeyframeNode — 缩略图网格 + 多选 + ============================================================ */ +export function KeyframeNode({ data, selected }: any) { + const d: NodeData = data + const st = keyframeStatus(d.job) + return ( + } + title="关键帧 · Keyframes" + subtitle={`STEP 4 · ${d.selectedFrames.size}/10`} + width={360} + selected={selected} + > + {d.job?.frames.length ? ( +
+ {d.job.frames.map((f) => { + const isSel = d.selectedFrames.has(f.index) + return ( + + ) + })} +
+ ) : ( +
等待视频流,自动 + 手动抽取 ≤10 张
+ )} +
+ ) +} + +/* ============================================================ + 5. ASRNode — Gemini 转录 + ============================================================ */ +export function ASRNode({ data, selected }: any) { + const d: NodeData = data + return ( + } + title="转录 · ASR" + subtitle="STEP 5 · Gemini" + selected={selected} + > +
+ Gemini 2.5 · 英文带时间戳分段 +
+ {d.job && d.job.transcript.length > 0 && ( +
+ {d.job.transcript.slice(0, 3).map((s) => ( +
+ + {s.start.toFixed(1)}s + + {s.en.slice(0, 60)} + {s.en.length > 60 && "…"} +
+ ))} + {d.job.transcript.length > 3 && ( +
还有 {d.job.transcript.length - 3} 段…
+ )} +
+ )} +
+ ) +} + +/* ============================================================ + 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 ( + } + title="翻译 · Translate" + subtitle="STEP 6 · EN → ZH" + selected={selected} + > +
+ 中文翻译 · 段落级 · 实时输出 +
+ {hasZh && d.job && ( +
+ {d.job.transcript.slice(0, 3).map((s) => ( +
{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}
+ ))} +
+ )} +
+ ) +} + +/* ============================================================ + 7. RewriteNode (placeholder) + ============================================================ */ +export function RewriteNode({ selected }: any) { + return ( + } + title="文案改写 · Rewrite" + subtitle="STEP 7 · 接产品信息" + selected={selected} + > +