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 (
-
-
-
-
-
+
+
+
+ >
)
}
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}
+ >
+
+
+ )
+}
+
+/* ============================================================
+ 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}
+ >
+
+ 下一冲刺接入
+
+ )
+}
+
+/* ============================================================
+ 8. ImageGenNode (placeholder)
+ ============================================================ */
+export function ImageGenNode({ selected }: any) {
+ return (
+ }
+ title="生图 · Image Gen"
+ subtitle="STEP 8 · nano-banana / GPT"
+ selected={selected}
+ >
+
+
+ nano-banana-pro
Gemini 3 Image
+
+
+ GPT Image
OpenAI
+
+
+
+ )
+}
+
+/* ============================================================
+ 9. VideoGenNode (placeholder)
+ ============================================================ */
+export function VideoGenNode({ selected }: any) {
+ return (
+ }
+ title="生视频 · Video Gen"
+ subtitle="STEP 9 · 多家可切"
+ selected={selected}
+ >
+
+ {["Seedance", "Kling", "Veo 3"].map((m) => (
+
+ {m}
+
+ ))}
+
+
+ )
+}
+
+/* ============================================================
+ 10. ComposeNode (placeholder)
+ ============================================================ */
+export function ComposeNode({ selected }: any) {
+ return (
+ }
+ title="合成成品 · Compose"
+ subtitle="STEP 10 · ffmpeg + TTS"
+ selected={selected}
+ hasSource={false}
+ >
+
+ 视频片段 + 字幕 / TTS
→ 最终 mp4 输出
+
+
+ )
+}
diff --git a/web/components/nodes/node-shell.tsx b/web/components/nodes/node-shell.tsx
new file mode 100644
index 0000000..b7976cc
--- /dev/null
+++ b/web/components/nodes/node-shell.tsx
@@ -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 = {
+ 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 = {
+ pending: "待运行",
+ running: "运行中",
+ done: "完成",
+ failed: "失败",
+}
+
+export function NodeShell({
+ type,
+ status,
+ icon,
+ title,
+ subtitle,
+ width = 280,
+ selected,
+ hasTarget = true,
+ hasSource = true,
+ children,
+}: Props) {
+ return (
+
+ {hasTarget &&
}
+
+
+ {icon ?
{icon} : null}
+
{title}
+
+ {status === "running" ? :
+ status === "done" ? :
+ status === "failed" ? : null}
+
+
+
+
+
+ {subtitle && (
+
+ {subtitle} · {STATUS_LABEL[status]}
+
+ )}
+ {children}
+
+
+ {hasSource &&
}
+
+ )
+}
diff --git a/web/components/theme-toggle.tsx b/web/components/theme-toggle.tsx
new file mode 100644
index 0000000..69aa4ad
--- /dev/null
+++ b/web/components/theme-toggle.tsx
@@ -0,0 +1,22 @@
+"use client"
+import { useEffect, useState } from "react"
+import { useTheme } from "next-themes"
+import { Moon, Sun } from "lucide-react"
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme()
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => setMounted(true), [])
+ if (!mounted) return null
+ const isDark = theme === "dark"
+ return (
+
+ )
+}
diff --git a/web/package.json b/web/package.json
index c8c4f2a..31020dd 100644
--- a/web/package.json
+++ b/web/package.json
@@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
+ "@xyflow/react": "^12.10.2",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index b148b6c..5840ed5 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -92,6 +92,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: 1.1.6
version: 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@xyflow/react':
+ specifier: ^12.10.2
+ version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
autoprefixer:
specifier: ^10.4.20
version: 10.5.0(postcss@8.5.14)
@@ -1185,6 +1188,24 @@ packages:
'@tailwindcss/postcss@4.3.0':
resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==}
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-drag@3.0.7':
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-selection@3.0.11':
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
+ '@types/d3-transition@3.0.9':
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+ '@types/d3-zoom@3.0.8':
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
'@types/node@22.19.19':
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
@@ -1196,6 +1217,15 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@xyflow/react@12.10.2':
+ resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@xyflow/system@0.0.76':
+ resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
+
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -1223,6 +1253,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ classcat@5.0.5:
+ resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
+
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -1239,6 +1272,44 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
@@ -1595,6 +1666,21 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ zustand@4.5.7:
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -2534,6 +2620,27 @@ snapshots:
postcss: 8.5.14
tailwindcss: 4.3.0
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-drag@3.0.7':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-selection@3.0.11': {}
+
+ '@types/d3-transition@3.0.9':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-zoom@3.0.8':
+ dependencies:
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+
'@types/node@22.19.19':
dependencies:
undici-types: 6.21.0
@@ -2546,6 +2653,29 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@xyflow/system': 0.0.76
+ classcat: 5.0.5
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ zustand: 4.5.7(@types/react@19.2.14)(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@xyflow/system@0.0.76':
+ dependencies:
+ '@types/d3-drag': 3.0.7
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-zoom: 3.0.0
+
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@@ -2575,6 +2705,8 @@ snapshots:
dependencies:
clsx: 2.1.1
+ classcat@5.0.5: {}
+
client-only@0.0.1: {}
clsx@2.1.1: {}
@@ -2593,6 +2725,42 @@ snapshots:
csstype@3.2.3: {}
+ d3-color@3.1.0: {}
+
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
+ d3-ease@3.0.1: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-selection@3.0.0: {}
+
+ d3-timer@3.0.1: {}
+
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
@@ -2895,3 +3063,10 @@ snapshots:
- '@types/react-dom'
zod@3.25.76: {}
+
+ zustand@4.5.7(@types/react@19.2.14)(react@19.2.0):
+ dependencies:
+ use-sync-external-store: 1.6.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ react: 19.2.0