ai-chatbot/火山引擎视频服务开发计划.md
Your Name 53ddb7e585
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Initial commit - clean repository
2025-12-17 20:15:18 +08:00

105 KiB
Raw Blame History

火山引擎视频生成服务 - 开发计划

📋 项目概述

为 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 表(已更新)

-- 表名: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>
  );
};

智能交互逻辑:

  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 或相关聊天组件

实现方案:

// 聊天界面布局(完整结构)
<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 / 可变)
    • 服务商名称(千问 / 火山引擎)
    • 占位图尺寸(自适应不同场景)
  • 统一所有视频生成服务的加载体验

⚠️ 注意事项

  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/张,立即上传

✅ 验收标准

  • 所有 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 );


---

**请确认此开发计划是否符合您的需求?如有调整或补充,请告知,我将立即开始实施!** 🚀