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

@@ -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; }
}
/* 节点 handleReactFlow */
.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; }

View File

@@ -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 (
<html lang="zh-CN">
<body className="font-sans antialiased">{children}</body>
<html lang="zh-CN" suppressHydrationWarning>
<body className="font-sans antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
)
}

View File

@@ -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<Job | null>(null)
const [submitting, setSubmitting] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const videoRef = useRef<HTMLVideoElement>(null)
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const transcribeTriggeredRef = useRef<string | null>(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<string, boolean> = {
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 (
<main className="relative min-h-screen text-white overflow-x-hidden">
<div className="ambient-glow" />
<div className="relative z-10 mx-auto max-w-6xl px-6 py-10 space-y-8">
{/* Header */}
<header className="space-y-2">
<div className="text-xs uppercase tracking-[0.3em] text-white/40">SKG · AI Material Pipeline</div>
<h1 className="font-serif text-4xl md:text-5xl leading-tight">
TK
<span className="text-white/40 ml-3 text-xl font-sans tracking-tight">/ Verification Prototype</span>
</h1>
<p className="text-white/50 text-sm max-w-2xl">
TikTok + Gemini / /
</p>
<>
<div className="canvas-bg" />
<main className="relative h-screen w-screen overflow-hidden">
{/* 顶部栏 */}
<header className="absolute top-4 left-6 right-6 z-20 flex items-center justify-between pointer-events-none">
<div className="pointer-events-auto">
<div className="text-[10px] uppercase tracking-[0.3em] text-[var(--text-faint)]">SKG · AI Material Pipeline</div>
<h1 className="font-serif text-[26px] leading-none mt-1 text-[var(--text-strong)]">
TK
<span className="text-[var(--text-faint)] ml-3 text-[14px] font-sans tracking-tight">/ Node Workflow</span>
</h1>
</div>
<div className="pointer-events-auto flex items-center gap-2">
{job && (
<div className="glass-node flex items-center gap-2 px-3 h-9" style={{ borderRadius: 12 }}>
<span className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">JOB</span>
<span className="text-[11.5px] font-mono text-[var(--text-strong)]">{job.id.slice(0, 8)}</span>
<span className="text-[10.5px] text-[var(--text-faint)]">·</span>
<span className="text-[11.5px] text-[var(--text-soft)]">{job.message || job.status}</span>
</div>
)}
<ThemeToggle />
</div>
</header>
{/* URL 输入 */}
<section>
<UrlInput
loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")}
onSubmitUrl={handleSubmit}
onUploadFile={handleUpload}
/>
</section>
{/* 画布 */}
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
fitViewOptions={{ padding: 0.12 }}
minZoom={0.4}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
<Controls position="bottom-left" />
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
</ReactFlow>
{job && (
<>
{/* 状态条 */}
<section>
<JobStatusBar job={job} />
</section>
{/* 视频预览 + 关键帧 */}
{job.video_url && (
<section className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-6">
<div className="glass-card overflow-hidden">
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
className="w-full aspect-video bg-black"
/>
<div className="px-4 py-2.5 text-[11px] font-mono text-white/40 border-t border-white/10">
{job.width}×{job.height} · {job.duration?.toFixed(1)}s
</div>
</div>
<div>
<div className="mb-6">
<div className="font-serif text-lg mb-1"> · Keyframes</div>
<div className="text-xs text-white/40"> 10 </div>
</div>
<KeyframeGallery frames={job.frames} selected={selected} onToggle={toggleFrame} />
</div>
</section>
)}
{/* 双语转录 */}
{(job.frames.length > 0 || job.transcript.length > 0) && (
<section>
<div className="mb-4">
<div className="font-serif text-lg mb-1"> · Transcript</div>
<div className="text-xs text-white/40"></div>
</div>
<TranscriptPanel
segments={job.transcript}
loading={job.status === "transcribing"}
onSeek={handleSeek}
/>
</section>
)}
</>
)}
{!job && (
<section className="text-center py-16">
<div className="font-serif text-2xl text-white/30"> TikTok / </div>
</section>
)}
{/* Footer */}
<footer className="pt-8 pb-4 text-[11px] text-white/30 font-mono flex items-center justify-between border-t border-white/5">
<div>SKG TK · MVP 1-4</div>
<div>4290 · {process.env.NEXT_PUBLIC_API_BASE ?? "localhost:4291"}</div>
{/* 底部说明 */}
<footer className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 text-[10.5px] font-mono text-[var(--text-faint)] pointer-events-none">
MVP 1-6 · 7-10 · ·
</footer>
</div>
<Toaster theme="dark" position="top-right" />
</main>
<Toaster theme="system" position="top-right" />
</main>
</>
)
}