版本: v1.0
日期: 2026-05-29
状态: 设计阶段
用户输入一段文本(故事、新闻、教程、文案),系统自动生成一段完整视频,包含:
| 用户类型 | 典型场景 | 视频时长 | 频率 |
|---|---|---|---|
| 内容创作者 | 小说转视频发抖音/快手 | 1-5分钟 | 高频 |
| 自媒体运营 | 新闻/干货转短视频 | 30秒-2分钟 | 高频 |
| 教育培训 | 教材/教程转教学视频 | 5-30分钟 | 中频 |
| 企业 | 产品介绍/宣传视频 | 1-3分钟 | 低频 |
输入文本
│
▼
┌──────────────────────────────────────────────────────────────┐
│ 第一层:文本解析(Scripting Layer) │
│ LLM 理解文本 → 规划分镜 → 输出结构化脚本 JSON │
│ 耗时: 3-10s │
└──────────────────────────┬───────────────────────────────────┘
│ 分镜脚本 (JSON)
▼
┌──────────────────────────────────────────────────────────────┐
│ 第二层:素材生成(Asset Generation Layer) │
│ 每个分镜并行生成:图片(T2I) + 配音(TTS) + 动态视频(I2V) │
│ 耗时: 每个分镜 5-30s(并行后总耗时 ≈ 最慢的那个) │
└──────────────────────────┬───────────────────────────────────┘
│ 素材文件 (图片/音频/视频片段)
▼
┌──────────────────────────────────────────────────────────────┐
│ 第三层:视频合成(Composition Layer) │
│ FFmpeg 编排:拼接片段 + 叠加音频 + 添加字幕 + 转场 + BGM │
│ 耗时: 视频时长的 0.5-2x │
└──────────────────────────┬───────────────────────────────────┘
│ .mp4 文件
▼
┌──────────────────────────────────────────────────────────────┐
│ 第四层:输出交付(Delivery Layer) │
│ 上传 OSS → 生成 CDN 链接 → 通知用户 │
│ 耗时: 数秒 │
└──────────────────────────────────────────────────────────────┘
端到端耗时预估(3 分钟视频,10 个分镜):
| 阶段 | 预览模式(512p) | 高清模式(1080p) |
|---|---|---|
| 文本解析 | 5s | 10s |
| 素材生成 | 30s | 120s |
| 视频合成 | 60s | 180s |
| 输出交付 | 5s | 10s |
| 总计 | ~100s | ~320s |
原始文本
│
├── (1) 文本预处理
│ ├── 分段(按段落/话题自动切分)
│ ├── 字数统计、预估总时长
│ └── 语言检测
│
├── (2) 核心 LLM 调用(Prompt 工程关键)
│ ├── 内容分析:主题、类型、情绪基调
│ ├── 角色提取:名称、性别、年龄、外貌、性格
│ ├── 场景提取:时间、地点、季节、氛围、色调
│ └── 分镜拆解:每个分镜的详细参数
│
└── (3) 结构化校验
├── JSON Schema 校验
├── 时长合理性检查(总时长 = Σ各分镜时长)
└── 兜底处理(LLM 输出异常时的降级方案)
// SceneScript - 完整分镜脚本
interface VideoScript {
scriptId: string;
title: string;
textOriginal: string; // 用户原始文本
textCharCount: number;
estimatedDuration: number; // 预估总时长(秒)
// 全局设定
globalStyle: {
genre: 'story' | 'news' | 'tutorial' | 'marketing' | 'poetry';
visualStyle: string; // 如 "中国水墨画风"、"3D皮克斯风"
colorPalette: string[]; // 主色调
aspectRatio: '16:9' | '9:16' | '1:1'; // 横屏/竖屏/方屏
resolution: '720p' | '1080p' | '4K';
};
// 角色设定(贯穿全片)
characters: Character[];
// 分镜列表
scenes: Scene[];
// TTS 配置
ttsConfig: {
provider: 'minimax' | 'aliyun' | 'volcano';
voiceId: string;
speed: number; // 0.5 - 2.0
volume: number; // 0 - 1
};
// BGM 配置
bgmConfig: {
style: string; // 如 "史诗管弦"、"轻快电子"、"中国古风"
tempo: 'slow' | 'medium' | 'fast';
volumeRatio: number; // 相对于旁白的音量,0.1 - 0.5
};
}
interface Character {
id: string;
name: string;
gender: 'male' | 'female';
ageRange: string;
appearance: string; // 外貌描述(用于生成一致性画面)
personality: string;
referenceImageUrl?: string; // 用户上传的参考图
}
interface Scene {
id: number; // 分镜序号 1,2,3...
duration: number; // 时长(秒)
// 画面相关
imagePrompt: string; // 完整的 T2I prompt(英文)
imagePromptZh: string; // 中文版 prompt(供用户查看和编辑)
negativePrompt: string; // 反向 prompt
charactersInScene: string[]; // 此分镜出现的角色 ID
sceneDescription: string; // 场景描述
// 镜头相关
cameraMotion: CameraMotion; // 镜头运动
composition: string; // 构图(如 "三分法"、"中心对称")
// 配音相关
narration: string; // 旁白文本
narrationStyle: string; // 朗读风格描述
// 转场
transitionIn: Transition; // 入场转场
transitionOut: Transition; // 出场转场
// 情绪
mood: string; // 情绪标签
intensity: number; // 情绪强度 1-10
}
type CameraMotion =
| { type: 'static' }
| { type: 'zoom'; direction: 'in' | 'out'; speed: 'slow' | 'medium' | 'fast' }
| { type: 'pan'; direction: 'left' | 'right' | 'up' | 'down'; speed: 'slow' | 'medium' | 'fast' }
| { type: 'tilt'; direction: 'up' | 'down' }
| { type: 'track'; direction: 'left' | 'right' | 'forward' | 'backward' };
type Transition =
| { type: 'cut' }
| { type: 'fade'; duration: number }
| { type: 'dissolve'; duration: number }
| { type: 'wipe'; direction: 'left' | 'right' | 'up' | 'down'; duration: number }
| { type: 'slide'; direction: 'left' | 'right' | 'up' | 'down'; duration: number };
你是一个专业的视频导演和分镜师。请将以下文本转化为视频分镜脚本。
## 全局设定
- 视频类型:[根据文本自动判断:故事/新闻/教程/营销]
- 视觉风格:[根据文本主题推荐]
- 画面比例:16:9(横屏)
- 目标总时长:[根据字数估算]
## 角色设定
从文本中提取所有角色,描述其外貌特征(用于保证画面一致性)。
## 分镜要求
1. 每个分镜时长 5-15 秒
2. 画面描述用英文(供 AI 生图),要详细描述:主体、动作、背景、光线、色调、构图
3. 旁白文本要口语化,适合朗读
4. 镜头运动要配合情节节奏
5. 转场要自然,高潮部分可以切换更快
## 输出格式
严格按以下 JSON 格式输出:
{
"title": "视频标题",
"genre": "...",
"visualStyle": "...",
"colorPalette": ["#1a1a2e", "#e94560"],
"characters": [{ "id": "c1", "name": "...", ... }],
"scenes": [
{
"id": 1,
"duration": 8,
"imagePrompt": "cinematic shot of..., 8K, photorealistic",
"narration": "旁白文本...",
"mood": "神秘",
"cameraMotion": {"type":"slow_zoom","direction":"in"},
"transitionIn": {"type":"fade","duration":1}
}
]
}
## 输入文本
{text_content}
| 组件 | 方案 | 原因 |
|---|---|---|
| LLM | Claude 3.5 Sonnet / GPT-4o | 结构化输出能力强 |
| 备选 LLM | 阿里百炼 Qwen-Max | 成本低,中文理解好 |
| Prompt 管理 | LangSmith / 自建模板系统 | 版本化 prompt,A/B 测试 |
| 输出校验 | Zod Schema + 自定义规则 | 保证下游不收到脏数据 |
┌─────────────────────┐
│ BullMQ 任务队列 │
│ (Redis-backed) │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Image Worker │ │ Audio Worker │ │ Video Worker │
│ (T2I Pool) │ │ (TTS Pool) │ │ (I2V Pool) │
│ concurrency=4│ │ concurrency=4│ │ concurrency=2│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐
│ ProviderA │ │ ProviderA │ │ ProviderA │
│ (主) │ │ (主) │ │ (主) │
├──────────────┤ ├──────────────┤ ├──────────────┤
│ ProviderB │ │ ProviderB │ │ ProviderB │
│ (备用/failover)│ │ (备用/failover)│ │ (备用/failover)│
└──────────────┘ └──────────────┘ └──────────────┘
interface GenerationProgress {
taskId: string;
stage: 'scripting' | 'generating' | 'composing' | 'done' | 'failed';
progress: number; // 0-100
stageDetail: {
current: number;
total: number;
label: string; // "正在生成第 3/10 个画面..."
};
estimatedRemaining: number; // 秒
error?: string;
}
进度推送方式:
GET /api/video/:id/progress — 每 2 秒请求一次ws://host/ws/video/:id — 实时推送(推荐)前端效果:
[████████░░░░░░░░] 45% 正在生成第 5/10 个画面...
├── ✅ 分镜脚本已生成 (3s)
├── ⏳ 画面生成中... (5/10)
├── ⬜ 配音生成 (等待中)
├── ⬜ 视频合成 (等待中)
└── 预计剩余: 2 分 30 秒
| 方案 | API 服务 | 单张成本 | 速度 | 质量 | 中文理解 |
|---|---|---|---|---|---|
| A1 | 通义万相 (阿里) | ¥0.06-0.12 | 3-8s | ⭐⭐⭐⭐ | ✅ 原生支持 |
| A2 | Stable Diffusion (自部署) | ¥0.01 | 5-15s | ⭐⭐⭐ | 需英文 |
| A3 | Midjourney API | ¥0.08-0.15 | 20-60s | ⭐⭐⭐⭐⭐ | 需英文 |
| A4 | DALL-E 3 (OpenAI) | ¥0.30-0.60 | 5-15s | ⭐⭐⭐⭐⭐ | 支持 |
| A5 | 即梦 (字节) | ¥0.04-0.08 | 3-5s | ⭐⭐⭐⭐ | ✅ 原生支持 |
推荐方案:通义万相 主 + 即梦 备用(成本低、中文友好、速度快)
这是最核心的难点。同一个角色在不同分镜里要保持脸一致。
方案一:IP-Adapter + FaceID (推荐)
用户参考图 (或第一帧生成的角色图)
│
▼
IP-Adapter FaceID 提取面部特征向量
│
▼
每个分镜的 T2I 请求附带特征向量作为 condition
│
▼
生成的所有图片保持面部一致
实现路径:ComfyUI + IP-Adapter-FaceID 工作流(自部署)
方案二:GPT-4o 验证 + 重试
生成第 N 个分镜图片
│
▼
GPT-4o 对比:这张图里的角色和前面一致吗?
├── 一致 → 通过
└── 不一致 → 调整 prompt → 重新生成(最多 3 次)
方案三:Seed 锁定 + LoRA
预训练角色的 LoRA 模型,所有分镜共用同一个 seed 和 LoRA。
当前项目已集成 3 家 TTS 服务,可直接复用:
| 服务商 | 音色数 | 单字成本 | 中文质量 | 感情表达 |
|---|---|---|---|---|
| MiniMax | 30+ | ¥0.015/字 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 阿里百炼 | 50+ | ¥0.01/字 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 火山引擎 | 20+ | ¥0.012/字 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
选择策略:
音色一致性:全片使用同一个 voiceId,无需额外处理。
async function generateNarration(scenes: Scene[]): Promise<AudioSegment[]> {
const results: AudioSegment[] = [];
for (const scene of scenes) {
const audio = await ttsProvider.synthesize({
text: scene.narration,
voiceId: ttsConfig.voiceId,
speed: scene.mood === '紧张' ? 1.2 : 1.0,
format: 'mp3',
});
// 根据实际音频时长校准分镜时长
scene.duration = audio.duration;
results.push({
sceneId: scene.id,
audioUrl: await uploadToOss(audio.buffer),
duration: audio.duration,
text: scene.narration,
});
}
return results;
}
如果预算允许,可以在 MVP 之后加入动态效果:
| 方案 | API | 单秒成本 | 速度 | 效果 |
|---|---|---|---|---|
| 可灵 (快手) | 国内 | ¥0.50-1.00 | 60s/秒 | ⭐⭐⭐⭐⭐ |
| 即梦 (字节) | 国内 | ¥0.30-0.60 | 30s/秒 | ⭐⭐⭐⭐ |
| Runway Gen-3 | 海外 | ¥0.80-1.50 | 60s/秒 | ⭐⭐⭐⭐⭐ |
| SVD (开源) | 自部署 | ¥0.01 | 120s/秒 | ⭐⭐⭐ |
MVP 替代方案:Ken Burns 效果
不动态生成视频,只对静态图片应用 FFmpeg 的缩放/平移效果:
# Ken Burns: 缓慢放大 + 平移
ffmpeg -loop 1 -i scene1.png \
-vf "zoompan=z='min(zoom+0.0015,1.5)':d=250:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1920x1080" \
-t 8 -c:v libx264 scene1_animated.mp4
效果类似纪录片风格,成本几乎为零,对于 MVP 完全够用。
所有素材就绪
│
├── (1) 图片转视频片段(Ken Burns 效果)
├── (2) 视频片段 + 配音音频 对齐
├── (3) 各分镜拼接 + 转场效果
├── (4) 叠加字幕(SRT/ASS)
├── (5) 混合背景音乐
├── (6) 添加片头片尾(可选)
└── (7) 编码输出
│
▼
output.mp4
class FFmpegComposer {
/**
* 根据分镜脚本生成 FFmpeg 命令
*/
buildCommand(params: ComposeParams): string {
const { scenes, bgmUrl, watermarkUrl, outputPath, resolution } = params;
// 构建 filter_complex
const filters: string[] = [];
const inputs: string[] = [];
let inputIndex = 0;
let videoStreams = '';
let audioStreams = '';
for (const scene of scenes) {
// 输入:图片 + 音频
inputs.push(`-loop 1 -t ${scene.duration} -i ${scene.imagePath}`);
inputs.push(`-i ${scene.audioPath}`);
const vIdx = inputIndex;
const aIdx = inputIndex + 1;
inputIndex += 2;
// Ken Burns 效果
filters.push(
`[${vIdx}:v]zoompan=z='min(zoom+0.0015,1.5)':d=1:` +
`s=${resolution.width}x${resolution.height}:` +
`x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'[v${scene.id}]`
);
videoStreams += `[v${scene.id}]`;
audioStreams += `[${aIdx}:a]`;
}
// 视频拼接
const concatN = scenes.length;
filters.push(`${videoStreams}concat=n=${concatN}:v=1:a=0 [vout]`);
filters.push(`${audioStreams}concat=n=${concatN}:v=0:a=1 [aout]`);
// 字幕叠加
let currentTime = 0;
for (const scene of scenes) {
const subtitleText = this.escapeText(scene.narration);
filters.push(
`[vout]drawtext=text='${subtitleText}':` +
`fontfile=/usr/share/fonts/NotoSansSC-Regular.ttf:` +
`fontsize=24:fontcolor=white:borderw=2:bordercolor=black@0.5:` +
`x=(w-text_w)/2:y=h-120:` +
`enable='between(t,${currentTime},${currentTime + scene.duration})'[vsub]`
);
currentTime += scene.duration;
}
// BGM 混合
inputs.push(`-i ${bgmUrl}`);
const bgmIdx = inputIndex;
const totalDuration = scenes.reduce((sum, s) => sum + s.duration, 0);
filters.push(
`[aout][${bgmIdx}:a]amix=inputs=2:duration=first:weights='1 0.3' [afinal]`
);
// 构建命令
const filterComplex = filters.join('; ');
return [
...inputs,
`-filter_complex "${filterComplex}"`,
`-map "[vsub]" -map "[afinal]"`,
`-c:v libx264 -preset medium -crf 20`,
`-c:a aac -b:a 192k`,
`-pix_fmt yuv420p`,
`-movflags +faststart`,
`-y ${outputPath}`,
].join(' ');
}
private escapeText(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/:/g, '\\:')
.replace(/,/g, '\\,');
}
}
interface SubtitleLine {
index: number;
startTime: number; // 秒
endTime: number; // 秒
text: string;
}
/**
* 从分镜脚本生成 SRT 字幕文件
*/
function generateSRT(scenes: Scene[], audioSegments: AudioSegment[]): string {
let srt = '';
let index = 1;
let currentTime = 0;
for (const scene of scenes) {
const words = scene.narration.split('');
const charsPerSecond = words.length / scene.duration;
// 每 15-20 字一行
const chunkSize = 18;
let pos = 0;
while (pos < words.length) {
const chunk = words.slice(pos, pos + chunkSize).join('');
const chunkDuration = chunk.length / charsPerSecond;
srt += `${index}\n`;
srt += `${this.formatSrtTime(currentTime)} --> ${this.formatSrtTime(currentTime + chunkDuration)}\n`;
srt += `${chunk}\n\n`;
currentTime += chunkDuration;
index++;
pos += chunkSize;
}
}
return srt;
}
function formatSrtTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${pad(h)}:${pad(m)}:${pad(s)},${String(ms).padEnd(3, '0')}`;
}
| 优化策略 | 效果 |
|---|---|
| GPU 编码 (NVENC) | 合成速度 3-5x 提升 |
| 分段合成 + 最后拼接 | 内存可控,支持长视频 |
| 预览模式 512p | 比 1080p 快 4x |
| 复用缓存 | 同一图片多个分镜只生成一次 |
class VideoDeliveryService {
async deliver(taskId: string, videoPath: string) {
// 1. 上传到 OSS
const ossKey = `videos/${taskId}/output_${Date.now()}.mp4`;
await this.oss.upload(videoPath, ossKey, {
headers: { 'Content-Type': 'video/mp4' },
});
// 2. 生成 CDN 签名 URL
const cdnUrl = this.oss.generateSignedUrl(ossKey, {
expires: 7 * 24 * 3600, // 7 天有效期
});
// 3. 生成封面图
const coverKey = await this.generateCover(videoPath);
// 4. 更新数据库
await this.db.videoTask.update(taskId, {
status: 'done',
videoUrl: cdnUrl,
coverUrl: coverKey,
fileSize: fs.statSync(videoPath).size,
completedAt: new Date(),
});
// 5. 推送通知
await this.notification.send(taskId, {
type: 'video_completed',
title: '视频生成完成',
videoUrl: cdnUrl,
});
// 6. 清理本地文件(保留 1 小时后删除)
setTimeout(() => fs.unlinkSync(videoPath), 3600000);
}
}
┌─────────────────────────────────────────────────────────────┐
│ Nginx (反向代理 + HTTPS) │
└──────────────┬──────────────────────────────┬───────────────┘
│ │
┌──────────────▼──────────┐ ┌────────────▼──────────────┐
│ Nest.js API Server │ │ Frontend (Vue/UniApp) │
│ (主要业务逻辑) │ │ │
│ - 用户认证 │ │ - 文本输入页 │
│ - 任务创建/查询 │ │ - 分镜编辑页 │
│ - 支付/订阅 │ │ - 进度展示页 │
│ - 素材管理 │ │ - 视频播放页 │
└──────────────┬──────────┘ └──────────────────────────────┘
│
┌──────────────▼─────────────────────────────────────────────┐
│ BullMQ Task Queue (Redis) │
│ │
│ ┌───────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Scripting │ │ Asset Gen │ │ Composition │ │
│ │ Workers (x2) │ │ Workers (x4) │ │ Workers (x2) │ │
│ │ │ │ │ │ (需 GPU 加速) │ │
│ │ CPU: 2核 │ │ CPU: 2核 │ │ GPU: T4 x1 │ │
│ │ Mem: 4GB │ │ Mem: 8GB │ │ Mem: 16GB │ │
│ └───────────────┘ └──────────────┘ └─────────────────┘ │
└──────────────┬──────────────────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────────────────┐
│ Storage Layer │
│ ┌──────────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ MySQL (任务/用户) │ │ Redis (队列 │ │ OSS (素材/视频)│ │
│ │ │ │ /缓存/进度) │ │ │ │
│ └──────────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
// === Scripting Worker ===
@Processor('scripting')
class ScriptingWorker {
@Process('generate')
async generateScript(job: Job<{ text: string; config: ScriptConfig }>) {
const { text, config } = job.data;
// 更新进度
await job.updateProgress({ stage: 'scripting', progress: 0 });
// 调用 LLM 生成分镜
const script = await this.llmService.generateScript(text, config);
await job.updateProgress({ stage: 'scripting', progress: 100 });
// 创建素材生成子任务
for (const scene of script.scenes) {
await this.assetQueue.add('generateImage', { scene, scriptId: script.id });
await this.assetQueue.add('generateAudio', { scene, scriptId: script.id });
}
return script;
}
}
// === Asset Worker ===
@Processor('asset-generation')
class AssetWorker {
@Process('generateImage')
async generateImage(job: Job<{ scene: Scene; scriptId: string }>) {
const { scene } = job.data;
// 重试机制 + provider failover
let image: Buffer;
const providers = ['tongyi', 'jimeng', 'openai'];
for (const provider of providers) {
try {
image = await this.imageProviders[provider].generate(scene.imagePrompt, {
negativePrompt: scene.negativePrompt,
size: '1024x1024',
});
break;
} catch (e) {
this.logger.warn(`Provider ${provider} failed, trying next...`, e);
}
}
if (!image) throw new Error('All image providers failed');
// 上传到 OSS
const url = await this.oss.upload(
`scripts/${job.data.scriptId}/scene_${scene.id}.png`,
image
);
// 更新场景状态
await this.db.scene.update(scene.id, { imageUrl: url, imageStatus: 'done' });
return { sceneId: scene.id, imageUrl: url };
}
@Process('generateAudio')
async generateAudio(job: Job<{ scene: Scene; scriptId: string }>) {
const { scene } = job.data;
const providers = ['minimax', 'aliyun', 'volcano'];
let audio: Buffer;
for (const provider of providers) {
try {
audio = await this.ttsProviders[provider].synthesize({
text: scene.narration,
voiceId: job.data.script.ttsConfig.voiceId,
speed: job.data.script.ttsConfig.speed,
});
break;
} catch (e) {
this.logger.warn(`TTS provider ${provider} failed, trying next...`, e);
}
}
if (!audio) throw new Error('All TTS providers failed');
const url = await this.oss.upload(
`scripts/${job.data.scriptId}/scene_${scene.id}_audio.mp3`,
audio
);
await this.db.scene.update(scene.id, { audioUrl: url, audioStatus: 'done' });
return { sceneId: scene.id, audioUrl: url };
}
}
// === Composition Worker ===
@Processor('composition')
class CompositionWorker {
@Process('compose')
async composeVideo(job: Job<{ scriptId: string; options: ComposeOptions }>) {
const { scriptId, options } = job.data;
await job.updateProgress({ stage: 'composing', progress: 0 });
// 加载所有素材
const scenes = await this.db.scene.findByScriptId(scriptId);
const script = await this.db.script.findById(scriptId);
// 确保所有素材就绪
const allReady = scenes.every(s => s.imageStatus === 'done' && s.audioStatus === 'done');
if (!allReady) throw new Error('Not all assets are ready');
// 下载素材到本地(OSS → tmp dir)
const tmpDir = `/tmp/video-${scriptId}`;
await this.downloadAssets(scenes, tmpDir);
// 生成 FFmpeg 命令
const composer = new FFmpegComposer();
const cmd = composer.buildCommand({
scenes: scenes.map(s => ({
...s,
imagePath: `${tmpDir}/scene_${s.id}.png`,
audioPath: `${tmpDir}/scene_${s.id}_audio.mp3`,
})),
bgmUrl: script.bgmConfig.audioUrl,
outputPath: `${tmpDir}/output.mp4`,
resolution: { width: 1920, height: 1080 },
});
await job.updateProgress({ progress: 20 });
// 执行 FFmpeg
await this.execFFmpeg(cmd, (progress) => {
job.updateProgress({ progress: 20 + Math.round(progress * 0.7) });
});
await job.updateProgress({ progress: 90 });
// 交付
await this.deliveryService.deliver(scriptId, `${tmpDir}/output.mp4`);
await job.updateProgress({ progress: 100 });
}
}
@WebSocketGateway({ namespace: '/ws/video' })
class VideoProgressGateway {
@WebSocketServer()
server: Server;
// 客户端订阅任务进度
@SubscribeMessage('subscribe')
async handleSubscribe(
@MessageBody() data: { taskId: string },
@ConnectedSocket() client: Socket
) {
client.join(`task:${data.taskId}`);
// 发送当前进度
const task = await this.videoService.getTask(data.taskId);
client.emit('progress', task);
}
// Worker 端推送进度更新
pushProgress(taskId: string, progress: VideoProgress) {
this.server.to(`task:${taskId}`).emit('progress', progress);
}
}
-- 视频任务主表
CREATE TABLE video_tasks (
id VARCHAR(36) PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200),
text_original TEXT NOT NULL,
text_char_count INT DEFAULT 0,
status ENUM('pending','scripting','generating','composing','done','failed') DEFAULT 'pending',
progress JSON, -- {stage, progress, detail}
video_url VARCHAR(500),
cover_url VARCHAR(500),
duration INT DEFAULT 0, -- 实际时长(秒)
file_size BIGINT DEFAULT 0, -- 字节
resolution VARCHAR(10) DEFAULT '1080p',
estimated_cost DECIMAL(10,4), -- 预估费用
actual_cost DECIMAL(10,4), -- 实际费用
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
INDEX idx_user_status (user_id, status),
INDEX idx_created (created_at)
);
-- 分镜脚本表
CREATE TABLE video_scripts (
id VARCHAR(36) PRIMARY KEY,
task_id VARCHAR(36) NOT NULL,
title VARCHAR(200),
genre VARCHAR(20),
visual_style VARCHAR(100),
color_palette JSON,
aspect_ratio VARCHAR(5) DEFAULT '16:9',
tts_config JSON, -- {provider, voiceId, speed, volume}
bgm_config JSON, -- {style, tempo, volumeRatio}
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES video_tasks(id)
);
-- 角色表
CREATE TABLE video_characters (
id VARCHAR(36) PRIMARY KEY,
script_id VARCHAR(36) NOT NULL,
name VARCHAR(100),
gender ENUM('male','female'),
age_range VARCHAR(20),
appearance TEXT,
personality TEXT,
reference_image_url VARCHAR(500),
face_feature_vector BLOB, -- IP-Adapter 提取的特征向量
FOREIGN KEY (script_id) REFERENCES video_scripts(id)
);
-- 分镜表
CREATE TABLE video_scenes (
id VARCHAR(36) PRIMARY KEY,
script_id VARCHAR(36) NOT NULL,
scene_order INT NOT NULL,
duration DECIMAL(5,1) DEFAULT 8.0,
image_prompt TEXT,
image_prompt_zh TEXT,
negative_prompt TEXT,
narration TEXT,
narration_style VARCHAR(100),
camera_motion JSON,
transition_in JSON,
transition_out JSON,
mood VARCHAR(50),
intensity TINYINT DEFAULT 5,
-- 生成结果
image_url VARCHAR(500),
image_status ENUM('pending','generating','done','failed') DEFAULT 'pending',
audio_url VARCHAR(500),
audio_status ENUM('pending','generating','done','failed') DEFAULT 'pending',
video_url VARCHAR(500), -- I2V 扩展
video_status ENUM('pending','generating','done','failed') DEFAULT 'pending',
retry_count INT DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (script_id) REFERENCES video_scripts(id),
INDEX idx_script_order (script_id, scene_order)
);
-- 场景中出现的角色关联
CREATE TABLE video_scene_characters (
scene_id VARCHAR(36) NOT NULL,
character_id VARCHAR(36) NOT NULL,
PRIMARY KEY (scene_id, character_id)
);
CREATE TABLE video_costs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(36) NOT NULL,
scene_id VARCHAR(36), -- NULL 表示整任务费用
cost_type ENUM('scripting','image','audio','video_gen','composition','delivery'),
provider VARCHAR(50), -- 如 'tongyi', 'minimax', 'ffmpeg'
units DECIMAL(10,2), -- 使用量(张数/字数/秒数)
unit_price DECIMAL(10,6),
total_cost DECIMAL(10,4),
detail JSON, -- 附加信息
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES video_tasks(id),
INDEX idx_task (task_id)
);
// === 视频任务 ===
// 创建视频任务
POST /api/video/create
Body: {
text: "输入文本...",
config?: {
style?: "水墨画风" | "3D卡通" | "写实" | "二次元",
resolution?: "720p" | "1080p" | "4K",
aspectRatio?: "16:9" | "9:16" | "1:1",
voice?: "male_young" | "female_warm" | "male_deep",
bgm?: "epic" | "light" | "none",
subtitleEnabled?: true
}
}
Response: {
code: 0,
data: {
taskId: "uuid",
estimatedCost: 0.85, // 预估费用
estimatedDuration: 180, // 预估耗时(秒)
status: "pending"
}
}
// 查询任务状态
GET /api/video/:id
Response: {
code: 0,
data: {
taskId: "uuid",
status: "generating",
progress: { stage: "generating", progress: 45, detail: "正在生成第5/10个画面..." },
script: { ... }, // status >= scripting 时返回
scenes: [ ... ], // 含素材 URL
videoUrl: "https://...", // status === done 时返回
coverUrl: "https://...",
duration: 180,
actualCost: 0.82
}
}
// 查询任务列表
GET /api/video/list?page=1&pageSize=20&status=done
Response: {
code: 0,
data: {
list: [{ taskId, title, coverUrl, duration, status, createdAt }],
total: 42
}
}
// 重试失败任务
POST /api/video/:id/retry
// 删除任务
DELETE /api/video/:id
// === 分镜编辑(草稿阶段干预) ===
// 更新分镜脚本
PUT /api/video/:id/script
Body: { scenes: [{ id: 1, imagePrompt: "修改后的prompt", narration: "修改后的旁白" }] }
// 重新生成单个分镜
POST /api/video/:id/scene/:sceneId/regenerate
Body: { type: "image" | "audio" | "all" }
// === 费用 ===
// 预估费用
POST /api/video/estimate
Body: { text: "...", config: { resolution: "1080p" } }
Response: { code: 0, data: { estimatedCost: 0.85 } }
// 查询费用明细
GET /api/video/:id/costs
Client → Server:
subscribe { taskId: "uuid" } // 订阅任务进度
unsubscribe { taskId: "uuid" } // 取消订阅
Server → Client:
progress { // 进度更新
taskId: "uuid",
stage: "generating",
progress: 67,
detail: { current: 7, total: 10, label: "正在生成第7/10个画面..." },
estimatedRemaining: 45
}
completed { // 任务完成
taskId: "uuid",
videoUrl: "https://...",
duration: 180
}
failed { // 任务失败
taskId: "uuid",
error: "视频合成失败:内存不足"
}
问题:同一个角色在不同分镜里脸不一样,看起来像不同的人。
解决方案:
┌─────────────────────────────────────────────────────────────┐
│ 三层保障机制 │
│ │
│ Layer 1: Prompt 工程 │
│ 所有分镜的 imagePrompt 前缀统一角色描述 │
│ 例: "A young Chinese woman, long black hair, round face, │
│ wearing a red hanfu, consistent character..." │
│ │
│ Layer 2: IP-Adapter + FaceID (推荐) │
│ - 第一帧生成角色 → 提取 FaceID embedding │
│ - 后续分镜附带 embedding 作为条件注入 │
│ - 技术栈: ComfyUI + IP-Adapter-FaceID-Plus │
│ │
│ Layer 3: GPT-4o 质量验证 │
│ - 每个分镜生成后 → GPT-4o 对比角色一致性 │
│ - 不一致 → 自动调整 prompt → 重新生成(最多 3 次) │
│ - Cost: ~$0.01/次验证 │
└─────────────────────────────────────────────────────────────┘
问题:用户输入 5000 字,需要 20+ 个分镜,LLM 输出可能失控。
解决方案:
预估成本公式:
Cost = N_scenes × (T2I_cost + TTS_cost) + BGM_cost + Compute_cost
以 3 分钟视频(10 个分镜)为例:
通义万相 T2I: 10 × ¥0.10 = ¥1.00
MiniMax TTS: 500字 × ¥0.015 = ¥7.50
BGM: ¥0.50
FFmpeg 合成: ¥0.20 (GPU 租用分摊)
LLM 分镜: ¥0.05
─────────────────────────
总计: ¥9.25
用户定价建议:
1080p: ¥2.99/次 (约 3x 成本覆盖)
4K: ¥5.99/次
VIP 月度: ¥29.99/月(不限次数,限 1080p)
// 多层容错机制
class ResilienceManager {
// 1. Provider Failover
async generateWithRetry<T>(
providers: string[],
generateFn: (provider: string) => Promise<T>,
maxRetries = 3
): Promise<T> {
for (const provider of providers) {
for (let retry = 0; retry < maxRetries; retry++) {
try {
return await generateFn(provider);
} catch (e) {
if (retry < maxRetries - 1) {
await this.sleep(Math.pow(2, retry) * 1000); // 指数退避
}
}
}
}
throw new Error('All providers exhausted');
}
// 2. 单个分镜失败不阻塞全部
async generateAllScenes(scenes: Scene[]) {
const results = await Promise.allSettled(
scenes.map(s => this.generateScene(s))
);
// 统计失败率
const failed = results.filter(r => r.status === 'rejected');
if (failed.length / scenes.length > 0.5) {
throw new Error('Too many scenes failed');
}
// 对失败的用 placeholder 替代
return results.map((r, i) => {
if (r.status === 'fulfilled') return r.value;
return this.generatePlaceholder(scenes[i]);
});
}
// 3. 任务幂等性
async createTask(text: string): Promise<string> {
const hash = crypto.createHash('md5').update(text).digest('hex');
const existing = await this.db.videoTask.findByHash(hash);
if (existing && existing.status === 'done') {
return existing.id; // 相同文本直接返回缓存结果
}
return this.createNewTask(text, hash);
}
}
用户编辑流程(保证可控性):
输入文本 → [生成分镜预览] → 用户确认/编辑分镜 → [生成素材] → 用户预览 →
[调整/重生成单个分镜] → [确认合成] → 等待 → 播放/下载
关键:分镜草稿阶段就允许用户干预,避免生成完才发现不满意
| 现有模块 | 路径 | 复用方式 |
|---|---|---|
| Nest.js 服务 | server/src/ |
新增 modules/video/ 目录 |
| TTS 多模型 | server/src/modules/tts/ |
封装为统一 TTS Provider 接口 |
| 阿里云 OSS | 现有 OSS 配置 | 视频素材 + 成品存储 |
| 支付系统 | server/src/modules/payment/ |
按视频收费,复用支付宝/微信 |
| 用户认证 | server/src/modules/auth/ |
复用 JWT 认证 + VIP 体系 |
| 任务队列 | 需要新增 | 引入 BullMQ + Redis |
| FFmpeg | 需要新增 | 服务器安装 + 封装 |
| 前端 UniApp | my-uniapp-vue3/ |
新增视频生成页面 |
server/src/modules/video/
├── video.module.ts # Nest.js 模块定义
├── video.controller.ts # API 控制器
├── video.service.ts # 业务逻辑
├── video.gateway.ts # WebSocket 进度推送
├── scripting/
│ ├── scripting.worker.ts # 分镜生成 Worker
│ ├── scripting.service.ts # LLM 调用封装
│ └── prompts/
│ ├── story.ts # 故事类 prompt 模板
│ ├── news.ts # 新闻类 prompt 模板
│ └── tutorial.ts # 教程类 prompt 模板
├── assets/
│ ├── image.worker.ts # T2I Worker
│ ├── image.service.ts # 多 Provider 封装
│ ├── audio.worker.ts # TTS Worker (复用现有)
│ ├── audio.service.ts # TTS Provider 统一接口
│ └── providers/
│ ├── tongyi.provider.ts
│ ├── jimeng.provider.ts
│ ├── minimax.provider.ts
│ └── aliyun-tts.provider.ts
├── composition/
│ ├── composition.worker.ts # 视频合成 Worker
│ ├── composition.service.ts
│ ├── ffmpeg-builder.ts # FFmpeg 命令构建器
│ └── subtitle.service.ts # 字幕生成
├── delivery/
│ └── delivery.service.ts # OSS 上传 + CDN
├── entities/
│ ├── video-task.entity.ts
│ ├── video-script.entity.ts
│ ├── video-character.entity.ts
│ └── video-scene.entity.ts
└── dto/
├── create-video.dto.ts
├── update-script.dto.ts
└── video-progress.dto.ts
my-uniapp-vue3/src/pages/
├── video-create/ # 视频创建页
│ └── index.vue # 输入文本 + 参数选择
├── video-script/ # 分镜编辑页
│ └── index.vue # 分镜预览 + 调整
├── video-progress/ # 进度页
│ └── index.vue # 实时进度 + 素材预览
├── video-player/ # 视频播放页
│ └── index.vue # 播放 + 分享 + 下载
└── video-list/ # 我的视频列表
└── index.vue # 历史记录
包含:
不包含:
| 天数 | 任务 | 产出 |
|---|---|---|
| Day 1 | LLM 分镜脚本生成 | Prompt 模板 + JSON Schema 校验 + API 端点 |
| Day 2 | T2I 接入(通义万相) | Provider 封装 + 批量生成 + OSS 上传 |
| Day 3 | TTS 模块统一封装 | Provider 接口 + 现有三家的适配层 |
| Day 4 | FFmpeg 合成引擎 | Ken Burns + 拼接 + 字幕 + BGM + 编码 |
| Day 5 | 任务编排 + 进度推送 | BullMQ 队列 + WebSocket Gateway + Worker 串联 |
| Day 6 | 前端页面 | 创建页 + 进度页 + 播放页 |
| Day 7 | 联调 + 测试 + 上线 | 端到端测试 + 性能调优 + 部署 |
# 新增依赖
# server/package.json
{
"dependencies": {
"bullmq": "^5.0",
"ioredis": "^5.3",
"@nestjs/bullmq": "^10.0",
"@nestjs/websockets": "^10.0",
"@nestjs/platform-socket.io": "^10.0",
"@alicloud/openapi-client": "^0.4",
"@alicloud/wanxiang-20240614": "^1.0",
"fluent-ffmpeg": "^2.1",
"zod": "^3.22"
}
}
# 服务端新增环境变量
# server/.env
# === Video Generation ===
VIDEO_T2I_PROVIDER=tongyi
VIDEO_T2I_BACKUP=jimeng
VIDEO_LLM_PROVIDER=claude
VIDEO_LLM_BACKUP=qwen-max
VIDEO_MAX_DURATION=600 # 最长支持 10 分钟
VIDEO_MAX_CHARS=10000 # 最多 1 万字
VIDEO_PREVIEW_RESOLUTION=512p # 预览分辨率
# Redis (BullMQ 需要)
REDIS_HOST=localhost
REDIS_PORT=6379
| 环节 | 调用次数 | 单价 | 小计 |
|---|---|---|---|
| LLM 分镜 | 1 次 | ¥0.05 | ¥0.05 |
| T2I 图片 | 10 张 | ¥0.10 | ¥1.00 |
| TTS 配音 | 500 字 | ¥0.015 | ¥7.50 |
| BGM | 1 首 | ¥0.50 | ¥0.50 |
| FFmpeg 合成 | 1 次 | ¥0.20 | ¥0.20 |
| 总计 | ¥9.25 |
| 方案 | 月生成量 | 单次成本 | 月成本 | 定价 | 月利润 |
|---|---|---|---|---|---|
| 保守 | 100 次 | ¥9.25 | ¥925 | ¥1,790 | ¥865 |
| 中等 | 500 次 | ¥9.25 | ¥4,625 | ¥8,950 | ¥4,325 |
| 乐观 | 2000 次 | ¥9.25 | ¥18,500 | ¥35,800 | ¥17,300 |
| 配置 | 用途 | 月费 |
|---|---|---|
| 4C8G 应用服务器 | Nest.js API | ¥200 |
| 2C4G Redis | BullMQ 队列 | ¥100 |
| 2C4G Worker x2 | 分镜 + 素材 | ¥200 |
| GPU T4 Worker | FFmpeg 加速 | ¥500 |
| OSS + CDN | 存储 + 分发 | ¥100 |
| 总计 | ¥1,100/月 |
| 项目 | 特点 | 启发 |
|---|---|---|
| HeyGen | 数字人视频 | 用户可编辑分镜 |
| Sora (OpenAI) | 文本 → 视频 | 端到端生成思路 |
| 剪映 AI | 模板化视频 | Ken Burns + 转场的实用效果 |
| RunwayML | AI 视频编辑 | 多模态视频生成 |
| Invideo AI | 文本 → 营销视频 | 分镜 Pipeline 架构 |
| 风险 | 概率 | 影响 | 应对 |
|---|---|---|---|
| AI 服务不稳定 | 高 | 中 | 多 Provider failover |
| 画面质量差 | 中 | 高 | 分镜编辑 + 重新生成 |
| 成本超预期 | 中 | 中 | 按用量计费 + 成本监控 |
| 生成速度慢 | 低 | 中 | 并行 + GPU 加速 + 缓存 |
| 用户生成违规内容 | 中 | 高 | 内容审核 + 敏感词过滤 |
| 术语 | 英文 | 解释 |
|---|---|---|
| T2I | Text-to-Image | 文生图 |
| TTS | Text-to-Speech | 文本转语音 |
| I2V | Image-to-Video | 图生视频 |
| LLM | Large Language Model | 大语言模型 |
| Ken Burns | - | 静态图片缓慢缩放/平移的效果 |
| IP-Adapter | - | 图像提示适配器,用于控制图像一致性 |
| SRT | SubRip Text | 字幕文件格式 |
文档维护者: AI Assistant
下次评审: MVP 开发完成后