auto-save 2026-05-12 16:02 (+2, ~6)
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
271
web/app/page.tsx
271
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<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user