diff --git a/web/video-studio/app/globals.css b/web/video-studio/app/globals.css
new file mode 100644
index 000000000..69d554a1e
--- /dev/null
+++ b/web/video-studio/app/globals.css
@@ -0,0 +1,62 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --background: 216 33% 97%;
+ --foreground: 222 47% 11%;
+ --card: 0 0% 100%;
+ --card-foreground: 222 47% 11%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222 47% 11%;
+ --primary: 217 91% 60%;
+ --primary-foreground: 0 0% 100%;
+ --secondary: 210 40% 96%;
+ --secondary-foreground: 222 47% 11%;
+ --muted: 210 40% 96%;
+ --muted-foreground: 215 20% 65%;
+ --accent: 210 40% 96%;
+ --accent-foreground: 222 47% 11%;
+ --destructive: 0 84% 60%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214 32% 91%;
+ --input: 214 32% 91%;
+ --ring: 217 91% 60%;
+ --radius: 0.75rem;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ background: radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 60%),
+ radial-gradient(circle at bottom, rgba(15, 23, 42, 0.06), transparent 55%),
+ hsl(var(--background));
+ color: hsl(var(--foreground));
+ min-height: 100vh;
+}
+
+.scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: rgba(59, 130, 246, 0.4);
+ border-radius: 9999px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-track {
+ background-color: transparent;
+}
+
+.card-gradient {
+ background: linear-gradient(145deg, rgba(59, 130, 246, 0.08), rgba(96, 165, 250, 0.15));
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/web/video-studio/app/layout.tsx b/web/video-studio/app/layout.tsx
new file mode 100644
index 000000000..e41fc1d74
--- /dev/null
+++ b/web/video-studio/app/layout.tsx
@@ -0,0 +1,23 @@
+import "./globals.css";
+import type { Metadata } from "next";
+import { Toaster } from "@/components/ui/toaster";
+
+export const metadata: Metadata = {
+ title: "AI 视频生成工作室",
+ description: "基于胜算云 API 的智能视频生成与编辑平台"
+};
+
+export default function RootLayout({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/web/video-studio/app/page.tsx b/web/video-studio/app/page.tsx
new file mode 100644
index 000000000..6baf7ffec
--- /dev/null
+++ b/web/video-studio/app/page.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import { useCallback, useMemo, useState } from "react";
+import { motion } from "framer-motion";
+import { ArrowRight, Sparkles } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { toast } from "@/components/ui/use-toast";
+import { ConfigPanel } from "@/components/config/config-panel";
+import { PromptEditor } from "@/components/prompt/prompt-editor";
+import { PromptTagManager } from "@/components/prompt/prompt-tag-manager";
+import { PromptTemplatePanel } from "@/components/prompt/prompt-template-panel";
+import { GenerationStatus } from "@/components/status/generation-status";
+import { VideoResultPanel } from "@/components/video/video-result-panel";
+import { ReferenceImageUploader } from "@/components/video/reference-image-uploader";
+import { VideoTemplateUploader } from "@/components/video/video-template-uploader";
+import { useStudioConfig } from "@/hooks/use-studio-config";
+import { useVideoGeneration } from "@/hooks/use-video-generation";
+import type { GenerationPayload } from "@/types/api";
+import type { PromptTag, PromptTemplate, ReferenceImage, TemplateVideo } from "@/types/studio";
+import { formatBytes } from "@/lib/utils";
+
+const NEGATIVE_PROMPT = "模糊, 低质量, 变形, 人物扭曲, 失真, 噪点";
+
+const RECOMMENDED_TAGS = [
+ "电影级运镜",
+ "自然光",
+ "4K 质感",
+ "真实人物",
+ "柔和色彩",
+ "动态景深",
+ "专业打光",
+ "细节特写"
+];
+
+const DEFAULT_TEMPLATES: PromptTemplate[] = [
+ {
+ id: "cinematic-nature",
+ name: "电影级自然景观",
+ description: "适合风景类视频,强调电影运镜与细节刻画",
+ prompt:
+ "使用电影级镜头拍摄一片晨雾缭绕的山谷,镜头缓慢推进,阳光穿透云层洒向绿色山峦,空气中有飘动的花瓣,整体色调温暖而梦幻。"
+ },
+ {
+ id: "city-night",
+ name: "赛博夜景",
+ description: "适合城市或夜景科幻题材",
+ prompt:
+ "一个赛博朋克城市的夜晚街头,霓虹灯反射在湿润的街道上,人群行走迅速,镜头跟随一个穿着未来风格外套的角色向前推进,背景有全息广告与飞行载具掠过。"
+ }
+];
+
+const DEFAULT_PROMPT =
+ "一只可爱的AI吉祥物在科技感十足的工作室里操作全息屏幕,屏幕上展示视频生成进度条和模型参数,镜头环绕角色移动,强调未来感与专业氛围。";
+
+async function fileToBase64(file: File): Promise {
+ const buffer = await file.arrayBuffer();
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ bytes.forEach((byte) => {
+ binary += String.fromCharCode(byte);
+ });
+ return `data:${file.type};base64,${btoa(binary)}`;
+}
+
+export default function StudioPage() {
+ const { config, setConfig } = useStudioConfig();
+ const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
+ const [tags, setTags] = useState(
+ RECOMMENDED_TAGS.slice(0, 3).map((label) => ({ id: crypto.randomUUID(), label }))
+ );
+ const [templates, setTemplates] = useState(DEFAULT_TEMPLATES);
+ const [referenceImages, setReferenceImages] = useState([]);
+ const [templateVideo, setTemplateVideo] = useState();
+
+ const generation = useVideoGeneration(config);
+ const isGenerating = useMemo(
+ () => ["submitting", "pending", "running"].includes(generation.state.phase),
+ [generation.state.phase]
+ );
+
+ const buildPrompt = useCallback(() => {
+ const tagLine = tags.length
+ ? `# 重点标签:${tags.map((tag) => tag.label).join(",")}`
+ : "";
+ const templateLine = templateVideo
+ ? `# 模板引用:请参考上传的视频模板 ${templateVideo.name} (${formatBytes(templateVideo.size)}) 的镜头风格`
+ : "";
+ return [tagLine, templateLine, prompt].filter(Boolean).join("\n\n");
+ }, [prompt, tags, templateVideo]);
+
+ const handleGenerate = useCallback(async () => {
+ if (!config.apiKey) {
+ toast({ title: "请先配置 API Key", variant: "destructive" });
+ return;
+ }
+
+ const finalPrompt = buildPrompt();
+ const payload: GenerationPayload = {
+ model: config.model,
+ prompt: finalPrompt,
+ aspect_ratio: config.aspectRatio,
+ duration_seconds: config.duration,
+ resolution: config.resolution,
+ enhance_prompt: config.enhancePrompt,
+ generate_audio: config.generateAudio
+ };
+
+ if (config.autoApplyNegativePrompt) {
+ payload.negative_prompt = NEGATIVE_PROMPT;
+ }
+
+ if (referenceImages.length > 0) {
+ payload.image_list = await Promise.all(
+ referenceImages.map(async (image) => ({
+ image: await fileToBase64(image.file),
+ role: "reference_image"
+ }))
+ );
+ }
+
+ if (templateVideo) {
+ payload.template_video = await fileToBase64(templateVideo.file);
+ }
+
+ generation.generate(payload);
+ toast({ title: "任务已提交", description: "后台正在生成,请关注状态栏" });
+ }, [config, buildPrompt, referenceImages, templateVideo, generation]);
+
+ const handleApplyTemplate = (template: PromptTemplate) => {
+ setPrompt(template.prompt);
+ toast({ title: "模板已应用", description: template.name });
+ };
+
+ const handleTemplateVideoInsert = (details: { name: string; size: number }) => {
+ setPrompt((prev) =>
+ [
+ prev,
+ `请结合模板 ${details.name} (大小 ${formatBytes(details.size)}) 的运镜节奏,保持画面风格一致。`
+ ].join("\n")
+ );
+ toast({ title: "模板信息已写入 Prompt" });
+ };
+
+ return (
+
+
+
+
+
超好用的 AI 视频生成工作室
+
+
+ 通过 Prompt、模板与参考图快速生成高质量视频。支持 Veo、Sora、豆包、即梦、MiniMax、Vidu 等多模型异步任务。
+
+
+
+
+
+
+
+
setTemplates((prev) => [template, ...prev])}
+ />
+
+
+ Prompt 标签管理
+
+
+
+
+
+
+
+
+
一键生成视频
+
+ 系统将自动包装 System Prompt、提交任务并轮询任务进度。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/video-studio/components/config/config-panel.tsx b/web/video-studio/components/config/config-panel.tsx
new file mode 100644
index 000000000..dd36c3c71
--- /dev/null
+++ b/web/video-studio/components/config/config-panel.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useMemo } from "react";
+import { Settings } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import type { StudioConfig, VideoModel } from "@/lib/config";
+import { toast } from "@/components/ui/use-toast";
+
+const MODEL_OPTIONS: Array<{ label: string; value: VideoModel; provider: string }> = [
+ { label: "Google Veo 3", value: "google/veo3", provider: "Google" },
+ { label: "Google Veo 2", value: "google/veo2", provider: "Google" },
+ { label: "OpenAI Sora", value: "openai/sora", provider: "OpenAI" },
+ { label: "豆包 Seedance Pro", value: "bytedance/doubao-seedance-1.0-pro", provider: "字节跳动" },
+ { label: "豆包 Seedance Lite T2V", value: "bytedance/doubao-seedance-1.0-lite-t2v", provider: "字节跳动" },
+ { label: "豆包 Seedance Lite I2V", value: "bytedance/doubao-seedance-1.0-lite-i2v", provider: "字节跳动" },
+ { label: "即梦 Ti2V Pro", value: "bytedance/jimeng_ti2v_v30_pro", provider: "即梦" },
+ { label: "即梦 T2V 标准", value: "bytedance/jimeng_t2v_v30", provider: "即梦" },
+ { label: "即梦 I2V 首帧", value: "bytedance/jimeng_i2v_first_v30", provider: "即梦" },
+ { label: "即梦 I2V 首尾", value: "bytedance/jimeng_i2v_first_tail_v30", provider: "即梦" },
+ { label: "MiniMax T2V Director", value: "minimax/t2v-01-director", provider: "MiniMax" },
+ { label: "MiniMax I2V Director", value: "minimax/i2v-01-director", provider: "MiniMax" },
+ { label: "MiniMax I2V Live", value: "minimax/i2v-01-live", provider: "MiniMax" },
+ { label: "MiniMax S2V", value: "minimax/s2v-01", provider: "MiniMax" },
+ { label: "Vidu 1.5", value: "vidu/vidu-1.5", provider: "Vidu" },
+ { label: "Vidu 2.0", value: "vidu/vidu-2.0", provider: "Vidu" },
+ { label: "Vidu Q1", value: "vidu/vidu-q1", provider: "Vidu" }
+];
+
+interface ConfigPanelProps {
+ config: StudioConfig;
+ onConfigChange: (config: Partial) => void;
+}
+
+export function ConfigPanel({ config, onConfigChange }: ConfigPanelProps) {
+ const providerHint = useMemo(() => {
+ const current = MODEL_OPTIONS.find((option) => option.value === config.model);
+ return current ? `${current.provider} · ${current.label}` : "";
+ }, [config.model]);
+
+ return (
+
+
+
+ 配置中心
+
+
+
+
+
+ onConfigChange({ apiKey: event.target.value })}
+ />
+
+
+
+ onConfigChange({ baseUrl: event.target.value })}
+ />
+
+
+
+
+ {providerHint &&
{providerHint}
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onConfigChange({ duration: Number(event.target.value) || config.duration })
+ }
+ />
+
+
+
+
+
智能加强 Prompt
+
+ 使用 System Prompt 自动优化用户描述,提高生成质量
+
+
+
onConfigChange({ enhancePrompt: checked })}
+ />
+
+
+
+
生成音频
+
部分模型支持同步生成背景音效
+
+
onConfigChange({ generateAudio: checked })}
+ />
+
+
+
+
自动负向提示
+
+ 自动附加常用负向词(模糊、低质量等)以避免生成缺陷
+
+
+
onConfigChange({ autoApplyNegativePrompt: checked })}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/video-studio/components/prompt/prompt-editor.tsx b/web/video-studio/components/prompt/prompt-editor.tsx
new file mode 100644
index 000000000..7b3693ad4
--- /dev/null
+++ b/web/video-studio/components/prompt/prompt-editor.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useState } from "react";
+import { FileText, Save } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Textarea } from "@/components/ui/textarea";
+import { toast } from "@/components/ui/use-toast";
+import type { PromptTemplate } from "@/types/studio";
+
+interface PromptEditorProps {
+ prompt: string;
+ onPromptChange: (value: string) => void;
+ onSaveTemplate: (template: PromptTemplate) => void;
+}
+
+export function PromptEditor({ prompt, onPromptChange, onSaveTemplate }: PromptEditorProps) {
+ const [templateName, setTemplateName] = useState("");
+ const [templateDescription, setTemplateDescription] = useState("");
+
+ const handleSaveTemplate = () => {
+ if (!templateName.trim()) {
+ toast({
+ title: "请输入模板名称",
+ variant: "destructive"
+ });
+ return;
+ }
+ onSaveTemplate({
+ id: crypto.randomUUID(),
+ name: templateName.trim(),
+ description: templateDescription.trim(),
+ prompt
+ });
+ setTemplateName("");
+ setTemplateDescription("");
+ toast({ title: "模板已保存", description: "可在左侧模板面板中复用" });
+ };
+
+ return (
+
+
+
+ Prompt 编辑器
+
+
+
+
+
+
+ setTemplateName(event.target.value)}
+ />
+
+
+
+
+ );
+}
diff --git a/web/video-studio/components/prompt/prompt-tag-manager.tsx b/web/video-studio/components/prompt/prompt-tag-manager.tsx
new file mode 100644
index 000000000..9e3554aa9
--- /dev/null
+++ b/web/video-studio/components/prompt/prompt-tag-manager.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import {
+ closestCenter,
+ DndContext,
+ DragEndEvent,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ rectSortingStrategy,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable
+} from "@dnd-kit/sortable";
+import { X } from "lucide-react";
+import { CSS } from "@dnd-kit/utilities";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import type { PromptTag } from "@/types/studio";
+
+interface PromptTagManagerProps {
+ tags: PromptTag[];
+ onTagsChange: (tags: PromptTag[]) => void;
+ suggestions?: string[];
+}
+
+export function PromptTagManager({ tags, onTagsChange, suggestions = [] }: PromptTagManagerProps) {
+ const [inputValue, setInputValue] = useState("");
+
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates
+ })
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) {
+ return;
+ }
+ const oldIndex = tags.findIndex((tag) => tag.id === active.id);
+ const newIndex = tags.findIndex((tag) => tag.id === over.id);
+ onTagsChange(arrayMove(tags, oldIndex, newIndex));
+ };
+
+ const addTag = (label: string) => {
+ if (!label.trim()) return;
+ if (tags.some((tag) => tag.label === label.trim())) return;
+ onTagsChange([...tags, { id: crypto.randomUUID(), label: label.trim() }]);
+ setInputValue("");
+ };
+
+ const removeTag = (id: string) => {
+ onTagsChange(tags.filter((tag) => tag.id !== id));
+ };
+
+ const filteredSuggestions = useMemo(
+ () => suggestions.filter((suggestion) => !tags.some((tag) => tag.label === suggestion)),
+ [suggestions, tags]
+ );
+
+ return (
+
+
+ setInputValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ addTag(inputValue);
+ }
+ }}
+ />
+
+
+
+
+ tag.id)} strategy={rectSortingStrategy}>
+
+ {tags.map((tag) => (
+ removeTag(tag.id)} />
+ ))}
+ {tags.length === 0 && (
+ 可通过拖拽调整标签优先级
+ )}
+
+
+
+
+ {filteredSuggestions.length > 0 && (
+
+
推荐标签
+
+ {filteredSuggestions.map((suggestion) => (
+ addTag(suggestion)}
+ >
+ {suggestion}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+interface SortableTagProps {
+ id: string;
+ label: string;
+ onRemove: () => void;
+}
+
+function SortableTag({ id, label, onRemove }: SortableTagProps) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
+
+ return (
+
+ {label}
+
+
+ );
+}
diff --git a/web/video-studio/components/prompt/prompt-template-panel.tsx b/web/video-studio/components/prompt/prompt-template-panel.tsx
new file mode 100644
index 000000000..74c726d44
--- /dev/null
+++ b/web/video-studio/components/prompt/prompt-template-panel.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useRef } from "react";
+import { Download, Library, Upload } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Textarea } from "@/components/ui/textarea";
+import { toast } from "@/components/ui/use-toast";
+import type { PromptTemplate } from "@/types/studio";
+
+interface PromptTemplatePanelProps {
+ templates: PromptTemplate[];
+ onApplyTemplate: (template: PromptTemplate) => void;
+ onTemplatesChange: (templates: PromptTemplate[]) => void;
+}
+
+export function PromptTemplatePanel({
+ templates,
+ onApplyTemplate,
+ onTemplatesChange
+}: PromptTemplatePanelProps) {
+ const fileInputRef = useRef(null);
+
+ const handleExport = () => {
+ const blob = new Blob([JSON.stringify(templates, null, 2)], {
+ type: "application/json"
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `prompt-templates-${Date.now()}.json`;
+ link.click();
+ URL.revokeObjectURL(url);
+ toast({ title: "模板已导出", variant: "success" });
+ };
+
+ const handleImport = async (files: FileList | null) => {
+ if (!files || files.length === 0) return;
+ const file = files[0];
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text) as PromptTemplate[];
+ if (!Array.isArray(data)) {
+ throw new Error("模板格式不正确");
+ }
+ onTemplatesChange([
+ ...templates,
+ ...data.map((item) => ({
+ ...item,
+ id: item.id ?? crypto.randomUUID()
+ }))
+ ]);
+ toast({ title: "模板导入成功", description: `新增 ${data.length} 个模板` });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "导入失败";
+ toast({ title: "模板导入失败", description: message, variant: "destructive" });
+ } finally {
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
+ };
+
+ return (
+
+
+
+
+ Prompt 模板
+
+
+
+
+ handleImport(event.target.files)}
+ />
+
+
+
+
+ {templates.length === 0 ? (
+
+ 保存你的常用 Prompt,或导入 JSON 模板文件快速搭建模板库。
+
+ ) : (
+
+ {templates.map((template) => (
+
+
+ {template.name}
+
+
+ {template.description && (
+
{template.description}
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/web/video-studio/components/status/generation-status.tsx b/web/video-studio/components/status/generation-status.tsx
new file mode 100644
index 000000000..79020203d
--- /dev/null
+++ b/web/video-studio/components/status/generation-status.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import { Loader2, Sparkles } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import { toast } from "@/components/ui/use-toast";
+import type { GenerationPhase, GenerationState } from "@/hooks/use-video-generation";
+
+interface GenerationStatusProps {
+ status: GenerationState;
+ label: string;
+ onCancel: () => void;
+ onReset: () => void;
+}
+
+const STATUS_COLORS: Record = {
+ idle: "bg-slate-100 text-slate-600",
+ submitting: "bg-blue-50 text-blue-600",
+ pending: "bg-blue-50 text-blue-600",
+ running: "bg-blue-100 text-blue-700",
+ completed: "bg-emerald-50 text-emerald-600",
+ failed: "bg-rose-50 text-rose-600"
+};
+
+export function GenerationStatus({ status, label, onCancel, onReset }: GenerationStatusProps) {
+ const showProgress = status.phase === "running" || status.phase === "pending" || status.phase === "submitting";
+ const showAction = status.phase === "running" || status.phase === "pending" || status.phase === "submitting";
+
+ return (
+
+
+
+ 生成状态
+
+
+
+
+ {label}
+
+ {showProgress && (
+
+
+
当前进度:{status.progress}%
+
+ )}
+ {status.phase === "completed" && status.result?.videoUrls.length ? (
+
+
视频生成完成
+
+ {status.result.videoUrls.map((url, index) => (
+ -
+ 版本 {index + 1}
+
+ 查看
+
+
+ ))}
+
+
+ ) : null}
+ {status.phase === "failed" && (
+
+
生成失败
+
{status.error ?? "请检查配置或稍后再试"}
+
+ )}
+
+
+ {showAction ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/web/video-studio/components/ui/badge.tsx b/web/video-studio/components/ui/badge.tsx
new file mode 100644
index 000000000..049f68e54
--- /dev/null
+++ b/web/video-studio/components/ui/badge.tsx
@@ -0,0 +1,38 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition-colors",
+ {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary/10 text-primary",
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
+ outline: "text-muted-foreground",
+ success: "border-transparent bg-emerald-500/10 text-emerald-600",
+ warning: "border-transparent bg-amber-500/10 text-amber-600"
+ }
+ },
+ defaultVariants: {
+ variant: "default"
+ }
+ }
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+const Badge = React.forwardRef(
+ ({ className, variant, ...props }, ref) => (
+
+ )
+);
+Badge.displayName = "Badge";
+
+export { Badge, badgeVariants };
diff --git a/web/video-studio/components/ui/button.tsx b/web/video-studio/components/ui/button.tsx
new file mode 100644
index 000000000..6d973716c
--- /dev/null
+++ b/web/video-studio/components/ui/button.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ link: "text-primary underline-offset-4 hover:underline"
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9"
+ }
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default"
+ }
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/web/video-studio/components/ui/card.tsx b/web/video-studio/components/ui/card.tsx
new file mode 100644
index 000000000..fac922671
--- /dev/null
+++ b/web/video-studio/components/ui/card.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
diff --git a/web/video-studio/components/ui/input.tsx b/web/video-studio/components/ui/input.tsx
new file mode 100644
index 000000000..bfff91ab0
--- /dev/null
+++ b/web/video-studio/components/ui/input.tsx
@@ -0,0 +1,23 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/web/video-studio/components/ui/label.tsx b/web/video-studio/components/ui/label.tsx
new file mode 100644
index 000000000..9695d450d
--- /dev/null
+++ b/web/video-studio/components/ui/label.tsx
@@ -0,0 +1,20 @@
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cn } from "@/lib/utils";
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/web/video-studio/components/ui/progress.tsx b/web/video-studio/components/ui/progress.tsx
new file mode 100644
index 000000000..b53736f3e
--- /dev/null
+++ b/web/video-studio/components/ui/progress.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface ProgressProps extends React.HTMLAttributes {
+ value?: number;
+}
+
+const Progress = React.forwardRef(
+ ({ className, value = 0, ...props }, ref) => (
+
+ )
+);
+Progress.displayName = "Progress";
+
+export { Progress };
diff --git a/web/video-studio/components/ui/select.tsx b/web/video-studio/components/ui/select.tsx
new file mode 100644
index 000000000..c25067389
--- /dev/null
+++ b/web/video-studio/components/ui/select.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator
+};
diff --git a/web/video-studio/components/ui/skeleton.tsx b/web/video-studio/components/ui/skeleton.tsx
new file mode 100644
index 000000000..cb241c5fd
--- /dev/null
+++ b/web/video-studio/components/ui/skeleton.tsx
@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils";
+
+export function Skeleton({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/web/video-studio/components/ui/switch.tsx b/web/video-studio/components/ui/switch.tsx
new file mode 100644
index 000000000..3f56f844f
--- /dev/null
+++ b/web/video-studio/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/web/video-studio/components/ui/textarea.tsx b/web/video-studio/components/ui/textarea.tsx
new file mode 100644
index 000000000..56e5066f0
--- /dev/null
+++ b/web/video-studio/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface TextareaProps extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/web/video-studio/components/ui/toaster.tsx b/web/video-studio/components/ui/toaster.tsx
new file mode 100644
index 000000000..a3c806c7c
--- /dev/null
+++ b/web/video-studio/components/ui/toaster.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+
+import { Toast, useToast } from "@/components/ui/use-toast";
+
+export function Toaster() {
+ const { toasts } = useToast();
+
+ return (
+
+
+ {toasts.map((toast) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/web/video-studio/components/ui/use-toast.ts b/web/video-studio/components/ui/use-toast.ts
new file mode 100644
index 000000000..675c3f61a
--- /dev/null
+++ b/web/video-studio/components/ui/use-toast.ts
@@ -0,0 +1,165 @@
+"use client";
+
+import * as React from "react";
+import { useCallback, useMemo } from "react";
+import { Cross2Icon } from "@radix-ui/react-icons";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+export type ToastActionElement = React.ReactNode;
+
+export interface ToastProps {
+ id: string;
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ action?: ToastActionElement;
+ duration?: number;
+ variant?: "default" | "destructive" | "success";
+}
+
+const TOAST_LIMIT = 5;
+const TOAST_REMOVE_DELAY = 1000;
+
+type ToasterToast = ToastProps & {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+type ToasterState = {
+ toasts: ToasterToast[];
+};
+
+const listeners: Array<(state: ToasterState) => void> = [];
+let toasts: ToasterToast[] = [];
+
+function dispatch(action: ToastAction) {
+ switch (action.type) {
+ case "ADD":
+ toasts = [
+ action.toast,
+ ...toasts.filter((toast) => toast.id !== action.toast.id)
+ ].slice(0, TOAST_LIMIT);
+ break;
+ case "DISMISS":
+ toasts = toasts.map((toast) =>
+ toast.id === action.toastId ? { ...toast, open: false } : toast
+ );
+ break;
+ case "REMOVE":
+ toasts = toasts.filter((toast) => toast.id !== action.toastId);
+ break;
+ }
+ listeners.forEach((listener) => listener({ toasts }));
+}
+
+type ToastAction =
+ | { type: "ADD"; toast: ToasterToast }
+ | { type: "DISMISS"; toastId?: string }
+ | { type: "REMOVE"; toastId: string };
+
+function toast({
+ title,
+ description,
+ action,
+ duration = 3500,
+ variant = "default"
+}: Omit) {
+ const id = Math.random().toString(36).slice(2, 9);
+
+ const onOpenChange = (open: boolean) => {
+ if (!open) {
+ dispatch({ type: "DISMISS", toastId: id });
+ setTimeout(() => dispatch({ type: "REMOVE", toastId: id }), TOAST_REMOVE_DELAY);
+ }
+ };
+
+ const toast: ToasterToast = {
+ id,
+ title,
+ description,
+ action,
+ duration,
+ variant,
+ open: true,
+ onOpenChange
+ };
+
+ dispatch({ type: "ADD", toast });
+
+ if (duration > 0) {
+ setTimeout(() => onOpenChange(false), duration);
+ }
+
+ return id;
+}
+
+function useToast() {
+ const [state, setState] = React.useState({ toasts });
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, []);
+
+ const dismiss = useCallback((toastId?: string) => {
+ dispatch({ type: "DISMISS", toastId });
+ }, []);
+
+ return {
+ ...state,
+ toast,
+ dismiss
+ };
+}
+
+function Toast({
+ open,
+ onOpenChange,
+ title,
+ description,
+ action,
+ variant
+}: ToasterToast) {
+ const background = useMemo(() => {
+ switch (variant) {
+ case "destructive":
+ return "bg-red-500/10 border-red-500/40 text-red-600";
+ case "success":
+ return "bg-emerald-500/10 border-emerald-500/30 text-emerald-600";
+ default:
+ return "bg-white/80 border-slate-200 text-slate-700";
+ }
+ }, [variant]);
+
+ return open ? (
+
+
+ {title &&
{title}
}
+ {description && (
+
{description}
+ )}
+ {action}
+
+
+
+ ) : null;
+}
+
+export { useToast, toast, Toast };
diff --git a/web/video-studio/components/video/reference-image-uploader.tsx b/web/video-studio/components/video/reference-image-uploader.tsx
new file mode 100644
index 000000000..fdac36242
--- /dev/null
+++ b/web/video-studio/components/video/reference-image-uploader.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useMemo } from "react";
+import { ImageIcon, Upload } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { formatBytes } from "@/lib/utils";
+import type { ReferenceImage } from "@/types/studio";
+
+interface ReferenceImageUploaderProps {
+ images: ReferenceImage[];
+ onImagesChange: (images: ReferenceImage[]) => void;
+}
+
+export function ReferenceImageUploader({
+ images,
+ onImagesChange
+}: ReferenceImageUploaderProps) {
+ const totalSize = useMemo(
+ () => images.reduce((acc, curr) => acc + curr.size, 0),
+ [images]
+ );
+
+ const handleFiles = (files: FileList | null) => {
+ if (!files) return;
+ const next = Array.from(files).slice(0, 4 - images.length).map((file) => ({
+ id: crypto.randomUUID(),
+ file,
+ size: file.size,
+ previewUrl: URL.createObjectURL(file)
+ }));
+ onImagesChange([...images, ...next]);
+ };
+
+ const removeImage = (id: string) => {
+ onImagesChange(images.filter((image) => image.id !== id));
+ };
+
+ return (
+
+
+
+ 参考图片
+
+
+
+
+
+
+ 最多上传 4 张参考图片,支持 PNG/JPG
+
+
+
+
+ {images.length > 0 && (
+
+
+ 已上传 {images.length} 张
+ 总大小:{formatBytes(totalSize)}
+
+
+ {images.map((image) => (
+
+

+
+ {image.file.name}
+ {formatBytes(image.size)}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/web/video-studio/components/video/video-result-panel.tsx b/web/video-studio/components/video/video-result-panel.tsx
new file mode 100644
index 000000000..c126c1b9f
--- /dev/null
+++ b/web/video-studio/components/video/video-result-panel.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { VideoIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { GenerationState } from "@/hooks/use-video-generation";
+
+interface VideoResultPanelProps {
+ status: GenerationState;
+}
+
+export function VideoResultPanel({ status }: VideoResultPanelProps) {
+ const hasResult = status.result?.videoUrls?.length;
+
+ return (
+
+
+
+ 生成结果
+
+
+
+ {hasResult ? (
+
+ {status.result?.videoUrls.map((url, index) => (
+
+ ))}
+
+ ) : status.phase === "running" || status.phase === "pending" || status.phase === "submitting" ? (
+
+ {Array.from({ length: 2 }).map((_, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ 生成后将在此处展示视频预览
+
+ )}
+
+
+ );
+}
diff --git a/web/video-studio/components/video/video-template-uploader.tsx b/web/video-studio/components/video/video-template-uploader.tsx
new file mode 100644
index 000000000..c98169dda
--- /dev/null
+++ b/web/video-studio/components/video/video-template-uploader.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useState } from "react";
+import { Upload, Video } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { formatBytes } from "@/lib/utils";
+import type { TemplateVideo } from "@/types/studio";
+
+interface VideoTemplateUploaderProps {
+ templateVideo?: TemplateVideo;
+ onTemplateChange: (video?: TemplateVideo) => void;
+ onInsertPrompt: (details: { name: string; size: number }) => void;
+}
+
+export function VideoTemplateUploader({
+ templateVideo,
+ onTemplateChange,
+ onInsertPrompt
+}: VideoTemplateUploaderProps) {
+ const [isHovering, setIsHovering] = useState(false);
+
+ const handleFile = (file: File) => {
+ const url = URL.createObjectURL(file);
+ const template: TemplateVideo = {
+ id: crypto.randomUUID(),
+ file,
+ url,
+ name: file.name,
+ size: file.size
+ };
+ onTemplateChange(template);
+ };
+
+ return (
+
+
+
+ 视频模板上传
+
+
+
+ setIsHovering(true)}
+ onDragLeave={() => setIsHovering(false)}
+ onDragOver={(event) => {
+ event.preventDefault();
+ setIsHovering(true);
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ const file = event.dataTransfer.files?.[0];
+ if (file) {
+ handleFile(file);
+ }
+ setIsHovering(false);
+ }}
+ >
+
+
+ 拖拽或点击上传视频模板 (MP4, MOV)
+
+
+
+
+ {templateVideo ? (
+
+
+
+
{templateVideo.name}
+
+ {formatBytes(templateVideo.size)}
+
+
+
+
+
+
+
+ ) : (
+
+ 上传模板后可预览并将模板描述写入到 Prompt 中,提高生成一致性。
+
+ )}
+
+
+ );
+}
diff --git a/web/video-studio/hooks/use-studio-config.ts b/web/video-studio/hooks/use-studio-config.ts
new file mode 100644
index 000000000..e8bf187a9
--- /dev/null
+++ b/web/video-studio/hooks/use-studio-config.ts
@@ -0,0 +1,27 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { configService, type StudioConfig, defaultConfig } from "@/lib/config";
+
+export function useStudioConfig() {
+ const [config, setConfig] = useState(defaultConfig);
+ const [isReady, setIsReady] = useState(false);
+
+ useEffect(() => {
+ const initial = configService.load();
+ setConfig(initial);
+ setIsReady(true);
+ const unsubscribe = configService.subscribe(setConfig);
+ return () => unsubscribe();
+ }, []);
+
+ const update = (partial: Partial) => {
+ setConfig((prev) => {
+ const next = { ...prev, ...partial };
+ configService.save(next);
+ return next;
+ });
+ };
+
+ return { config, setConfig: update, isReady };
+}
diff --git a/web/video-studio/hooks/use-video-generation.ts b/web/video-studio/hooks/use-video-generation.ts
new file mode 100644
index 000000000..71c03fd2e
--- /dev/null
+++ b/web/video-studio/hooks/use-video-generation.ts
@@ -0,0 +1,115 @@
+"use client";
+
+import { useCallback, useMemo, useRef, useState } from "react";
+import type { GenerationPayload, GenerationResult } from "@/types/api";
+import type { StudioConfig } from "@/lib/config";
+import { pollGeneration, submitGeneration } from "@/lib/api";
+
+export type GenerationPhase =
+ | "idle"
+ | "submitting"
+ | "pending"
+ | "running"
+ | "completed"
+ | "failed";
+
+export interface GenerationState {
+ phase: GenerationPhase;
+ progress: number;
+ requestId?: string;
+ result?: GenerationResult;
+ error?: string;
+}
+
+export function useVideoGeneration(config: StudioConfig) {
+ const [state, setState] = useState({
+ phase: "idle",
+ progress: 0
+ });
+ const abortRef = useRef(false);
+
+ const reset = useCallback(() => {
+ abortRef.current = false;
+ setState({ phase: "idle", progress: 0 });
+ }, []);
+
+ const generate = useCallback(
+ async (payload: GenerationPayload) => {
+ abortRef.current = false;
+ try {
+ setState({ phase: "submitting", progress: 0 });
+ const response = await submitGeneration(config, payload);
+ if (response.code !== "success") {
+ throw new Error(response.message || "提交任务失败");
+ }
+ const requestId = response.data.request_id;
+ setState({ phase: "pending", progress: 0, requestId });
+
+ const result = await pollGeneration(config, requestId, (update) => {
+ if (abortRef.current) {
+ return;
+ }
+ setState((prev) => ({
+ ...prev,
+ phase: update.status === "IN_PROGRESS" ? "running" : prev.phase,
+ progress: update.progress,
+ result: update
+ }));
+ });
+
+ if (abortRef.current) {
+ return;
+ }
+
+ setState({
+ phase: result.status === "COMPLETED" ? "completed" : "failed",
+ progress: result.progress,
+ requestId,
+ result,
+ error: result.status === "FAILED" ? result.failReason : undefined
+ });
+ } catch (error) {
+ if (abortRef.current) {
+ return;
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ setState({
+ phase: "failed",
+ progress: 0,
+ error: message
+ });
+ }
+ },
+ [config]
+ );
+
+ const cancel = useCallback(() => {
+ abortRef.current = true;
+ setState((prev) => ({ ...prev, phase: "idle" }));
+ }, []);
+
+ const statusLabel = useMemo(() => {
+ switch (state.phase) {
+ case "submitting":
+ return "提交任务中...";
+ case "pending":
+ return "任务排队中";
+ case "running":
+ return `生成中 ${state.progress}%`;
+ case "completed":
+ return "生成完成";
+ case "failed":
+ return state.error ? `生成失败:${state.error}` : "生成失败";
+ default:
+ return "等待生成";
+ }
+ }, [state]);
+
+ return {
+ state,
+ generate,
+ reset,
+ cancel,
+ statusLabel
+ };
+}
diff --git a/web/video-studio/lib/api.ts b/web/video-studio/lib/api.ts
new file mode 100644
index 000000000..7583bf06b
--- /dev/null
+++ b/web/video-studio/lib/api.ts
@@ -0,0 +1,85 @@
+import { sleep } from "@/lib/utils";
+import type { StudioConfig } from "@/lib/config";
+import type {
+ GenerationPayload,
+ GenerationResult,
+ TaskResponse
+} from "@/types/api";
+
+async function request(
+ input: RequestInfo,
+ init: RequestInit,
+ apiKey: string
+): Promise {
+ const headers = new Headers(init.headers);
+ if (apiKey) {
+ headers.set("Authorization", `Bearer ${apiKey}`);
+ }
+
+ const response = await fetch(input, {
+ ...init,
+ headers
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(text || response.statusText);
+ }
+
+ return (await response.json()) as T;
+}
+
+export async function submitGeneration(
+ config: StudioConfig,
+ payload: GenerationPayload
+): Promise {
+ const url = `${config.baseUrl.replace(/\/$/, "")}/v1/tasks/generations`;
+ return request(
+ url,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify(payload)
+ },
+ config.apiKey
+ );
+}
+
+export async function pollGeneration(
+ config: StudioConfig,
+ requestId: string,
+ onUpdate?: (result: GenerationResult) => void
+): Promise {
+ const endpoint = `${config.baseUrl.replace(/\/$/, "")}/v1/tasks/generations/${requestId}`;
+
+ await sleep(2000);
+
+ while (true) {
+ const response = await request(
+ endpoint,
+ { method: "GET" },
+ config.apiKey
+ );
+
+ const { data } = response;
+ const progress = parseInt(data.progress?.replace("%", "") ?? "0", 10);
+ const result: GenerationResult = {
+ requestId: data.request_id,
+ status: data.status,
+ failReason: data.fail_reason,
+ progress: Number.isNaN(progress) ? 0 : progress,
+ videoUrls: data.data?.video_urls ?? [],
+ raw: data
+ };
+
+ onUpdate?.(result);
+
+ if (data.status === "COMPLETED" || data.status === "FAILED") {
+ return result;
+ }
+
+ await sleep(1000);
+ }
+}
diff --git a/web/video-studio/lib/config.ts b/web/video-studio/lib/config.ts
new file mode 100644
index 000000000..4b3bdb4b5
--- /dev/null
+++ b/web/video-studio/lib/config.ts
@@ -0,0 +1,95 @@
+export type VideoModel =
+ | "google/veo3"
+ | "google/veo2"
+ | "openai/sora"
+ | "bytedance/doubao-seedance-1.0-pro"
+ | "bytedance/doubao-seedance-1.0-lite-t2v"
+ | "bytedance/doubao-seedance-1.0-lite-i2v"
+ | "bytedance/jimeng_ti2v_v30_pro"
+ | "bytedance/jimeng_t2v_v30"
+ | "bytedance/jimeng_i2v_first_v30"
+ | "bytedance/jimeng_i2v_first_tail_v30"
+ | "minimax/t2v-01-director"
+ | "minimax/i2v-01-director"
+ | "minimax/i2v-01-live"
+ | "minimax/s2v-01"
+ | "vidu/vidu-1.5"
+ | "vidu/vidu-2.0"
+ | "vidu/vidu-q1";
+
+export interface StudioConfig {
+ apiKey: string;
+ baseUrl: string;
+ model: VideoModel;
+ aspectRatio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9";
+ duration: number;
+ resolution: "480p" | "720p" | "1080p" | "360p";
+ enhancePrompt: boolean;
+ generateAudio: boolean;
+ autoApplyNegativePrompt: boolean;
+}
+
+const STORAGE_KEY = "video-studio-config";
+
+const defaultConfig: StudioConfig = {
+ apiKey: "",
+ baseUrl: "https://router.shengsuanyun.com/api",
+ model: "google/veo3",
+ aspectRatio: "16:9",
+ duration: 8,
+ resolution: "1080p",
+ enhancePrompt: true,
+ generateAudio: false,
+ autoApplyNegativePrompt: true
+};
+
+export class ConfigService {
+ private subscribers = new Set<(config: StudioConfig) => void>();
+
+ load(): StudioConfig {
+ if (typeof window === "undefined") {
+ return defaultConfig;
+ }
+
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return defaultConfig;
+ }
+ const parsed = JSON.parse(raw) as Partial;
+ return { ...defaultConfig, ...parsed };
+ } catch (error) {
+ console.error("Failed to parse stored config", error);
+ return defaultConfig;
+ }
+ }
+
+ save(config: StudioConfig) {
+ if (typeof window === "undefined") {
+ return;
+ }
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
+ this.notify(config);
+ }
+
+ update(partial: Partial) {
+ const next = { ...this.load(), ...partial };
+ this.save(next);
+ }
+
+ subscribe(handler: (config: StudioConfig) => void) {
+ this.subscribers.add(handler);
+ return () => {
+ this.subscribers.delete(handler);
+ };
+ }
+
+ private notify(config: StudioConfig) {
+ for (const subscriber of this.subscribers) {
+ subscriber(config);
+ }
+ }
+}
+
+export const configService = new ConfigService();
+export { defaultConfig };
diff --git a/web/video-studio/lib/utils.ts b/web/video-studio/lib/utils.ts
new file mode 100644
index 000000000..034417e3d
--- /dev/null
+++ b/web/video-studio/lib/utils.ts
@@ -0,0 +1,21 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return "0 B";
+ }
+ const units = ["B", "KB", "MB", "GB"] as const;
+ const index = Math.min(
+ Math.floor(Math.log(bytes) / Math.log(1024)),
+ units.length - 1
+ );
+ const value = bytes / Math.pow(1024, index);
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
+}
+
+export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/web/video-studio/next-env.d.ts b/web/video-studio/next-env.d.ts
new file mode 100644
index 000000000..4f11a03dc
--- /dev/null
+++ b/web/video-studio/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/web/video-studio/next.config.mjs b/web/video-studio/next.config.mjs
new file mode 100644
index 000000000..2f1096e5e
--- /dev/null
+++ b/web/video-studio/next.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: {
+ typedRoutes: true,
+ serverActions: true
+ }
+};
+
+export default nextConfig;
diff --git a/web/video-studio/package.json b/web/video-studio/package.json
new file mode 100644
index 000000000..2d5dca5af
--- /dev/null
+++ b/web/video-studio/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "video-studio",
+ "private": true,
+ "version": "0.1.0",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@dnd-kit/core": "^6.1.0",
+ "@dnd-kit/modifiers": "^6.1.0",
+ "@dnd-kit/sortable": "^7.0.0",
+ "autoprefixer": "^10.4.18",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "framer-motion": "^11.0.8",
+ "lucide-react": "^0.398.0",
+ "next": "^14.1.0",
+ "postcss": "^8.4.35",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "tailwind-merge": "^2.2.1",
+ "tailwindcss": "^3.4.3",
+ "tailwindcss-animate": "^1.0.7",
+ "@radix-ui/react-slot": "^1.0.4",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-switch": "^2.0.1",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-icons": "^1.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.11.25",
+ "@types/react": "^18.2.66",
+ "@types/react-dom": "^18.2.22",
+ "typescript": "^5.4.2"
+ }
+}
\ No newline at end of file
diff --git a/web/video-studio/postcss.config.js b/web/video-studio/postcss.config.js
new file mode 100644
index 000000000..5cbc2c7d8
--- /dev/null
+++ b/web/video-studio/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/web/video-studio/tailwind.config.ts b/web/video-studio/tailwind.config.ts
new file mode 100644
index 000000000..9815d8467
--- /dev/null
+++ b/web/video-studio/tailwind.config.ts
@@ -0,0 +1,62 @@
+import type { Config } from "tailwindcss";
+import { fontFamily } from "tailwindcss/defaultTheme";
+
+const config: Config = {
+ darkMode: ["class"],
+ content: [
+ "./pages/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./app/**/*.{ts,tsx}",
+ "./src/**/*.{ts,tsx}"
+ ],
+ theme: {
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "#3b82f6",
+ foreground: "#ffffff"
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))"
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))"
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))"
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))"
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))"
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))"
+ }
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)"
+ },
+ fontFamily: {
+ sans: ["Inter", ...fontFamily.sans]
+ }
+ }
+ },
+ plugins: [require("tailwindcss-animate")]
+};
+
+export default config;
diff --git a/web/video-studio/tsconfig.json b/web/video-studio/tsconfig.json
new file mode 100644
index 000000000..60cbab2f7
--- /dev/null
+++ b/web/video-studio/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/components/*": ["./components/*"],
+ "@/hooks/*": ["./hooks/*"],
+ "@/lib/*": ["./lib/*"],
+ "@/types/*": ["./types/*"]
+ },
+ "plugins": [{ "name": "next" }]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/web/video-studio/types/api.ts b/web/video-studio/types/api.ts
new file mode 100644
index 000000000..b1636ccd5
--- /dev/null
+++ b/web/video-studio/types/api.ts
@@ -0,0 +1,63 @@
+export interface TaskData {
+ request_id: string;
+ task_id: string;
+ action?: string;
+ status: TaskStatus;
+ fail_reason?: string;
+ submit_time?: number;
+ start_time?: number;
+ finish_time?: number;
+ progress?: string;
+ data?: {
+ video_urls?: string[];
+ image_urls?: string[];
+ text?: string;
+ progress?: number;
+ };
+}
+
+export interface TaskResponse {
+ code: string;
+ message: string;
+ data: TaskData;
+}
+
+export type TaskStatus =
+ | "SUBMITTING"
+ | "PENDING"
+ | "SUBMITTED"
+ | "QUEUED"
+ | "IN_PROGRESS"
+ | "COMPLETED"
+ | "FAILED"
+ | "CANCELLED"
+ | "TIMEOUT"
+ | "UNKNOWN";
+
+export interface GenerationResult {
+ requestId: string;
+ status: TaskStatus;
+ progress: number;
+ videoUrls: string[];
+ failReason?: string;
+ raw: TaskData;
+}
+
+export interface GenerationPayload {
+ model: string;
+ prompt: string;
+ negative_prompt?: string;
+ aspect_ratio?: string;
+ duration_seconds?: number;
+ resolution?: string;
+ enhance_prompt?: boolean;
+ generate_audio?: boolean;
+ person_generation?: string;
+ sample_count?: number;
+ seed?: number;
+ storage_uri?: string;
+ image?: string;
+ last_frame?: string;
+ image_list?: Array<{ image: string; role?: string }>;
+ template_video?: string;
+}
diff --git a/web/video-studio/types/studio.ts b/web/video-studio/types/studio.ts
new file mode 100644
index 000000000..3401edf34
--- /dev/null
+++ b/web/video-studio/types/studio.ts
@@ -0,0 +1,26 @@
+export interface PromptTemplate {
+ id: string;
+ name: string;
+ description?: string;
+ prompt: string;
+}
+
+export interface PromptTag {
+ id: string;
+ label: string;
+}
+
+export interface ReferenceImage {
+ id: string;
+ file: File;
+ previewUrl: string;
+ size: number;
+}
+
+export interface TemplateVideo {
+ id: string;
+ file: File;
+ url: string;
+ name: string;
+ size: number;
+}