105 KiB
火山引擎视频生成服务 - 开发计划
📋 项目概述
为 AI Chatbot 系统新增独立的"火山引擎视频"服务商,支持基于豆包 Seedance 模型的视频生成能力,包括文生视频、图生视频等多种场景。
🎯 核心功能规划
1️⃣ 文生视频(Text-to-Video)
- 功能描述:用户输入文本提示词,AI 生成视频
- 支持模型:
doubao-seedance-1-0-pro-250528doubao-seedance-1-0-pro-fast-251015doubao-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-250528doubao-seedance-1-0-pro-fast-251015doubao-seedance-1-0-lite-i2v-250428
- 使用场景:
- 静态图片动态化
- 图片续写视频故事
3️⃣ 图生视频-首尾帧模式(Image-to-Video: First & Last Frame)
- 功能描述:上传首帧 + 尾帧图片,AI 生成中间过渡视频
- 支持模型:
doubao-seedance-1-0-pro-250528doubao-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 表(已更新)
-- 表名:AiProvider
-- 类型约束已更新,支持 'volcano-video'
CHECK (type IN ('alibaba', 'openai', 'custom', 'local', 'gemini',
'qianwen-video', 'volcano-vision', 'volcano-video'))
✅ AiModel 表(已扩展)
-- 完整表结构
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 表(已扩展)
-- 新增视频存储相关字段
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)
// 更新服务商类型枚举
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
// 视频生成模式
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
核心功能模块:
// 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
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小时后自动触发归档
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触发(每小时执行一次)
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
{
"crons": [
{
"path": "/api/jobs/archive-videos",
"schedule": "0 * * * *"
}
]
}
说明:
0 * * * *= 每小时执行一次(整点触发)- 由于归档延迟设为8小时,每小时检查一次即可
- 相比5分钟周期,大幅降低服务器负载
或使用外部Cron服务(cron-job.org / GitHub Actions):
# 每小时执行一次
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 + │ │
│ └─────┘ └────────┘ │
└──────────────────────────────────────┘
核心功能:
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>
);
};
智能交互逻辑:
-
模式联动模型选择:
- 用户选择"首尾帧过渡" → 自动筛选支持该模式的模型
- 如果当前模型不支持,自动切换到
doubao-seedance-1-0-pro-250528 - 在模式下拉菜单中,不支持的模式显示为禁用状态(灰色)
-
动态图片上传区域:
- 选择"文生视频" → 隐藏图片上传按钮
- 选择"图生视频-首帧" → 显示
[+ 图片]按钮 - 选择"首尾帧" → 显示
[+ 首帧] [+ 尾帧]两个按钮 - 选择"参考图" → 显示
[+ 参考图1-4]可多次点击,最多4张
-
参数约束与提示:
- 根据模型能力禁用不支持的选项(按钮显示为灰色 disabled)
- 参数冲突时显示 Tooltip 提示:
"参考图模式不支持 1080p 分辨率" "Lite 模型不支持固定摄像头功能" - 1080p 选项带
+图标,表示高级功能
-
实时参数同步:
- 用户调整任何参数 → 立即触发
onParamsChange回调 - 参数实时保存到本地存储(localStorage)
- 下次打开时恢复上次的参数设置
- 用户调整任何参数 → 立即触发
-
视觉反馈:
- 当前选中的参数按钮使用
variant="default"高亮显示 - 悬停时显示详细信息(如:16:9 → "宽屏格式,适合电脑/电视观看")
- 参数变更时,按钮有短暂的动画效果
- 当前选中的参数按钮使用
3.2 集成到聊天界面(参考官方布局)
文件:app/(chat)/page.tsx 或相关聊天组件
实现方案:
// 聊天界面布局(完整结构)
<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 动态提示:
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 分钟)
使用示例:
// 在 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
{
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 智能模型选择逻辑
功能:根据用户选择的生成模式,自动选择合适的模型
// 获取支持指定模式的模型
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保存 🆕
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
新增验证:
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
功能需求:添加模型时配置能力参数
// 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智能选择:
// 组件: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 | 历史对话可回溯 |
| 无需归档 | 上传即永久 | 无需定时任务处理 |
前端图片上传代码示例:
// 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 ⭐ Pro Fast 251015 Lite T2V |
Pro 250528 (最高质量) |
| 图生视频-首帧 | Pro 250528 ⭐ Pro Fast 251015 Lite I2V |
Pro 250528 (最高质量) |
| 图生视频-首尾帧 | Pro 250528 ⭐ Lite I2V |
Pro 250528 (唯一支持高分辨率) |
| 图生视频-参考图 | Lite I2V ⭐ (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 天 | 🎯 完整的视频生成 + 永久存储系统 |
🚀 部署与上线
环境变量配置
# 火山引擎 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 / 可变)
- 服务商名称(千问 / 火山引擎)
- 占位图尺寸(自适应不同场景)
- 统一所有视频生成服务的加载体验
⚠️ 注意事项
-
任务数据保留时间:生成的视频 URL 仅保留 24 小时,需及时下载或转存到自有存储
-
限流规制:
- RPM 限制:每分钟最多创建 300-600 个任务(根据模型不同)
- 并发限制:同时最多 5-10 个任务在处理中
-
费用成本:按 token 计费,建议监控用量
-
内容审核:火山引擎可能对生成内容进行审核,敏感内容可能被拒绝
-
加载组件优化(可选):
- 当前复用
QianwenVideoLoading组件,提示文字为"30-60 秒" - 火山引擎视频生成实际耗时约 1-3 分钟
- 可在后续优化中支持自定义提示文字,或创建通用的
VideoLoading组件
- 当前复用
-
图片和视频存储 🆕:
图片存储(输入图片):
- 上传时机:用户选择图片后立即上传到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/张,立即上传
✅ 验收标准
- 所有 4 种生成模式均可正常工作
- 视频生成进度实时显示
- 支持自定义参数(分辨率、时长等)
- 错误处理完善,用户友好的提示
- 管理后台可配置模型和 API Key
- 视频可正常播放和下载
- 移动端和桌面端体验良好
- 代码符合 Ultracite 规范(无 Lint 错误)
📎 附录:模型能力预设配置
预设配置数据(用于系统初始化)
// 火山引擎视频模型能力配置预设
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
-- ==========================================
-- 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}';
执行迁移:
# 方式1:使用 Drizzle ORM 自动迁移
npm run db:migrate
# 方式2:手动执行 SQL
psql -d your_database -f lib/db/migrations/0013_add_video_storage.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 );
---
**请确认此开发计划是否符合您的需求?如有调整或补充,请告知,我将立即开始实施!** 🚀