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 编辑器 + + + +