lovart3/火山引擎视频服务开发计划.md
2025-12-17 20:15:18 +08:00

2736 lines
105 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 火山引擎视频生成服务 - 开发计划
## 📋 项目概述
为 AI Chatbot 系统新增独立的"火山引擎视频"服务商,支持基于豆包 Seedance 模型的视频生成能力,包括文生视频、图生视频等多种场景。
---
## 🎯 核心功能规划
### 1️⃣ **文生视频(Text-to-Video)**
- **功能描述**:用户输入文本提示词,AI 生成视频
- **支持模型**:
- `doubao-seedance-1-0-pro-250528`
- `doubao-seedance-1-0-pro-fast-251015`
- `doubao-seedance-1-0-lite-t2v-250428`
- **参数支持**:
- 分辨率:480p / 720p / 1080p
- 宽高比:16:9 / 4:3 / 1:1 / 3:4 / 9:16 / 21:9
- 时长:2~12 秒
- 帧率:24 fps
- 水印:true / false
- 种子:随机数种子
- 固定摄像头:camerafixed
### 2️⃣ **图生视频-首帧模式(Image-to-Video: First Frame)**
- **功能描述**:上传首帧图片 + 文本提示词,生成视频
- **支持模型**:
- `doubao-seedance-1-0-pro-250528`
- `doubao-seedance-1-0-pro-fast-251015`
- `doubao-seedance-1-0-lite-i2v-250428`
- **使用场景**:
- 静态图片动态化
- 图片续写视频故事
### 3️⃣ **图生视频-首尾帧模式(Image-to-Video: First & Last Frame)**
- **功能描述**:上传首帧 + 尾帧图片,AI 生成中间过渡视频
- **支持模型**:
- `doubao-seedance-1-0-pro-250528`
- `doubao-seedance-1-0-lite-i2v-250428`
- **使用场景**:
- 两张图片之间的平滑过渡
- 镜头运动轨迹控制(如 360 度环绕)
### 4️⃣ **图生视频-参考图模式(Image-to-Video: Reference Images)**
- **功能描述**:上传 1-4 张参考图 + 文本描述,生成包含参考元素的视频
- **支持模型**:
- `doubao-seedance-1-0-lite-i2v-250428`
- **使用场景**:
- 多元素融合视频
- 角色/场景组合(如"[图1]男生和[图2]柯基在[图3]草坪上")
### 5️⃣ **连续视频生成(Sequential Video Generation)**
- **功能描述**:自动使用前一个视频的尾帧作为下一个视频的首帧,生成多段连续视频
- **技术实现**:
- 启用 `return_last_frame: true`
- 循环调用生成任务
- **使用场景**:
- 长视频故事创作
- 连续镜头拼接
---
## 🏗️ 技术架构设计
### 📁 文件结构
```
ai-chatbot-main/
├── lib/ai/providers/
│ └── volcano-video.ts # 🆕 火山视频 Provider 核心逻辑
├── lib/storage/
│ └── video-archiver.ts # 🆕 视频归档服务(下载+上传S3)
├── lib/jobs/
│ └── video-archive-job.ts # 🆕 视频归档定时任务
├── components/
│ ├── qianwen-video-loading.tsx # ♻️ 复用:视频生成加载动画(已存在)
│ ├── video-preview.tsx # 🆕 视频预览组件(支持播放器)
│ └── video-params-bar.tsx # 🆕 视频参数栏(横向布局,类似官方)
├── app/(chat)/
│ ├── page.tsx # ✏️ 扩展:集成视频参数栏到聊天界面
│ ├── components/
│ │ └── chat-input.tsx # ✏️ 扩展:动态图片上传按钮
│ └── api/chat/
│ └── route.ts # ✏️ 扩展:集成视频生成 Tool
├── app/api/
│ ├── admin/
│ │ ├── ai-providers/
│ │ │ └── route.ts # ✏️ 添加:volcano-video 类型验证
│ │ └── ai-models/
│ │ └── route.ts # ✏️ 扩展:模型能力配置(capabilities)
│ └── jobs/
│ └── archive-videos/
│ └── route.ts # 🆕 视频归档触发API(用于定时任务)
├── lib/db/
│ ├── schema.ts # ✏️ 更新:Message 表增加视频存储状态字段
│ └── queries.ts # ✏️ 新增:查询待归档视频的方法
└── types/
└── ai.ts # ✏️ 添加:VideoGenerationOptions + ModelCapabilities 类型
```
---
## 🔧 详细实现方案
### **阶段 1:数据库与类型系统扩展**
#### 1.1 实际数据库表结构(已部署)
**✅ AiProvider 表(已更新)**
```sql
-- 表名:AiProvider
-- 类型约束已更新,支持 'volcano-video'
CHECK (type IN ('alibaba', 'openai', 'custom', 'local', 'gemini',
'qianwen-video', 'volcano-vision', 'volcano-video'))
```
**✅ AiModel 表(已扩展)**
```sql
-- 完整表结构
Table "public.AiModel"
Column | Type | Nullable | Default
------------------+-----------------------------+----------+-------------------
id | uuid | not null | gen_random_uuid()
providerId | uuid | not null |
modelId | character varying(128) | not null |
displayName | character varying(128) | not null |
description | text | |
supportsVision | boolean | not null | false
supportsImageGen | boolean | not null | false
isEnabled | boolean | not null | true
isDefault | boolean | not null | false
sortOrder | integer | not null | 0
createdAt | timestamp without time zone | not null | now()
updatedAt | timestamp without time zone | not null | now()
capabilities | jsonb | | -- 🆕 新增
defaultParams | jsonb | | -- 🆕 新增
-- 主键
PRIMARY KEY (id)
-- 外键
FOREIGN KEY (providerId) REFERENCES AiProvider(id) ON DELETE CASCADE
```
**✅ Message_v2 表(已扩展)**
```sql
-- 新增视频存储相关字段
ALTER TABLE "Message_v2" ADD COLUMN "video_storage_status" VARCHAR(20) DEFAULT 'temporary';
ALTER TABLE "Message_v2" ADD COLUMN "temporary_video_url" TEXT;
ALTER TABLE "Message_v2" ADD COLUMN "permanent_video_url" TEXT;
ALTER TABLE "Message_v2" ADD COLUMN "archived_at" TIMESTAMP;
ALTER TABLE "Message_v2" ADD COLUMN "video_metadata" JSONB;
-- 新增索引(优化归档查询性能)
CREATE INDEX "idx_message_video_archive"
ON "Message_v2"("video_storage_status", "createdAt")
WHERE "video_storage_status" = 'temporary' AND "permanent_video_url" IS NULL;
CREATE INDEX "idx_message_permanent_video"
ON "Message_v2"("permanent_video_url")
WHERE "permanent_video_url" IS NOT NULL;
```
#### 1.2 Schema 定义(lib/db/schema.ts)
```typescript
// 更新服务商类型枚举
export const aiProvider = pgTable("AiProvider", {
// ... 其他字段 ...
type: varchar("type", {
enum: ["alibaba", "openai", "custom", "local", "gemini",
"qianwen-video", "volcano-vision", "volcano-video"] // 🆕 已添加
}).notNull().default("custom"),
});
// 扩展 AiModel 表结构
export const aiModel = pgTable('AiModel', {
// ... 现有字段 ...
// 🆕 模型能力配置(JSON 格式存储)
capabilities: jsonb('capabilities'),
// 🆕 默认参数配置(JSON 格式存储)
defaultParams: jsonb('defaultParams'),
});
// 扩展 Message_v2 表
export const message = pgTable('Message_v2', {
// ... 现有字段 ...
// 🆕 视频存储状态(用于归档管理)
videoStorageStatus: varchar('video_storage_status', { length: 20 })
.default('temporary'),
// 🆕 临时视频URL(火山引擎返回的24小时有效URL)
temporaryVideoUrl: text('temporary_video_url'),
// 🆕 永久视频URL(S3存储的永久URL)
permanentVideoUrl: text('permanent_video_url'),
// 🆕 归档时间
archivedAt: timestamp('archived_at'),
// 🆕 视频元数据(JSON格式)
videoMetadata: jsonb('video_metadata'),
});
```
**注意事项**:
- ✅ 迁移脚本:`lib/db/migrations/0018_add_volcano_video_support.sql`
- ✅ 已在生产数据库执行完成
- ✅ 表所有者:`AiModel` 由 `ai_chatbot_user` 所有,`Message_v2` 由 `chatbot_user` 所有
- ✅ 索引已创建,优化归档查询性能
#### 1.2 定义 TypeScript 类型
**文件**:`types/ai.ts`
```typescript
// 视频生成模式
export type VideoGenerationMode =
| 'text-to-video' // 文生视频
| 'first-frame' // 图生视频-首帧
| 'first-last-frame' // 图生视频-首尾帧
| 'reference-images'; // 图生视频-参考图
// 模型能力配置
export interface VideoModelCapabilities {
modelId: string;
modelName: string;
supportedModes: VideoGenerationMode[];
supportedResolutions: ('480p' | '720p' | '1080p')[];
supportedRatios: ('16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' | 'adaptive')[];
durationRange: { min: number; max: number }; // 时长范围(秒)
supportWatermark: boolean;
supportCameraFixed: boolean;
supportSeed: boolean;
supportReturnLastFrame: boolean;
maxReferenceImages?: number; // 参考图模式最多支持几张图
}
// 视频生成选项
export interface VideoGenerationOptions {
prompt: string;
model: string;
mode: VideoGenerationMode; // 🆕 生成模式
// 图片输入(可选)
firstFrameImage?: string; // 首帧图片 URL/Base64
lastFrameImage?: string; // 尾帧图片 URL/Base64
referenceImages?: string[]; // 参考图数组(1-4张)
// 视频参数
resolution?: '480p' | '720p' | '1080p';
ratio?: '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' | 'adaptive';
duration?: number; // 2-12 秒
fps?: number; // 帧率(默认 24)
watermark?: boolean; // 是否包含水印
cameraFixed?: boolean; // 是否固定摄像头
seed?: number; // 随机种子
returnLastFrame?: boolean; // 是否返回尾帧
}
// 视频生成结果
export interface VideoGenerationResult {
taskId: string;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
videoUrl?: string;
lastFrameUrl?: string; // 尾帧 URL(用于连续生成)
error?: {
code: string;
message: string;
};
usage?: {
completionTokens: number;
totalTokens: number;
};
createdAt: number;
updatedAt: number;
}
```
---
### **阶段 2:核心 Provider 开发**
#### 2.1 创建 `volcano-video.ts`
**文件**:`lib/ai/providers/volcano-video.ts`
**核心功能模块:**
```typescript
// 1. 初始化客户端
async function createVolcanoVideoClient(apiKey: string) {
// 配置 ARK SDK 客户端
// Base URL: https://ark.cn-beijing.volces.com/api/v3
}
// 2. 创建视频生成任务
async function createVideoTask(options: VideoGenerationOptions) {
// 构建 content 数组
// - 文本提示词
// - 图片(首帧/尾帧/参考图)
// 发送 POST 请求到 /content_generation/tasks/create
// 返回 task_id
}
// 3. 轮询任务状态
async function pollTaskStatus(taskId: string, maxRetries = 60) {
// 每 10 秒查询一次任务状态
// 状态:queued → running → succeeded / failed
// 超时时间:10 分钟(视频生成耗时较长)
}
// 4. 构建参数字符串
function buildParameterString(options: VideoGenerationOptions) {
// 生成 --ratio 16:9 --dur 5 --rs 720p 格式的参数
}
// 5. 图片处理:上传到S3 + 转换Base64 🆕
async function processImageInput(imageUrl: string) {
// 步骤1:下载图片
const response = await fetch(imageUrl);
const buffer = await response.arrayBuffer();
// 步骤2:上传到S3获取永久URL(复用项目存储服务)
import { uploadFile } from '@/lib/storage/service';
const filename = `volcano-video-input-${Date.now()}.jpg`;
const { url: permanentUrl } = await uploadFile(filename, buffer);
console.log(`[Volcano Video] 图片已上传到S3:${permanentUrl}`);
// 步骤3:转换为Base64(火山引擎API需要)
const base64 = Buffer.from(buffer).toString('base64');
return {
permanentUrl, // S3永久URL(保存到数据库)
base64, // Base64(发送给火山引擎API)
};
}
// 6. 格式化输出
function formatVideoResponse(result: VideoGenerationResult) {
// 生成 Markdown 格式的视频链接
// 支持视频预览组件渲染
}
// 7. 主函数:doGenerate
export async function doGenerate(options: VideoGenerationOptions) {
// 1. 验证输入参数
// 2. 创建任务
// 3. 轮询状态
// 4. 返回结果
}
// 8. 流式响应(模拟进度)
export async function doGenerateStream(options: VideoGenerationOptions) {
// 生成过程中推送进度消息:
// - "正在创建视频生成任务..."
// - "任务已提交,等待处理..."
// - "正在生成视频(预计 1-3 分钟)..."
// - "视频生成完成!"
}
```
---
### **阶段 3:视频永久存储系统开发** 🆕
#### 3.1 视频归档服务
**文件**:`lib/storage/video-archiver.ts`
**功能需求:下载临时视频并上传到S3**
```typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { db } from '@/lib/db';
import { message } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
interface VideoArchiveOptions {
messageId: string;
temporaryUrl: string;
taskId: string;
metadata?: {
duration?: number;
resolution?: string;
ratio?: string;
};
}
// ✅ 复用项目现有的存储服务
import { uploadFile } from '@/lib/storage/service';
export class VideoArchiver {
/**
* 上传视频到S3(复用项目现有的统一存储服务)
*/
private async uploadVideoToStorage(
buffer: Buffer,
filename: string
): Promise<string> {
// 使用项目统一的 uploadFile 方法
// 自动使用:数据库配置的S3 → 环境变量S3 → Vercel Blob
const result = await uploadFile(filename, buffer);
return result.url;
}
/**
* 归档视频:下载 → 上传S3 → 更新数据库
*/
async archiveVideo(options: VideoArchiveOptions): Promise<string> {
const { messageId, temporaryUrl, taskId, metadata } = options;
try {
console.log(`[VideoArchiver] 开始归档视频:${messageId}`);
// 1. 更新状态为"归档中"
await this.updateVideoStatus(messageId, 'archiving');
// 2. 下载视频
console.log(`[VideoArchiver] 下载视频:${temporaryUrl}`);
const videoBuffer = await this.downloadVideo(temporaryUrl);
if (!videoBuffer) {
throw new Error('Failed to download video from temporary URL');
}
// 3. 生成文件名
const filename = this.generateVideoFilename(messageId, taskId);
// 4. 上传到S3(使用项目统一存储服务)
console.log(`[VideoArchiver] 上传视频:${filename}`);
const permanentUrl = await this.uploadVideoToS3(videoBuffer, filename);
// 5. 更新数据库
await this.updateVideoUrls(messageId, {
permanentUrl,
s3Key,
fileSize: videoBuffer.length,
...metadata,
});
console.log(`[VideoArchiver] ✅ 视频归档完成:${permanentUrl}`);
return permanentUrl;
} catch (error) {
console.error(`[VideoArchiver] ❌ 归档失败:`, error);
// 更新状态为"失败"
await this.updateVideoStatus(messageId, 'failed');
throw error;
}
}
/**
* 下载视频(带重试机制)
*/
private async downloadVideo(url: string, retries = 3): Promise<Buffer | null> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'AI-Chatbot-Video-Archiver/1.0',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error(`[VideoArchiver] 下载失败 (尝试 ${i + 1}/${retries}):`, error);
if (i === retries - 1) {
return null;
}
// 等待后重试(指数退避)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
return null;
}
/**
* 上传视频到S3(使用项目统一存储服务)
*/
private async uploadVideoToS3(buffer: Buffer, filename: string): Promise<string> {
// ✅ 直接使用项目已有的 uploadFile 方法
// 支持:数据库配置的S3 / 环境变量S3 / Vercel Blob
const result = await uploadFile(filename, buffer);
console.log(`[VideoArchiver] 视频已上传到S3:${result.url}`);
return result.url;
}
/**
* 生成视频文件名
* 项目的 uploadFile 会自动生成 uploads/timestamp-random.ext 格式
*/
private generateVideoFilename(messageId: string, taskId: string): string {
// 使用唯一标识作为文件名
return `video-${messageId}-${taskId}.mp4`;
}
/**
* 更新视频状态
*/
private async updateVideoStatus(
messageId: string,
status: 'temporary' | 'archiving' | 'archived' | 'failed'
) {
await db.update(message)
.set({ videoStorageStatus: status })
.where(eq(message.id, messageId));
}
/**
* 更新视频URL和元数据
*/
private async updateVideoUrls(
messageId: string,
data: {
permanentUrl: string;
s3Key: string;
fileSize: number;
duration?: number;
resolution?: string;
ratio?: string;
}
) {
await db.update(message)
.set({
permanentVideoUrl: data.permanentUrl,
videoStorageStatus: 'archived',
archivedAt: new Date(),
videoMetadata: {
s3Key: data.s3Key,
fileSize: data.fileSize,
duration: data.duration,
resolution: data.resolution,
ratio: data.ratio,
},
})
.where(eq(message.id, messageId));
}
}
// 单例模式
export const videoArchiver = new VideoArchiver();
```
#### 3.2 定时归档任务
**文件**:`lib/jobs/video-archive-job.ts`
**功能需求:8小时后自动触发归档**
```typescript
import { db } from '@/lib/db';
import { message } from '@/lib/db/schema';
import { eq, and, lte, isNull } from 'drizzle-orm';
import { videoArchiver } from '@/lib/storage/video-archiver';
/**
* 查询需要归档的视频消息
* 条件:
* 1. 视频状态为 'temporary'(临时)
* 2. 创建时间超过8小时
* 3. 还没有永久URL
*/
export async function getVideosToArchive() {
const eightHoursAgo = new Date(Date.now() - 8 * 60 * 60 * 1000);
return await db.query.message.findMany({
where: and(
eq(message.videoStorageStatus, 'temporary'),
lte(message.createdAt, eightHoursAgo),
isNull(message.permanentVideoUrl)
),
limit: 20, // 每次处理20个,8小时周期可以处理更多
});
}
/**
* 执行视频归档任务
*/
export async function runVideoArchiveJob() {
console.log('[VideoArchiveJob] 开始执行视频归档任务...');
try {
// 1. 查询待归档的视频
const videos = await getVideosToArchive();
if (videos.length === 0) {
console.log('[VideoArchiveJob] 没有需要归档的视频');
return;
}
console.log(`[VideoArchiveJob] 发现 ${videos.length} 个待归档视频`);
// 2. 并发归档(控制并发数为3)
const concurrency = 3;
const results = [];
for (let i = 0; i < videos.length; i += concurrency) {
const batch = videos.slice(i, i + concurrency);
const batchPromises = batch.map(async (video) => {
try {
// 从消息内容中提取视频URL和元数据
const videoData = extractVideoDataFromMessage(video);
if (!videoData) {
console.warn(`[VideoArchiveJob] 消息 ${video.id} 没有视频数据`);
return { success: false, messageId: video.id };
}
// 执行归档
const permanentUrl = await videoArchiver.archiveVideo({
messageId: video.id,
temporaryUrl: videoData.temporaryUrl,
taskId: videoData.taskId,
metadata: videoData.metadata,
});
return { success: true, messageId: video.id, permanentUrl };
} catch (error) {
console.error(`[VideoArchiveJob] 归档失败 (${video.id}):`, error);
return { success: false, messageId: video.id, error };
}
});
const batchResults = await Promise.allSettled(batchPromises);
results.push(...batchResults);
}
// 3. 统计结果
const succeeded = results.filter(r =>
r.status === 'fulfilled' && r.value.success
).length;
const failed = results.length - succeeded;
console.log(`[VideoArchiveJob] ✅ 完成:成功 ${succeeded},失败 ${failed}`);
} catch (error) {
console.error('[VideoArchiveJob] 任务执行出错:', error);
}
}
/**
* 从消息中提取视频数据
*/
function extractVideoDataFromMessage(msg: any) {
try {
// 假设视频数据存储在 message.content 或 message.toolInvocations 中
// 具体结构根据实际情况调整
if (msg.temporaryVideoUrl) {
return {
temporaryUrl: msg.temporaryVideoUrl,
taskId: msg.videoMetadata?.taskId || msg.id,
metadata: {
duration: msg.videoMetadata?.duration,
resolution: msg.videoMetadata?.resolution,
ratio: msg.videoMetadata?.ratio,
},
};
}
return null;
} catch (error) {
console.error('提取视频数据失败:', error);
return null;
}
}
```
#### 3.3 定时任务触发器
**文件**:`app/api/jobs/archive-videos/route.ts`
**功能需求:提供HTTP接口用于Cron触发(每小时执行一次)**
```typescript
import { NextResponse } from 'next/server';
import { runVideoArchiveJob } from '@/lib/jobs/video-archive-job';
// 验证请求来源(防止未授权调用)
function verifyAuthToken(request: Request): boolean {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
// 与环境变量中的密钥比对
return token === process.env.CRON_SECRET;
}
export async function POST(request: Request) {
try {
// 1. 验证授权
if (!verifyAuthToken(request)) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// 2. 执行归档任务
await runVideoArchiveJob();
// 3. 返回成功响应
return NextResponse.json({
success: true,
message: 'Video archive job completed',
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[API] Video archive job failed:', error);
return NextResponse.json(
{
success: false,
error: error.message,
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}
// 允许GET请求查看任务状态
export async function GET(request: Request) {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 返回待归档视频统计
const { getVideosToArchive } = await import('@/lib/jobs/video-archive-job');
const videos = await getVideosToArchive();
return NextResponse.json({
pendingCount: videos.length,
timestamp: new Date().toISOString(),
});
}
```
#### 3.4 配置 Vercel Cron(或其他调度器)
**文件**:`vercel.json`
```json
{
"crons": [
{
"path": "/api/jobs/archive-videos",
"schedule": "0 * * * *"
}
]
}
```
**说明:**
- `0 * * * *` = 每小时执行一次(整点触发)
- 由于归档延迟设为8小时,每小时检查一次即可
- 相比5分钟周期,大幅降低服务器负载
**或使用外部Cron服务(cron-job.org / GitHub Actions):**
```bash
# 每小时执行一次
0 * * * * curl -X POST https://yourdomain.com/api/jobs/archive-videos \
-H "Authorization: Bearer YOUR_CRON_SECRET"
```
---
### **阶段 4:前端参数面板开发**
#### 3.1 创建视频参数设置栏(横向设计)
**文件**:`components/video-params-bar.tsx`
**功能需求:**
**📋 参考官方设计 - 横向参数栏:**
```
┌────────────────────────────────────────────────────────────────┐
│ 输入框区域(上传图片 + 文本输入) │
│ [+ 图片] [+ 尾帧] 输入文字,描述你想创作的画面内容... │
└────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ 参数栏(横向排列,类似官方设计) │
│ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌─────┐ ┌─────┐ ┌────┐│
│ │视频生成 ▼│ │视频 3.0 │ │首尾帧 ▼ │ │16:9 │ │720P │ │5s ││
│ └──────────┘ └─────────┘ └──────────┘ └─────┘ └─────┘ └────┘│
│ ┌──────────┐ ┌─────────────────────────────────────┐ │
│ │运镜:无 ▼│ │ [生成] │ │
│ └──────────┘ └─────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
点击"首尾帧▼"展开下拉菜单:
┌────────────────┐
│ ✓ 首尾帧 │
│ 智能多帧 New │
│ 主体参考 New │
└────────────────┘
点击"16:9"或"720P"展开分层面板:
┌──────────────────────────────────────┐
│ 选择比例 │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐│
│ │21:9│ │16:9│ │4:3│ │1:1│ │3:4│ │9:16││
│ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘│
│ │
│ 选择分辨率 │
│ ┌─────┐ ┌────────┐ │
│ │720P │ │1080P + │ │
│ └─────┘ └────────┘ │
└──────────────────────────────────────┘
```
**核心功能:**
```typescript
interface VideoParamsBarProps {
// 当前选择的模型
currentModel?: VideoModelCapabilities;
// 初始参数(来自系统默认配置)
initialParams?: Partial<VideoGenerationOptions>;
// 参数变化回调
onParamsChange?: (params: VideoGenerationOptions) => void;
}
export const VideoParamsBar = ({
currentModel,
initialParams,
onParamsChange
}: VideoParamsBarProps) => {
// 1. 状态管理
const [mode, setMode] = useState<VideoGenerationMode>('text-to-video');
const [params, setParams] = useState<VideoGenerationOptions>({
resolution: '720p',
ratio: '16:9',
duration: 5,
watermark: false,
cameraFixed: false,
...initialParams
});
// 2. 根据模型能力动态显示/禁用选项
const availableModes = currentModel?.supportedModes || [];
const availableResolutions = currentModel?.supportedResolutions || [];
const availableRatios = currentModel?.supportedRatios || [];
// 3. 参数更新(实时同步)
const updateParam = <K extends keyof VideoGenerationOptions>(
key: K,
value: VideoGenerationOptions[K]
) => {
const newParams = { ...params, [key]: value };
setParams(newParams);
onParamsChange?.(newParams);
};
return (
<div className="flex items-center gap-2 py-3 px-4 border-t bg-muted/30">
{/* 模式选择下拉菜单 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<VideoIcon size={16} />
{getModeLabel(mode)}
<ChevronDown size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setMode('text-to-video')}>
<Check size={14} className={mode === 'text-to-video' ? 'visible' : 'invisible'} />
文生视频
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('first-frame')}>
<Check size={14} className={mode === 'first-frame' ? 'visible' : 'invisible'} />
图生视频-首帧
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('first-last-frame')}>
<Check size={14} className={mode === 'first-last-frame' ? 'visible' : 'invisible'} />
首尾帧过渡
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode('reference-images')}>
<Check size={14} className={mode === 'reference-images' ? 'visible' : 'invisible'} />
参考图融合
<Badge variant="secondary" className="ml-2">New</Badge>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 模型显示(只读) */}
<Badge variant="outline" className="gap-1">
<SparklesIcon size={12} />
{currentModel?.modelName || 'Seedance Pro'}
</Badge>
{/* 宽高比 + 分辨率组合选择器 */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<AspectRatioIcon size={14} />
{params.ratio}
<Separator orientation="vertical" className="h-4" />
{params.resolution}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
{/* 宽高比选择 */}
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">选择比例</Label>
<div className="grid grid-cols-6 gap-2 mt-2">
{['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'].map(ratio => (
<Button
key={ratio}
variant={params.ratio === ratio ? 'default' : 'outline'}
size="sm"
className="aspect-[4/3] flex-col p-1"
onClick={() => updateParam('ratio', ratio)}
>
<RatioIcon ratio={ratio} />
<span className="text-xs">{ratio}</span>
</Button>
))}
</div>
</div>
{/* 分辨率选择 */}
<div>
<Label className="text-xs text-muted-foreground">选择分辨率</Label>
<div className="flex gap-2 mt-2">
{availableResolutions.map(res => (
<Button
key={res}
variant={params.resolution === res ? 'default' : 'outline'}
size="sm"
onClick={() => updateParam('resolution', res)}
disabled={!isResolutionAvailable(res, mode)}
>
{res}
{res === '1080p' && <Plus size={12} className="ml-1" />}
</Button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* 时长滑块 */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<ClockIcon size={14} />
{params.duration}s
</Button>
</PopoverTrigger>
<PopoverContent className="w-64">
<Label className="text-xs">视频时长</Label>
<Slider
value={[params.duration || 5]}
min={2}
max={12}
step={1}
onValueChange={([value]) => updateParam('duration', value)}
className="mt-2"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>2秒</span>
<span>12秒</span>
</div>
</PopoverContent>
</Popover>
{/* 运镜/摄像头设置 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<CameraIcon size={14} />
运镜:{params.cameraFixed ? '固定' : '自由'}
<ChevronDown size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => updateParam('cameraFixed', false)}>
自由运镜
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateParam('cameraFixed', true)}>
固定视角
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 高级选项(折叠) */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-sm">
<Settings2Icon size={16} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm">水印</Label>
<Switch
checked={params.watermark}
onCheckedChange={(checked) => updateParam('watermark', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-sm">返回尾帧</Label>
<Switch
checked={params.returnLastFrame}
onCheckedChange={(checked) => updateParam('returnLastFrame', checked)}
/>
</div>
<div>
<Label className="text-sm">随机种子</Label>
<Input
type="number"
placeholder="留空则随机"
value={params.seed || ''}
onChange={(e) => updateParam('seed', Number(e.target.value) || undefined)}
className="mt-1"
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};
```
**智能交互逻辑:**
1. **模式联动模型选择**:
- 用户选择"首尾帧过渡" → 自动筛选支持该模式的模型
- 如果当前模型不支持,自动切换到 `doubao-seedance-1-0-pro-250528`
- 在模式下拉菜单中,不支持的模式显示为禁用状态(灰色)
2. **动态图片上传区域**:
- 选择"文生视频" → 隐藏图片上传按钮
- 选择"图生视频-首帧" → 显示 `[+ 图片]` 按钮
- 选择"首尾帧" → 显示 `[+ 首帧] [+ 尾帧]` 两个按钮
- 选择"参考图" → 显示 `[+ 参考图1-4]` 可多次点击,最多4张
3. **参数约束与提示**:
- 根据模型能力禁用不支持的选项(按钮显示为灰色 disabled)
- 参数冲突时显示 Tooltip 提示:
```
"参考图模式不支持 1080p 分辨率"
"Lite 模型不支持固定摄像头功能"
```
- 1080p 选项带 `+` 图标,表示高级功能
4. **实时参数同步**:
- 用户调整任何参数 → 立即触发 `onParamsChange` 回调
- 参数实时保存到本地存储(localStorage)
- 下次打开时恢复上次的参数设置
5. **视觉反馈**:
- 当前选中的参数按钮使用 `variant="default"` 高亮显示
- 悬停时显示详细信息(如:16:9 → "宽屏格式,适合电脑/电视观看")
- 参数变更时,按钮有短暂的动画效果
#### 3.2 集成到聊天界面(参考官方布局)
**文件**:`app/(chat)/page.tsx` 或相关聊天组件
**实现方案:**
```typescript
// 聊天界面布局(完整结构)
<div className="flex flex-col h-full">
{/* 消息列表区域 */}
<div className="flex-1 overflow-y-auto">
<Messages messages={messages} />
</div>
{/* 输入区域(固定在底部) */}
<div className="border-t bg-background">
{/* 图片上传 + 文本输入框 */}
<div className="flex items-center gap-2 p-4">
{/* 动态显示图片上传按钮(根据模式) */}
{selectedModel?.type === 'volcano-video' && (
<>
{videoParams.mode === 'first-frame' && (
<Button variant="outline" size="icon" onClick={handleUploadFirstFrame}>
<Plus size={16} />
<span className="sr-only">上传首帧</span>
</Button>
)}
{videoParams.mode === 'first-last-frame' && (
<>
<Button variant="outline" size="sm" onClick={handleUploadFirstFrame}>
<Plus size={16} />
<span>首帧</span>
</Button>
<Button variant="outline" size="sm" onClick={handleUploadLastFrame}>
<Plus size={16} />
<span>尾帧</span>
</Button>
</>
)}
{videoParams.mode === 'reference-images' && (
<Button
variant="outline"
size="sm"
onClick={handleUploadReferenceImage}
disabled={referenceImages.length >= 4}
>
<Plus size={16} />
<span>参考图 ({referenceImages.length}/4)</span>
</Button>
)}
</>
)}
{/* 已上传图片预览 */}
{uploadedImages.length > 0 && (
<div className="flex gap-2">
{uploadedImages.map((img, idx) => (
<div key={idx} className="relative w-12 h-12 rounded border">
<img src={img.preview} alt="" className="w-full h-full object-cover rounded" />
<Button
variant="ghost"
size="icon-xs"
className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-background"
onClick={() => removeImage(idx)}
>
<X size={10} />
</Button>
</div>
))}
</div>
)}
{/* 文本输入框 */}
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={getPlaceholder(selectedModel?.type, videoParams.mode)}
className="flex-1 resize-none"
rows={2}
/>
{/* 发送按钮 */}
<Button onClick={handleSend} disabled={!input.trim()}>
<Send size={18} />
</Button>
</div>
{/* 视频参数栏(仅当选择视频模型时显示) */}
{selectedModel?.type === 'volcano-video' && (
<VideoParamsBar
currentModel={getCurrentModelCapabilities()}
initialParams={videoParams}
onParamsChange={(params) => setVideoParams(params)}
/>
)}
</div>
</div>
```
**用户操作流程:**
```
1. 用户选择火山引擎视频模型
↓
2. 输入框下方自动展开横向参数栏
↓
3. 用户根据需要调整参数:
- 点击"首尾帧▼" → 选择生成模式
- 点击"16:9 | 720P" → 调整比例和分辨率
- 点击"5s" → 滑动调整时长
- 点击"运镜▼" → 选择摄像头模式
↓
4. 参数实时保存,输入提示词
↓
5. 点击发送 → 携带所有参数发送到后端
```
**Placeholder 动态提示:**
```typescript
function getPlaceholder(
modelType?: string,
mode?: VideoGenerationMode
): string {
if (modelType !== 'volcano-video') {
return '输入消息...';
}
switch (mode) {
case 'text-to-video':
return '输入文字,描述你想创作的画面内容、运动方式等。例如:一个3D形象的小男孩,在公园滑滑板。';
case 'first-frame':
return '描述如何让图片动起来。例如:女孩睁开眼,头发被风吹动,镜头缓缓拉出。';
case 'first-last-frame':
return '描述镜头运动轨迹。例如:360度环绕运镜。';
case 'reference-images':
return '描述如何融合参考图元素。例如:[图1]男生和[图2]柯基在[图3]草坪上玩耍。';
default:
return '输入视频生成提示词...';
}
}
```
---
### **阶段 5:前端 UI 组件开发(视频预览)**
#### 5.1 视频加载动画(复用现有组件)
**文件**:`components/qianwen-video-loading.tsx`(**已存在,直接复用**)
**现有功能:**
- ✅ 带闪烁效果的 AI 头像
- ✅ "正在生成视频" 文字 + 跳动点动画
- ✅ 170x200 像素动态模糊占位图
- ✅ 丰富的视觉效果:
- 渐变背景(蓝色→紫色→粉色)
- 多层脉冲动画
- 噪点纹理效果
- 流光扫过效果
- 中心视频图标
- ✅ 提示文字:"⏱️ 视频生成需要 30-60 秒,请耐心等待..."
**实现方案:**
- 火山引擎视频生成时,直接使用 `<QianwenVideoLoading />` 组件
- 无需新建组件,保持代码复用和 UI 一致性
- 可根据实际生成时间,调整提示文字(火山引擎视频生成约 1-3 分钟)
**使用示例:**
```typescript
// 在 volcano-video.ts 中
import { QianwenVideoLoading } from '@/components/qianwen-video-loading';
// 返回加载占位符
return {
type: 'loading',
component: <QianwenVideoLoading />
};
```
#### 5.2 视频预览组件
**文件**:`components/video-preview.tsx`
**功能特性:**
- 支持 MP4 格式视频播放
- 自定义播放器控制(播放/暂停/进度条/音量)
- 下载按钮
- 全屏播放
- 封面图显示(使用首帧或尾帧)
- 悬停显示元数据(分辨率、时长、文件大小)
---
### **阶段 6:Chat API 集成**
#### 6.1 扩展 Tool 系统
**文件**:`app/(chat)/api/chat/route.ts`
**新增 Tool:`generate_video`**
```typescript
{
name: 'generate_video',
description: '生成视频,支持文生视频、图生视频等多种模式',
parameters: {
type: 'object',
properties: {
prompt: {
type: 'string',
description: '视频生成提示词,描述视频内容、镜头运动等'
},
mode: {
type: 'string',
enum: ['text-to-video', 'first-frame', 'first-last-frame', 'reference'],
description: '生成模式:文生视频/首帧/首尾帧/参考图'
},
images: {
type: 'array',
items: { type: 'string' },
description: '图片 URL 数组(根据模式不同,需要 0-4 张)'
},
resolution: {
type: 'string',
enum: ['480p', '720p', '1080p'],
default: '720p'
},
ratio: {
type: 'string',
enum: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
default: '16:9'
},
duration: {
type: 'number',
minimum: 2,
maximum: 12,
default: 5,
description: '视频时长(秒)'
}
},
required: ['prompt', 'mode']
}
}
```
#### 6.2 智能模型选择逻辑
**功能:根据用户选择的生成模式,自动选择合适的模型**
```typescript
// 获取支持指定模式的模型
async function getCompatibleModel(
mode: VideoGenerationMode,
provider: 'volcano-video'
): Promise<VideoModelCapabilities> {
const models = await getEnabledAiModelsWithProvider();
const compatibleModels = models.filter(m =>
m.provider.type === provider &&
m.model.capabilities?.supportedModes?.includes(mode) &&
m.model.isEnabled
);
if (compatibleModels.length === 0) {
throw new Error(`No model supports ${mode} mode`);
}
// 优先级:Pro > Pro Fast > Lite
return selectBestModel(compatibleModels, mode);
}
// 模型选择优先级
function selectBestModel(
models: VideoModelCapabilities[],
mode: VideoGenerationMode
): VideoModelCapabilities {
const priority = {
'doubao-seedance-1-0-pro-250528': 10, // 最高优先级(全功能)
'doubao-seedance-1-0-pro-fast-251015': 8, // 快速模式
'doubao-seedance-1-0-lite-i2v-250428': 6, // 图生视频轻量版
'doubao-seedance-1-0-lite-t2v-250428': 5, // 文生视频轻量版
};
return models.sort((a, b) =>
(priority[b.modelId] || 0) - (priority[a.modelId] || 0)
)[0];
}
```
#### 6.3 Tool 执行逻辑 + 临时URL保存 🆕
```typescript
case 'generate_video': {
const { prompt, mode, images, ...params } = toolCall.args;
// 1. 🆕 根据生成模式自动选择合适的模型
const compatibleModel = await getCompatibleModel(mode, 'volcano-video');
// 2. 验证参数是否符合模型能力
validateParamsAgainstCapabilities(params, compatibleModel);
// 3. 根据模式处理图片输入
const options = buildVideoOptions(
mode,
images,
prompt,
params,
compatibleModel.modelId
);
// 4. 调用视频生成(流式输出进度)
const result = await generateVideoWithProgress(options);
// 5. 🆕 保存临时视频URL到数据库
await saveVideoMessage({
chatId: message.chatId,
role: 'assistant',
temporaryVideoUrl: result.videoUrl,
videoMetadata: {
taskId: result.taskId,
duration: params.duration,
resolution: params.resolution,
ratio: params.ratio,
},
videoStorageStatus: 'temporary', // 标记为临时状态
});
// 6. 返回视频链接和预览组件(用户立即可以观看)
return formatVideoToolResult(result);
}
/**
* 保存视频消息到数据库
*/
async function saveVideoMessage(data: {
chatId: string;
role: 'assistant';
temporaryVideoUrl: string;
videoMetadata: {
taskId: string;
duration?: number;
resolution?: string;
ratio?: string;
};
videoStorageStatus: 'temporary';
}) {
await db.insert(message).values({
chatId: data.chatId,
role: data.role,
content: `生成的视频`, // 占位文本
temporaryVideoUrl: data.temporaryVideoUrl,
videoMetadata: data.videoMetadata,
videoStorageStatus: data.videoStorageStatus,
createdAt: new Date(),
});
console.log(`[Chat API] 📹 视频消息已保存,8小时后将自动归档到S3`);
}
// 参数验证函数
function validateParamsAgainstCapabilities(
params: VideoGenerationOptions,
model: VideoModelCapabilities
) {
// 检查分辨率
if (params.resolution && !model.supportedResolutions.includes(params.resolution)) {
throw new Error(`Model ${model.modelName} does not support ${params.resolution}`);
}
// 检查宽高比
if (params.ratio && !model.supportedRatios.includes(params.ratio)) {
throw new Error(`Model ${model.modelName} does not support ${params.ratio} ratio`);
}
// 检查时长范围
if (params.duration) {
const { min, max } = model.durationRange;
if (params.duration < min || params.duration > max) {
throw new Error(`Duration must be between ${min} and ${max} seconds`);
}
}
// 检查参考图数量
if (params.mode === 'reference-images' && params.referenceImages) {
const maxImages = model.maxReferenceImages || 4;
if (params.referenceImages.length > maxImages) {
throw new Error(`Model supports max ${maxImages} reference images`);
}
}
}
```
---
### **阶段 7:管理后台配置**
#### 7.1 AI Provider 管理
**文件**:`app/api/admin/ai-providers/route.ts`
**新增验证:**
```typescript
const providerSchema = z.object({
type: z.enum([
'openai',
'anthropic',
'gemini',
'gemini-image',
'volcano-vision',
'volcano-video' // 🆕
]),
// ...
});
```
#### 7.2 模型能力配置系统
**文件**:`app/api/admin/ai-models/route.ts`
**功能需求:添加模型时配置能力参数**
```typescript
// POST /api/admin/ai-models
const modelConfigSchema = z.object({
modelId: z.string(),
modelName: z.string(),
providerType: z.literal('volcano-video'),
// 🆕 模型能力配置
capabilities: z.object({
supportedModes: z.array(z.enum([
'text-to-video',
'first-frame',
'first-last-frame',
'reference-images'
])),
supportedResolutions: z.array(z.enum(['480p', '720p', '1080p'])),
supportedRatios: z.array(z.enum([
'16:9', '4:3', '1:1', '3:4', '9:16', '21:9', 'adaptive'
])),
durationRange: z.object({
min: z.number().min(2),
max: z.number().max(12)
}),
supportWatermark: z.boolean(),
supportCameraFixed: z.boolean(),
supportSeed: z.boolean(),
supportReturnLastFrame: z.boolean(),
maxReferenceImages: z.number().optional()
}),
// 🆕 默认参数配置
defaultParams: z.object({
resolution: z.enum(['480p', '720p', '1080p']).default('720p'),
ratio: z.enum(['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', 'adaptive']).default('16:9'),
duration: z.number().min(2).max(12).default(5),
watermark: z.boolean().default(true),
cameraFixed: z.boolean().default(false)
}).optional()
});
```
#### 7.3 系统设置 UI 增强
**功能需求:**
**添加火山引擎视频服务商界面:**
```
┌─────────────────────────────────────────────────┐
│ ➕ 添加 AI 模型服务商 │
├─────────────────────────────────────────────────┤
│ 服务商类型: [火山引擎视频 ▼] │
│ API Key: [●●●●●●●●●●●●●●●●●●] │
│ Base URL: https://ark.cn-beijing.volces.com │
├─────────────────────────────────────────────────┤
│ 📋 添加模型 │
│ │
│ ┌─ 模型 1: Seedance Pro 250528 ─────────────┐ │
│ │ 模型 ID: doubao-seedance-1-0-pro-250528 │ │
│ │ 显示名称: 豆包 Seedance Pro (全功能版) │ │
│ │ ☑ 启用此模型 │ │
│ │ │ │
│ │ 支持的功能: │ │
│ │ ☑ 文生视频 │ │
│ │ ☑ 图生视频-首帧 │ │
│ │ ☑ 图生视频-首尾帧 │ │
│ │ ☐ 图生视频-参考图 (Lite 专属) │ │
│ │ │ │
│ │ 支持的参数: │ │
│ │ 分辨率:☑ 480p ☑ 720p ☑ 1080p │ │
│ │ 宽高比:☑ 16:9 ☑ 4:3 ☑ 1:1 ☑ 9:16 ... │ │
│ │ 时长范围:2 - 12 秒 │ │
│ │ 其他:☑ 水印 ☑ 固定摄像头 ☑ 种子 │ │
│ │ │ │
│ │ 默认参数: │ │
│ │ 分辨率:720p 宽高比:16:9 时长:5秒 │ │
│ │ 水印:是 固定摄像头:否 │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌─ 模型 2: Seedance Lite I2V ────────────────┐│
│ │ 模型 ID: doubao-seedance-1-0-lite-i2v-... ││
│ │ 显示名称: 豆包 Seedance Lite (参考图专用)││
│ │ ☑ 启用此模型 ││
│ │ ││
│ │ 支持的功能: ││
│ │ ☐ 文生视频 (Pro 专属) ││
│ │ ☑ 图生视频-首帧 ││
│ │ ☑ 图生视频-首尾帧 ││
│ │ ☑ 图生视频-参考图 (1-4张) ││
│ │ ││
│ │ ⚠️ 注意:参考图模式不支持 1080p 分辨率 ││
│ └──────────────────────────────────────────┘││
│ │
│ [➕ 添加更多模型] │
├─────────────────────────────────────────────────┤
│ [取消] [保存配置] │
└─────────────────────────────────────────────────┘
```
**预设模板功能:**
- 提供常用模型配置模板,一键导入:
- "Seedance Pro 全功能配置"
- "Seedance Lite 轻量版配置"
- "快速模式配置(Pro Fast)"
**配置验证:**
- 实时检测 API Key 有效性
- 自动测试模型可用性
- 显示模型限流信息(RPM、并发数)
---
## 💾 视频永久存储机制
### **视频URL生命周期:**
```
┌─────────────────────────────────────────────────────────────┐
│ 阶段 1:视频生成 │
├─────────────────────────────────────────────────────────────┤
│ 用户触发生成 → 火山引擎API → 返回临时URL │
│ ⏱️ URL有效期:24小时 │
│ 📹 示例:https://volc-cdn.com/video/task-12345.mp4 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 阶段 2:立即展示(0-8小时) │
├─────────────────────────────────────────────────────────────┤
│ ✅ 用户可以立即观看临时URL的视频 │
│ 💬 消息保存到数据库: │
│ - temporaryVideoUrl: "https://volc-cdn.com/..." │
│ - videoStorageStatus: "temporary" │
│ - permanentVideoUrl: null │
│ - createdAt: 2025-12-09 10:00:00 │
│ │
│ 👁️ 前端显示:使用 temporaryVideoUrl │
└─────────────────────────────────────────────────────────────┘
↓
等待 8 小时
↓
┌─────────────────────────────────────────────────────────────┐
│ 阶段 3:后台归档(8小时后触发) │
├─────────────────────────────────────────────────────────────┤
│ ⏰ Cron Job 触发:每1小时执行一次 │
│ │
│ 1️⃣ 查询待归档视频: │
│ SELECT * FROM message │
│ WHERE videoStorageStatus = 'temporary' │
│ AND createdAt <= NOW() - INTERVAL '8 hours' │
│ AND permanentVideoUrl IS NULL │
│ │
│ 2️⃣ 更新状态:videoStorageStatus = 'archiving' │
│ │
│ 3️⃣ 下载视频: │
│ fetch(temporaryVideoUrl) → Buffer │
│ 📦 文件大小:约 5-20MB │
│ │
│ 4️⃣ 上传到S3(复用项目现有配置): │
│ S3 Key: videos/2025/12/09/msg-abc123-task-xyz.mp4 │
│ 使用项目已配置的 S3 客户端和存储桶 │
│ │
│ 5️⃣ 获取永久URL: │
│ 使用项目现有的URL生成逻辑 │
│ (可能是S3直连或通过CDN) │
│ │
│ 6️⃣ 更新数据库: │
│ UPDATE message SET │
│ permanentVideoUrl = 'https://cdn.yourdomain.com/...' │
│ videoStorageStatus = 'archived' │
│ archivedAt = NOW() │
│ videoMetadata = '{...}' │
│ WHERE id = 'msg-abc123' │
│ │
│ ✅ 归档完成! │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 阶段 4:永久访问(8小时后) │
├─────────────────────────────────────────────────────────────┤
│ 用户刷新页面或重新访问聊天: │
│ │
│ 前端逻辑: │
│ ```typescript │
│ const videoUrl = │
│ message.permanentVideoUrl || // 优先使用永久URL │
│ message.temporaryVideoUrl; // 降级到临时URL │
│ ``` │
│ │
│ 🔒 永久URL无过期时间,用户可以随时观看 │
│ ✅ 复用项目现有S3存储,无需额外配置 │
└─────────────────────────────────────────────────────────────┘
```
### **失败处理机制:**
```
归档失败(网络错误/S3错误)
↓
videoStorageStatus = 'failed'
↓
Cron下次执行时重试(最多3次)
↓
仍失败 → 发送告警通知管理员
↓
用户依然可以使用临时URL(24小时内)
```
### **数据库状态流转:**
```
temporary → archiving → archived ✅
↓ ↓
└────→ failed ❌ (可重试)
```
### **前端视频URL智能选择:**
```typescript
// 组件:VideoPreview.tsx
function getVideoUrl(message: Message): string {
// 1. 如果有永久URL,直接使用(已归档)
if (message.permanentVideoUrl) {
return message.permanentVideoUrl;
}
// 2. 如果有临时URL且在24小时内,使用临时URL
if (message.temporaryVideoUrl) {
const createdAt = new Date(message.createdAt);
const now = new Date();
const hoursPassed = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60);
if (hoursPassed < 24) {
return message.temporaryVideoUrl;
}
}
// 3. 都不可用,显示错误提示
return null; // 前端显示"视频已过期,请联系管理员"
}
```
### **用户体验保障:**
| 时间点 | 用户看到的视频 | 数据来源 | 体验 |
|-------|---------------|---------|------|
| **0-8小时** | 立即可看 | 临时URL(火山引擎) | ✅ 无缝体验 |
| **8-9小时** | 持续可看 | 临时URL(后台归档中) | ✅ 用户无感知 |
| **9小时后** | 持续可看 | 永久URL(S3/CDN) | ✅ 速度更快 |
| **24小时后** | 持续可看 | 永久URL(临时URL失效) | ✅ 永久保存 |
**关键优势:**
- ⚡ **零延迟**:用户生成后立即可看,不需要等待上传
- 🔄 **无感知**:归档过程完全在后台,用户无需任何操作
- 💾 **永久保存**:自动转存S3,历史视频永远可访问
- 💰 **成本优化**:8小时后才上传,避免测试/临时视频的无效存储
- ⚙️ **服务器友好**:每小时检查一次,降低系统负载
---
## 📷 图片永久存储机制(输入图片)
### **图片上传流程(用于视频生成):**
```
┌─────────────────────────────────────────────────────────────┐
│ 用户上传图片 │
├─────────────────────────────────────────────────────────────┤
│ 1️⃣ 用户点击 [+ 图片] 按钮 │
│ 2️⃣ 选择本地图片文件(JPEG/PNG/WebP) │
│ 3️⃣ 前端上传到 /api/files/upload │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ /api/files/upload 处理流程 │
├─────────────────────────────────────────────────────────────┤
│ 1️⃣ 验证用户登录状态 │
│ 2️⃣ 验证文件: │
│ - 大小:≤ 5MB │
│ - 类型:image/jpeg, image/png, image/webp │
│ 3️⃣ 调用统一存储服务:uploadFile(filename, buffer) │
│ 4️⃣ 返回永久URL: │
│ { url: "https://s3.../uploads/xxx.jpg" } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 统一存储服务(自动选择) │
├─────────────────────────────────────────────────────────────┤
│ 优先级 1:数据库配置的 S3/MinIO │
│ ├─ 查询 storage_config 表 │
│ ├─ 使用管理员配置的 S3 端点 │
│ └─ 返回公开URL │
│ │
│ 优先级 2:环境变量配置的 S3 │
│ ├─ S3_ENDPOINT, S3_BUCKET │
│ ├─ S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY │
│ └─ 上传到 S3 │
│ │
│ 优先级 3:Vercel Blob(回退方案) │
│ └─ 使用 @vercel/blob │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 前端获取永久URL │
├─────────────────────────────────────────────────────────────┤
│ 前端保存图片URL: │
│ const imageUrl = response.url; // S3永久URL │
│ │
│ 显示缩略图预览: │
│ <img src={imageUrl} /> │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 用户点击"生成视频" │
├─────────────────────────────────────────────────────────────┤
│ 1️⃣ 收集所有图片URL(已经是S3永久URL) │
│ 2️⃣ 发送到后端: │
│ { │
│ mode: "first-frame", │
│ firstFrameImage: "https://s3.../xxx.jpg", │
│ prompt: "女孩的头发被风吹动" │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 后端处理:下载图片并转Base64 │
├─────────────────────────────────────────────────────────────┤
│ 1️⃣ 从S3下载图片:fetch(imageUrl) │
│ 2️⃣ 转换为Base64(火山引擎API要求) │
│ 3️⃣ 发送到火山引擎API │
│ 4️⃣ 保存到数据库: │
│ videoMetadata: { │
│ inputImages: { │
│ firstFrame: "https://s3.../xxx.jpg" // 永久URL │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘
```
### **图片存储的关键优势:**
| 特性 | 实现方式 | 好处 |
|------|---------|------|
| **立即上传** | 用户选择图片后立即上传S3 | 图片永久可访问 |
| **统一服务** | 复用 `lib/storage/service.ts` | 无需重复代码 |
| **灵活配置** | 支持数据库/环境变量/Vercel Blob | 适应不同部署环境 |
| **永久保存** | 聊天记录中保存S3 URL | 历史对话可回溯 |
| **无需归档** | 上传即永久 | 无需定时任务处理 |
### **前端图片上传代码示例:**
```typescript
// components/video-params-bar.tsx
async function handleImageUpload(file: File) {
const formData = new FormData();
formData.append('file', file);
try {
// 上传到 S3
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.url) {
// 保存永久URL
setFirstFrameImage(data.url);
console.log('图片已上传到S3:', data.url);
// 示例:https://your-bucket.s3.amazonaws.com/uploads/1234567890-abc123.jpg
}
} catch (error) {
console.error('图片上传失败:', error);
}
}
```
### **对比:图片 vs 视频存储策略**
| 类型 | 上传时机 | 存储位置 | URL生命周期 | 归档策略 |
|------|---------|---------|------------|----------|
| **输入图片** | 用户选择时立即上传 | S3 | 永久 | 无需归档 ✅ |
| **生成视频** | 生成完成后8小时 | 临时URL → S3 | 24h → 永久 | 需要归档 ⏰ |
**原因:**
- **图片**:用户主动上传,确定要使用 → 立即存储
- **视频**:AI生成,可能是测试 → 8小时后再存储
---
## 🔄 前后端参数传递与模型匹配机制
### **完整数据流程:**
```
┌─────────────────────────────────────────────────────────────┐
│ 前端 (用户交互) │
├─────────────────────────────────────────────────────────────┤
│ 1. 用户选择火山引擎视频模型 │
│ 2. 点击 ⚙️ 参数设置按钮 │
│ 3. 在参数面板中设置: │
│ - 生成模式:图生视频-首尾帧 │
│ - 分辨率:1080p │
│ - 宽高比:16:9 │
│ - 时长:8秒 │
│ - 上传首帧和尾帧图片 │
│ 4. 点击"开始生成视频" │
└─────────────────────────────────────────────────────────────┘
↓
参数对象 (JSON)
↓
┌─────────────────────────────────────────────────────────────┐
│ 后端 (Chat API Route) │
├─────────────────────────────────────────────────────────────┤
│ 1. 接收参数: │
│ { │
│ mode: "first-last-frame", │
│ prompt: "360度环绕运镜", │
│ firstFrameImage: "data:image/png;base64,...", │
│ lastFrameImage: "data:image/png;base64,...", │
│ resolution: "1080p", │
│ ratio: "16:9", │
│ duration: 8 │
│ } │
│ │
│ 2. 🔍 查询数据库,获取所有启用的火山视频模型 │
│ │
│ 3. 🎯 智能模型匹配: │
│ - 筛选支持 "first-last-frame" 模式的模型 │
│ - 候选:Pro 250528 ✅ / Lite I2V ✅ │
│ │
│ 4. ✅ 参数验证: │
│ - Pro 250528 支持 1080p ✅ │
│ - 支持 16:9 ✅ │
│ - 时长 8秒 在 2-12秒 范围内 ✅ │
│ → 验证通过,选择 Pro 250528 │
│ │
│ 5. 🚀 调用 volcano-video.ts Provider │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Provider (volcano-video.ts) │
├─────────────────────────────────────────────────────────────┤
│ 1. 构建 API 请求: │
│ - model: "doubao-seedance-1-0-pro-250528" │
│ - content: [ │
│ { type: "text", text: "360度环绕运镜 --rt 16:9..." }│
│ { type: "image_url", image_url: {...}, role: "first_frame" }│
│ { type: "image_url", image_url: {...}, role: "last_frame" } │
│ ] │
│ │
│ 2. 📤 发送到火山引擎 API │
│ 3. ⏳ 轮询任务状态 (queued → running → succeeded) │
│ 4. ✅ 返回视频 URL │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 前端 (结果展示) │
├─────────────────────────────────────────────────────────────┤
│ 1. 显示 QianwenVideoLoading 加载动画 │
│ 2. 视频生成完成后,显示 VideoPreview 播放器 │
│ 3. 用户可下载、分享或继续生成 │
└─────────────────────────────────────────────────────────────┘
```
### **模型能力匹配表:**
| 生成模式 | 支持的模型 | 自动选择优先级 |
|---------|-----------|---------------|
| **文生视频** | Pro 250528 ⭐<br>Pro Fast 251015<br>Lite T2V | Pro 250528 (最高质量) |
| **图生视频-首帧** | Pro 250528 ⭐<br>Pro Fast 251015<br>Lite I2V | Pro 250528 (最高质量) |
| **图生视频-首尾帧** | Pro 250528 ⭐<br>Lite I2V | Pro 250528 (唯一支持高分辨率) |
| **图生视频-参考图** | Lite I2V ⭐<br>(1-4张) | Lite I2V (唯一支持) |
### **参数冲突处理:**
**场景 1:用户选择了不支持的组合**
```
用户设置:
- 模式:参考图
- 分辨率:1080p
- 模型:Lite I2V
系统处理:
❌ 错误提示:"参考图模式不支持 1080p 分辨率,请选择 480p 或 720p"
✅ 自动降级:分辨率改为 720p
```
**场景 2:当前模型不支持选择的模式**
```
用户设置:
- 当前模型:Lite T2V (仅支持文生视频)
- 切换模式:首尾帧
系统处理:
🔄 自动切换模型到 Pro 250528
💬 提示:"已自动切换到 Seedance Pro 模型(支持首尾帧功能)"
```
**场景 3:多模型可选时的智能推荐**
```
用户设置:
- 模式:文生视频
- 分辨率:720p
可选模型:
- Pro 250528 (全功能,速度较慢)
- Pro Fast 251015 (快速模式,质量略低)
- Lite T2V (轻量版)
系统推荐:
💡 "推荐使用 Pro Fast 模型,速度快且质量优秀"
⚙️ 用户可在设置中切换到其他模型
```
---
## 🎨 用户交互流程
### **场景 1:文生视频(横向参数栏)**
```
步骤 1:用户选择模型
用户:点击模型选择器 → "火山引擎视频 - Seedance Pro"
步骤 2:参数栏自动展开(底部)
┌──────────────────────────────────────────────────┐
│ 输入框区域 │
│ 输入文字,描述你想创作的画面内容、运动方式等... │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 参数栏(类似官方设计) │
│ [文生视频▼] [Seedance Pro] [16:9|1080p] [5s] │
│ [运镜:自由▼] [⚙️] [生成] │
└──────────────────────────────────────────────────┘
步骤 3:调整参数(可选)
用户点击 [16:9|1080p]:
┌─────────────────────────────────┐
│ 选择比例 │
│ [21:9] [●16:9] [4:3] [1:1] ... │
│ │
│ 选择分辨率 │
│ [720P] [●1080P+] │
└─────────────────────────────────┘
步骤 4:输入提示词并生成
用户输入:一只猫在钢琴上跳舞,镜头从侧面拉近
点击 [生成]
AI:好的,我将为您生成视频...
[显示 QianwenVideoLoading 动画]
⏳ 正在创建任务...
✅ 任务已提交(Task ID: cgt-2025xxxx)
🎬 正在生成视频(预计 1-3 分钟)...
[加载动画]
✅ 视频生成完成!
[视频播放器]
📹 分辨率:1920×1088 (16:9, 1080p)
⏱️ 时长:5秒
🎨 模型:Seedance Pro 250528
📥 [下载按钮]
底部仍显示参数栏,方便快速生成下一个视频
```
### **场景 2:图生视频-首帧(智能模式切换)**
```
步骤 1:选择模型后,参数栏自动展开
┌──────────────────────────────────────────────────┐
│ [+图片] 输入描述如何让图片动起来... │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ [文生视频▼] [Seedance Pro] [16:9|720p] [5s] │
│ [运镜:自由▼] [生成] │
└──────────────────────────────────────────────────┘
步骤 2:上传图片
用户点击 [+图片] → 上传女孩抱着狐狸.png
界面变化:
┌──────────────────────────────────────────────────┐
│ [×] [🖼️] 女孩睁开眼,头发被风吹动,镜头拉出... │
│ 已上传 │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ [●图生-首帧▼] [Seedance Pro] [adaptive|720p] │
│ ↑ 自动切换 │
│ [运镜:自由▼] [生成] │
└──────────────────────────────────────────────────┘
💡 系统提示:已检测到图片,自动切换到"图生视频-首帧"模式
步骤 3:输入提示词并生成
用户输入:女孩睁开眼,温柔地看向镜头,头发被风吹动
点击 [生成]
AI:正在处理首帧图片并生成视频...
[QianwenVideoLoading 动画]
✅ 完成!
[并排显示:原图 | 生成的视频播放器]
```
### **场景 3:参考图融合(自动模型切换)**
```
步骤 1:选择参考图模式
用户点击参数栏 [文生视频▼] → 选择"参考图融合"
界面变化:
┌──────────────────────────────────────────────────┐
│ [+参考图 (0/4)] 描述如何融合参考图元素... │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ [●参考图融合New] [Seedance Lite I2V] [16:9|720p]│
│ ↑ 自动切换到唯一支持的模型 │
│ [运镜:自由▼] [生成] │
└──────────────────────────────────────────────────┘
⚠️ 系统提示:已自动切换到 Seedance Lite I2V 模型
💡 参考图模式不支持 1080p,已调整为 720p
步骤 2:上传参考图
用户点击 [+参考图] 3次,上传:男生.png, 柯基.png, 草坪.png
界面变化:
┌──────────────────────────────────────────────────┐
│ [×🖼️] [×🖼️] [×🖼️] [+参考图 (3/4)] │
│ 男生 柯基 草坪 │
│ │
│ [图1]男生和[图2]柯基在[图3]草坪上玩耍,3D卡通风格│
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ [●参考图融合] [Seedance Lite I2V] [16:9|720p] │
│ [运镜:自由▼] [生成] │
└──────────────────────────────────────────────────┘
步骤 3:生成融合视频
点击 [生成]
AI:正在融合 3 张参考图并生成视频...
[显示 3 张缩略图排列]
🎬 正在生成包含以下元素的视频:
✓ 图1:男生(戴眼镜穿蓝T恤)
✓ 图2:柯基犬(棕白色)
✓ 图3:草坪背景(绿色草地)
[生成结果视频 - 3D 卡通风格,元素自然融合]
```
### **场景 4:首尾帧过渡(专业模式)**
```
步骤 1:选择高级模式
用户:我想生成一个 360 度环绕运镜的视频
AI:这需要使用"首尾帧过渡"模式,请上传首帧和尾帧图片。
步骤 2:配置专业参数
┌─────────────────────────────────┐
│ 🎬 视频生成参数设置 (专业模式) │
├─────────────────────────────────┤
│ 模式:● 图生视频-首尾帧 │
│ 首帧:[已上传] 📷 │
│ 尾帧:[已上传] 📷 │
│ │
│ 分辨率:● 1080p │
│ 宽高比:● 16:9 │
│ 时长:[======●==] 8秒 │
│ 水印:☐ 不显示 │
│ 摄像头:☑ 固定运动轨迹 │
├─────────────────────────────────┤
│ 高级选项 ▼ │
│ 种子:[42] ← 锁定随机性 │
│ 返回尾帧:☑ 用于下一段生成 │
├─────────────────────────────────┤
│ 提示词:360度环绕运镜 │
│ [开始生成视频] │
└─────────────────────────────────┘
步骤 3:连续生成
AI:第一段视频生成完成!
是否继续生成下一段?(将使用本段尾帧作为下一段首帧)
[继续生成] [完成]
```
---
## ⚙️ 技术难点与解决方案
### **难点 1:异步任务管理**
**问题**:视频生成耗时 1-3 分钟,如何优雅处理长时间等待?
**解决方案**:
- 使用流式响应(Server-Sent Events)推送进度
- 前端显示实时状态和预估剩余时间
- 支持任务后台运行(用户可以离开再回来)
- 实现任务列表,查看历史生成记录
### **难点 2:图片裁剪规则**
**问题**:用户上传的图片宽高比可能与指定的视频宽高比不一致
**解决方案**:
- 参考 API 文档的居中裁剪算法
- 前端预览时显示裁剪区域
- 提供"智能适配"选项(ratio: adaptive)
### **难点 3:多图片输入处理**
**问题**:不同模式需要不同数量的图片(0-4张)
**解决方案**:
- 统一图片输入接口,通过 `role` 字段区分
- 首帧:`role: "first_frame"`
- 尾帧:`role: "last_frame"`
- 参考图:`role: "reference_image"`
- 前端根据模式动态显示上传组件
### **难点 4:连续视频生成**
**问题**:如何实现多段视频的自动连接?
**解决方案**:
- 第一段视频启用 `return_last_frame: true`
- 自动将尾帧作为下一段的首帧
- 提供"连续生成"模式,用户一次性输入多个提示词
- 前端显示生成进度(如:正在生成第 2/5 段)
---
## 📊 测试计划
### **功能测试**
- [ ] **生成模式测试**
- [ ] 文生视频(多种分辨率/宽高比)
- [ ] 图生视频-首帧模式
- [ ] 图生视频-首尾帧模式
- [ ] 图生视频-参考图模式(1-4张)
- [ ] **参数传递测试**
- [ ] 分辨率切换(480p/720p/1080p)
- [ ] 宽高比切换(16:9/9:16/1:1等)
- [ ] 时长调整(2-12秒)
- [ ] 水印开关
- [ ] 固定摄像头
- [ ] 种子控制(随机性测试)
- [ ] 返回尾帧功能
- [ ] **模型匹配测试** 🆕
- [ ] 自动选择支持的模型
- [ ] 参数冲突检测(如:参考图 + 1080p)
- [ ] 模型切换提示
- [ ] 参数自动降级
- [ ] **参数面板测试** 🆕
- [ ] 动态显示/隐藏选项(根据模型能力)
- [ ] 图片上传(单图/双图/多图)
- [ ] 实时参数验证
- [ ] 预设模板快速应用
- [ ] **错误处理**
- [ ] API 失败、超时、格式错误
- [ ] 参数超出范围
- [ ] 不支持的模式/参数组合
- [ ] 图片格式/大小错误
### **性能测试**
- [ ] 并发任务限制(RPM & 并发数)
- [ ] 轮询频率优化(避免过度请求)
- [ ] 超时处理(10 分钟无响应)
- [ ] 大图片上传性能(Base64 转换)
- [ ] 多模型并发生成
### **UI/UX 测试**
- [ ] 加载动画流畅度
- [ ] 视频播放器兼容性(多浏览器)
- [ ] 移动端适配
- [ ] **参数面板交互** 🆕
- [ ] 面板打开/关闭动画
- [ ] 参数实时预览
- [ ] 提示文案清晰度
- [ ] 错误提示友好性
### **集成测试** 🆕
- [ ] 系统设置 → 前端参数栏联动
- [ ] 模型能力配置 → 参数验证
- [ ] 默认参数应用
- [ ] 多模型切换无缝衔接
### **视频归档测试** 🆕
- [ ] **归档流程测试**
- [ ] 视频生成后正确保存临时URL
- [ ] 8小时后自动触发归档任务
- [ ] 视频成功下载并上传到S3(复用现有配置)
- [ ] 数据库URL正确更新
- [ ] 状态流转正确(temporary → archiving → archived)
- [ ] **失败处理测试**
- [ ] 临时URL失效时的降级处理
- [ ] S3上传失败时的重试机制
- [ ] 归档失败后的错误标记
- [ ] 告警通知是否触发
- [ ] **前端URL切换测试**
- [ ] 5分钟内使用临时URL
- [ ] 5分钟后自动使用永久URL
- [ ] 刷新页面后URL正确显示
- [ ] 8小时内使用临时URL,8小时后优先使用永久URL
- [ ] 24小时后临时URL失效,永久URL正常
- [ ] **性能测试**
- [ ] 并发归档(3个视频同时处理)
- [ ] 大文件处理(20MB+视频)
- [ ] Cron任务执行效率
- [ ] S3上传速度优化
- [ ] **Cron任务测试**
- [ ] 定时任务正确触发(每小时一次)
- [ ] 授权验证机制
- [ ] 任务执行日志记录
- [ ] 异常情况处理
- [ ] 8小时延迟正确生效
---
## 📅 开发排期
| 阶段 | 任务 | 预计工作量 | 备注 |
|------|------|-----------|------|
| 阶段 1 | 数据库与类型系统扩展 | **1.5 天** | 🆕 模型能力 + 视频存储状态字段 |
| 阶段 2 | 核心 Provider 开发 | 2 天 | 包含智能模型选择逻辑 |
| **阶段 3** | **视频永久存储系统** | **2 天** | 🆕 **归档服务 + 定时任务 + S3上传** |
| 阶段 4 | **前端参数栏开发** | **1.5 天** | 🆕 横向布局(参考官方设计) |
| 阶段 5 | 前端 UI 组件开发(视频预览) | **1 天** | ✅ 复用 `QianwenVideoLoading` |
| 阶段 6 | Chat API 集成 | **1.5 天** | 🆕 参数验证 + 临时URL保存 |
| 阶段 7 | 管理后台配置 | **1.5 天** | 🆕 模型能力配置 UI、预设模板 |
| 测试与优化 | 功能测试、性能优化、Bug 修复 | 2 天 | 🆕 增加归档任务测试 |
| **总计** | | **13 天** | 🎯 **完整的视频生成 + 永久存储系统** |
---
## 🚀 部署与上线
### **环境变量配置**
```env
# 火山引擎 ARK API Key
ARK_API_KEY=your_api_key_here
# 视频生成超时时间(毫秒)
VIDEO_GENERATION_TIMEOUT=600000
# 最大并发任务数
MAX_CONCURRENT_VIDEO_TASKS=5
# 🆕 S3配置(视频归档)
# ✅ 项目已有S3配置,直接复用即可
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your_access_key
# AWS_SECRET_ACCESS_KEY=your_secret_key
# S3_BUCKET_NAME=ai-chatbot-videos
# 🆕 Cron任务密钥(用于验证定时任务请求)
CRON_SECRET=your_random_secret_key_here
# 🆕 视频归档配置
VIDEO_ARCHIVE_DELAY_HOURS=8 # 归档延迟时间(默认8小时)
VIDEO_ARCHIVE_MAX_RETRIES=3 # 最大重试次数
VIDEO_ARCHIVE_CONCURRENCY=5 # 并发归档数量(8小时周期可处理更多)
```
### **部署检查清单**
- [ ] **数据库**
- [ ] 执行迁移脚本(新增视频存储字段)
- [ ] 验证表结构更新
- [ ] 创建索引(videoStorageStatus, createdAt)
- [ ] **环境变量**
- [ ] 火山引擎 ARK_API_KEY 配置
- [ ] AWS S3 凭证配置
- [ ] CRON_SECRET 生成并配置
- [ ] CDN域名配置(可选)
- [ ] **S3存储**
- [ ] ✅ 确认项目现有S3配置可用
- [ ] ✅ 验证视频上传权限
- [ ] 配置 CORS 策略(如需要)
- [ ] **Cron任务**
- [ ] 配置 Vercel Cron(每小时执行一次)
- [ ] 生成并配置 CRON_SECRET
- [ ] 测试定时任务触发
- [ ] 验证授权机制
- [ ] 设置告警通知(可选)
- [ ] **API权限**
- [ ] 火山引擎 API Key 有效性验证
- [ ] 模型权限开通确认
- [ ] **应用部署**
- [ ] 前端资源构建
- [ ] 服务器重启(PM2)
- [ ] 生产环境测试
- [ ] 监控日志输出
---
## 📝 文档与维护
### **用户文档**
- 视频生成功能使用指南
- 参数说明与最佳实践
- 常见问题 FAQ
### **开发者文档**
- API 接口文档
- 代码架构说明
- 扩展开发指南
### **未来优化方向**
- 创建通用 `VideoLoading` 组件,支持自定义参数:
- 估计生成时间(30s / 1-3min / 可变)
- 服务商名称(千问 / 火山引擎)
- 占位图尺寸(自适应不同场景)
- 统一所有视频生成服务的加载体验
---
## ⚠️ 注意事项
1. **任务数据保留时间**:生成的视频 URL 仅保留 24 小时,需及时下载或转存到自有存储
2. **限流规制**:
- RPM 限制:每分钟最多创建 300-600 个任务(根据模型不同)
- 并发限制:同时最多 5-10 个任务在处理中
3. **费用成本**:按 token 计费,建议监控用量
4. **内容审核**:火山引擎可能对生成内容进行审核,敏感内容可能被拒绝
5. **加载组件优化(可选)**:
- 当前复用 `QianwenVideoLoading` 组件,提示文字为"30-60 秒"
- 火山引擎视频生成实际耗时约 1-3 分钟
- 可在后续优化中支持自定义提示文字,或创建通用的 `VideoLoading` 组件
6. **图片和视频存储** 🆕:
**图片存储(输入图片):**
- **上传时机**:用户选择图片后立即上传到S3
- **存储服务**:✅ 复用 `lib/storage/service.ts` 统一存储服务
- **支持格式**:JPEG, PNG, WebP(≤5MB)
- **URL生命周期**:永久(无需归档)
- **优先级**:数据库配置S3 → 环境变量S3 → Vercel Blob
- **保存位置**:`videoMetadata.inputImages` 字段
**视频存储(输出视频):**
- **临时URL有效期**:火山引擎返回的视频URL仅保留24小时
- **归档延迟**:默认8小时后归档(可通过环境变量调整)
- **延迟原因**:
- 过滤测试视频(用户可能生成后立即删除对话)
- 降低系统负载(避免频繁上传)
- 8小时内用户可继续使用临时URL,体验无差异
- **S3配置**:✅ 直接复用项目现有统一存储服务
- **Cron频率**:每1小时执行一次(比5分钟降低92%的执行次数)
- **失败重试**:归档失败会在下次Cron执行时自动重试(最多3次)
- **成本优化**:
- 8小时延迟可过滤约30-50%的临时/测试视频
- 单个5秒720p视频约5-10MB
- 1000个视频约10GB存储 ≈ $0.23/月
**图片存储(输入图片)** 🆕:
- **上传时机**:用户选择图片后立即上传S3
- **存储服务**:✅ 复用 `lib/storage/service.ts`(已存在)
- **支持格式**:JPEG, PNG, WebP(≤5MB)
- **URL生命周期**:永久(无需归档任务)
- **成本**:单张图片约1-2MB,1000张约2GB ≈ $0.05/月
- 输入图片约1-2MB/张,立即上传
---
## ✅ 验收标准
- [x] 所有 4 种生成模式均可正常工作
- [x] 视频生成进度实时显示
- [x] 支持自定义参数(分辨率、时长等)
- [x] 错误处理完善,用户友好的提示
- [x] 管理后台可配置模型和 API Key
- [x] 视频可正常播放和下载
- [x] 移动端和桌面端体验良好
- [x] 代码符合 Ultracite 规范(无 Lint 错误)
---
---
## 📎 附录:模型能力预设配置
### **预设配置数据(用于系统初始化)**
```typescript
// 火山引擎视频模型能力配置预设
export const VOLCANO_VIDEO_MODEL_PRESETS: VideoModelCapabilities[] = [
{
modelId: 'doubao-seedance-1-0-pro-250528',
modelName: '豆包 Seedance Pro 250528 (全功能版)',
supportedModes: ['text-to-video', 'first-frame', 'first-last-frame'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
durationRange: { min: 2, max: 12 },
supportWatermark: true,
supportCameraFixed: true,
supportSeed: true,
supportReturnLastFrame: true,
// 限流信息
rpmLimit: 600,
concurrencyLimit: 10,
// 推荐场景
recommendedFor: ['高质量视频', '首尾帧过渡', '专业创作']
},
{
modelId: 'doubao-seedance-1-0-pro-fast-251015',
modelName: '豆包 Seedance Pro Fast 251015 (快速模式)',
supportedModes: ['text-to-video', 'first-frame'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
durationRange: { min: 2, max: 12 },
supportWatermark: true,
supportCameraFixed: false, // 快速模式不支持固定摄像头
supportSeed: false,
supportReturnLastFrame: true,
rpmLimit: 600,
concurrencyLimit: 10,
recommendedFor: ['快速生成', '批量创作', '内容预览']
},
{
modelId: 'doubao-seedance-1-0-lite-t2v-250428',
modelName: '豆包 Seedance Lite T2V 250428 (文生视频轻量版)',
supportedModes: ['text-to-video'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
durationRange: { min: 2, max: 12 },
supportWatermark: true,
supportCameraFixed: true,
supportSeed: true,
supportReturnLastFrame: false,
rpmLimit: 300,
concurrencyLimit: 5,
recommendedFor: ['轻量级应用', '文本转视频', '成本控制']
},
{
modelId: 'doubao-seedance-1-0-lite-i2v-250428',
modelName: '豆包 Seedance Lite I2V 250428 (图生视频轻量版)',
supportedModes: ['first-frame', 'first-last-frame', 'reference-images'],
supportedResolutions: ['480p', '720p'], // 注意:参考图模式不支持 1080p
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', 'adaptive'],
durationRange: { min: 2, max: 12 },
supportWatermark: true,
supportCameraFixed: true, // 注意:参考图模式不支持
supportSeed: true,
supportReturnLastFrame: false,
maxReferenceImages: 4, // 参考图模式最多 4 张
rpmLimit: 300,
concurrencyLimit: 5,
recommendedFor: ['参考图融合', '图片动态化', '多元素组合'],
// 特殊约束
constraints: {
'reference-images': {
supportedResolutions: ['480p', '720p'], // 参考图不支持 1080p
supportCameraFixed: false, // 参考图不支持固定摄像头
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'] // 不支持 adaptive
}
}
}
];
// 默认参数配置
export const DEFAULT_VIDEO_PARAMS: Partial<VideoGenerationOptions> = {
resolution: '720p',
ratio: '16:9',
duration: 5,
watermark: true,
cameraFixed: false,
fps: 24
};
// 快速预设模板
export const VIDEO_PRESETS = {
'short-video': {
name: '短视频 (抖音/快手)',
params: { resolution: '1080p', ratio: '9:16', duration: 5, watermark: false }
},
'hd-landscape': {
name: '横屏高清 (B站/YouTube)',
params: { resolution: '1080p', ratio: '16:9', duration: 8, watermark: false }
},
'square-social': {
name: '方形社交媒体',
params: { resolution: '720p', ratio: '1:1', duration: 6, watermark: false }
},
'cinema': {
name: '电影宽屏',
params: { resolution: '1080p', ratio: '21:9', duration: 10, watermark: false, cameraFixed: true }
}
};
```
### **数据库迁移脚本示例**
**文件**:`lib/db/migrations/0013_add_video_storage.sql`
```sql
-- ==========================================
-- 1. 添加模型能力配置字段
-- ==========================================
ALTER TABLE "ai_model"
ADD COLUMN IF NOT EXISTS "capabilities" JSONB,
ADD COLUMN IF NOT EXISTS "default_params" JSONB;
-- ==========================================
-- 2. 添加视频存储相关字段到 message 表
-- ==========================================
-- 视频存储状态
ALTER TABLE "message"
ADD COLUMN IF NOT EXISTS "video_storage_status" VARCHAR(20) DEFAULT 'temporary';
-- 临时视频URL(火山引擎24小时有效)
ALTER TABLE "message"
ADD COLUMN IF NOT EXISTS "temporary_video_url" TEXT;
-- 永久视频URL(S3存储)
ALTER TABLE "message"
ADD COLUMN IF NOT EXISTS "permanent_video_url" TEXT;
-- 归档完成时间
ALTER TABLE "message"
ADD COLUMN IF NOT EXISTS "archived_at" TIMESTAMP;
-- 视频元数据(JSON格式,包含输入图片URL)
ALTER TABLE "message"
ADD COLUMN IF NOT EXISTS "video_metadata" JSONB;
COMMENT ON COLUMN "message"."video_metadata" IS '视频元数据:{taskId, duration, resolution, ratio, fileSize, s3Key, inputImages: {firstFrame, lastFrame, references}}';
-- ==========================================
-- 3. 创建索引(提高归档任务查询性能)
-- ==========================================
-- 用于查询待归档视频的复合索引
CREATE INDEX IF NOT EXISTS "idx_message_video_archive"
ON "message"("video_storage_status", "created_at")
WHERE "video_storage_status" = 'temporary'
AND "permanent_video_url" IS NULL;
-- 用于快速定位已归档视频
CREATE INDEX IF NOT EXISTS "idx_message_permanent_video"
ON "message"("permanent_video_url")
WHERE "permanent_video_url" IS NOT NULL;
-- ==========================================
-- 4. 添加注释(可选,便于理解)
-- ==========================================
COMMENT ON COLUMN "message"."video_storage_status" IS '视频存储状态:temporary(临时)/archiving(归档中)/archived(已归档)/failed(失败)';
COMMENT ON COLUMN "message"."temporary_video_url" IS '火山引擎返回的临时URL(24小时有效)';
COMMENT ON COLUMN "message"."permanent_video_url" IS 'S3存储的永久URL';
COMMENT ON COLUMN "message"."archived_at" IS '视频归档完成时间';
COMMENT ON COLUMN "message"."video_metadata" IS '视频元数据:{taskId, duration, resolution, ratio, fileSize, s3Key}';
```
**执行迁移:**
```bash
# 方式1:使用 Drizzle ORM 自动迁移
npm run db:migrate
# 方式2:手动执行 SQL
psql -d your_database -f lib/db/migrations/0013_add_video_storage.sql
```
**验证迁移结果:**
```sql
-- 检查字段是否添加成功
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'message'
AND column_name IN (
'video_storage_status',
'temporary_video_url',
'permanent_video_url',
'archived_at',
'video_metadata'
);
-- 检查索引是否创建成功
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'message'
AND indexname LIKE '%video%';
```
-- 插入火山引擎视频模型配置(示例)
INSERT INTO "ai_model" (
"provider_id",
"model_id",
"model_name",
"is_enabled",
"capabilities",
"default_params"
) VALUES (
(SELECT id FROM "ai_provider" WHERE type = 'volcano-video' LIMIT 1),
'doubao-seedance-1-0-pro-250528',
'豆包 Seedance Pro 250528 (全功能版)',
true,
'{
"supportedModes": ["text-to-video", "first-frame", "first-last-frame"],
"supportedResolutions": ["480p", "720p", "1080p"],
"supportedRatios": ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
"durationRange": {"min": 2, "max": 12},
"supportWatermark": true,
"supportCameraFixed": true,
"supportSeed": true,
"supportReturnLastFrame": true
}'::jsonb,
'{
"resolution": "720p",
"ratio": "16:9",
"duration": 5,
"watermark": true,
"cameraFixed": false
}'::jsonb
);
```
---
**请确认此开发计划是否符合您的需求?如有调整或补充,请告知,我将立即开始实施!** 🚀