Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions web/video-studio/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions web/video-studio/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="zh-CN" suppressHydrationWarning>
<body className="min-h-screen bg-background text-foreground">
{children}
<Toaster />
</body>
</html>
);
}
227 changes: 227 additions & 0 deletions web/video-studio/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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<PromptTag[]>(
RECOMMENDED_TAGS.slice(0, 3).map((label) => ({ id: crypto.randomUUID(), label }))
);
const [templates, setTemplates] = useState<PromptTemplate[]>(DEFAULT_TEMPLATES);
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const [templateVideo, setTemplateVideo] = useState<TemplateVideo | undefined>();

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 (
<motion.main
className="mx-auto flex min-h-screen max-w-[1440px] flex-col gap-6 px-6 py-8"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 text-primary">
<Sparkles className="h-6 w-6" />
<h1 className="text-2xl font-semibold tracking-tight">超好用的 AI 视频生成工作室</h1>
</div>
<p className="text-sm text-muted-foreground">
通过 Prompt、模板与参考图快速生成高质量视频。支持 Veo、Sora、豆包、即梦、MiniMax、Vidu 等多模型异步任务。
</p>
</div>

<div className="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)_360px]">
<div className="space-y-4">
<VideoTemplateUploader
templateVideo={templateVideo}
onTemplateChange={setTemplateVideo}
onInsertPrompt={handleTemplateVideoInsert}
/>
<ReferenceImageUploader images={referenceImages} onImagesChange={setReferenceImages} />
<PromptTemplatePanel
templates={templates}
onApplyTemplate={handleApplyTemplate}
onTemplatesChange={setTemplates}
/>
</div>

<div className="space-y-4">
<PromptEditor
prompt={prompt}
onPromptChange={setPrompt}
onSaveTemplate={(template) => setTemplates((prev) => [template, ...prev])}
/>
<Card className="border-primary/10 shadow-lg">
<CardHeader>
<CardTitle className="text-primary">Prompt 标签管理</CardTitle>
</CardHeader>
<CardContent>
<PromptTagManager tags={tags} onTagsChange={setTags} suggestions={RECOMMENDED_TAGS} />
</CardContent>
</Card>
<Card className="border-primary/10 bg-primary/5 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-primary">一键生成视频</div>
<p className="text-xs text-muted-foreground">
系统将自动包装 System Prompt、提交任务并轮询任务进度。
</p>
</div>
<Button
type="button"
size="lg"
className="w-full md:w-auto"
onClick={handleGenerate}
disabled={isGenerating}
>
开始生成
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
<VideoResultPanel status={generation.state} />
</div>

<div className="space-y-4">
<ConfigPanel config={config} onConfigChange={setConfig} />
<GenerationStatus
status={generation.state}
label={generation.statusLabel}
onCancel={generation.cancel}
onReset={generation.reset}
/>
</div>
</div>
</motion.main>
);
}
Loading