Sistine Starter

AI 视频生成功能

基于火山引擎豆包 API 的异步视频生成实现指南

AI 视频生成功能

Sistine Starter 集成了火山引擎豆包 API 的视频生成功能,支持图生视频 (Image-to-Video)文生视频 (Text-to-Video)。由于视频生成是异步任务,需要通过轮询机制检查任务状态。本文档详细介绍视频生成系统的架构、异步流程和集成方法。

功能概览

核心特性

  • 图生视频 (I2V): 基于静态图像生成动态视频
  • 文生视频 (T2V): 根据文本描述生成视频
  • 异步任务处理: 提交任务后通过轮询获取结果
  • 积分扣费: 每次生成消耗 50 积分,事务性扣费确保数据一致性
  • 结果存储: 生成结果自动上传到 R2 存储,长期保存
  • 超时保护: 5 分钟超时机制防止任务卡死
  • 多参数支持: 分辨率、时长、水印、镜头固定等

技术架构

用户提交生成请求

身份认证 (Better Auth)

检查积分余额 (50 积分)

创建生成记录 (generationHistory)

扣除积分 + 记录账本

调用火山引擎 API (异步任务)

返回 taskId

前端轮询任务状态 (/api/video/status)

任务完成 → 上传到 R2 存储

更新生成记录 (completed)

返回视频 URL

火山引擎配置

模型选择

当前使用的模型是 doubao-seedance-1-0-pro-250528,这是火山引擎豆包系列的视频生成专用模型。

模型特性:

  • 支持图生视频和文生视频
  • 输出分辨率: 最高 1080p
  • 视频时长: 5-15 秒
  • 生成时间: 约 2-5 分钟/个

配置位置: lib/volcano-engine/video.ts:105

const model = volcanoEngineConfig.videoModel || 'doubao-seedance-1-0-pro-250528';

API 参数配置

可用参数:

参数类型默认值说明
promptstring可选视频生成的文本描述 (文生视频时必需)
imageUrlstring可选参考图像 URL (图生视频时必需)
resolutionstring-输出分辨率 (480p / 720p / 1080p)
durationnumber-视频时长 (秒),建议 5-15 秒
watermarkboolean-是否添加水印
cameraFixedboolean-镜头是否固定 (图生视频时有效)

调整参数示例:

// 生成高清视频,镜头固定
const result = await volcanoEngine.generateVideoFromImage(
  imageUrl,
  '人物缓慢微笑',
  {
    resolution: '1080p',
    duration: 10,
    watermark: false,
    cameraFixed: true,
  }
);

分辨率说明

选项说明积分建议生成时间
480p标清 (约 854x480)40 积分约 2 分钟
720p高清 (约 1280x720)50 积分约 3 分钟
1080p全高清 (约 1920x1080)70 积分约 5 分钟

数据库架构

生成历史表 (generationHistory)

视频生成使用与图像生成相同的表,通过 type 字段区分。建表语句请参考 数据库 文档中的 generationHistory 表定义。

关键字段说明:

字段类型说明
typeTEXT固定为 'video'
promptTEXT用户输入的提示词 (图生视频时为动作描述)
imageUrlTEXT参考图像 URL (图生视频必需,文生视频为 null)
taskIdTEXT火山引擎返回的任务 ID,用于轮询状态
resultUrlTEXT视频完成后的 URL (存储在 R2)
statusTEXTpendingprocessingcompleted / failed
creditsUsedINTEGER固定为 50 积分

Metadata 示例:

{
  "duration": 10,
  "resolution": "1080p",
  "watermark": false,
  "cameraFixed": true
}

异步任务流程

流程图

┌─────────────────────────────────────────────────────────┐
│  1. 用户提交生成请求 (POST /api/video/generate)        │
│     - 扣除 50 积分                                       │
│     - 创建 generationHistory 记录 (status: pending)     │
│     - 调用火山引擎 API                                   │
│     - 返回 taskId 和 historyId                          │
└────────────────────┬────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│  2. 前端开始轮询 (GET /api/video/status?taskId=xxx)    │
│     每 5 秒轮询一次                                      │
└────────────────────┬────────────────────────────────────┘


          ┌─────────┴─────────┐
          │  任务仍在处理中?   │
          └─────────┬─────────┘

         ┌──────────┼──────────┐
         │          │          │
        是         否          │
         │          │          │
         ▼          ▼          │
    继续轮询    任务完成      │
                    │          │
                    ▼          │
    ┌───────────────────────┐ │
    │  3. 上传视频到 R2     │ │
    │  4. 更新记录为       │ │
    │     completed         │ │
    │  5. 返回视频 URL      │ │
    └───────────────────────┘ │


                      ┌───────────────┐
                      │  任务失败或   │
                      │  超时 (5分钟) │
                      │  更新为 failed│
                      └───────────────┘

步骤详解

步骤 1: 提交生成请求

端点: POST /api/video/generate

实现文件: app/api/video/generate/route.ts

请求格式:

// 图生视频 (Image-to-Video)
{
  "imageUrl": "https://example.com/photo.jpg",
  "prompt": "人物缓慢微笑,背景保持静止",
  "duration": 10,
  "resolution": "1080p",
  "watermark": false
}

// 文生视频 (Text-to-Video)
{
  "prompt": "夕阳下的海滩,海浪轻轻拍打岸边",
  "duration": 15,
  "resolution": "720p",
  "watermark": false
}

响应格式:

{
  "id": "uuid-history-id",
  "taskId": "volcano-task-id-xxx",
  "status": "processing",
  "remainingCredits": 250
}

关键字段:

  • id: 生成记录 ID (用于后续查询历史)
  • taskId: 火山引擎任务 ID (必须传给状态查询接口)
  • status: 初始状态为 "processing"

步骤 2: 轮询任务状态

端点: GET /api/video/status?taskId=xxx&historyId=xxx

实现文件: app/api/video/status/route.ts

请求参数:

参数类型必需说明
taskIdstring火山引擎返回的任务 ID
historyIdstring生成记录 ID (用于权限验证)

响应格式 (处理中):

{
  "id": "uuid-history-id",
  "taskId": "volcano-task-id-xxx",
  "status": "processing",
  "taskStatus": "RUNNING",
  "statusText": "视频生成中",
  "elapsedSeconds": 45
}

响应格式 (完成):

{
  "id": "uuid-history-id",
  "taskId": "volcano-task-id-xxx",
  "status": "completed",
  "videoUrl": "https://your-r2-storage.com/videos/user_123/video_xxx.mp4"
}

响应格式 (失败):

{
  "id": "uuid-history-id",
  "taskId": "volcano-task-id-xxx",
  "status": "failed",
  "error": "Video generation timeout after 5 minutes"
}

API 实现详解

生成端点 (POST /api/video/generate)

核心实现逻辑

export async function POST(req: NextRequest) {
  try {
    // 1. 身份认证
    const session = await auth.api.getSession({ headers: req.headers });
    if (!session?.session?.userId) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }
    const userId = session.session.userId;

    // 2. 解析请求参数
    const { prompt, imageUrl, duration, resolution, watermark } = await req.json();

    // 3. 验证参数 (至少需要 prompt 或 imageUrl)
    if (!prompt && !imageUrl) {
      return NextResponse.json({
        error: "Either prompt or imageUrl is required"
      }, { status: 400 });
    }

    // 4. 检查积分余额 (50 积分)
    const creditsNeeded = 50;
    const hasCredits = await canUserAfford(userId, creditsNeeded);
    if (!hasCredits) {
      return NextResponse.json({
        error: "Insufficient credits",
        creditsNeeded,
        remainingCredits: 0
      }, { status: 402 });
    }

    // 5. 创建生成记录
    const historyId = randomUUID();
    await db.insert(generationHistory).values({
      id: historyId,
      userId,
      type: "video",
      prompt: prompt || "Image to video generation",
      imageUrl,
      status: "pending",
      creditsUsed: creditsNeeded,
      metadata: JSON.stringify({ duration, resolution, watermark }),
    });

    // 6. 扣除积分
    const deductResult = await deductCredits(
      userId,
      creditsNeeded,
      "video_generation",
      historyId
    );

    if (!deductResult.success) {
      // 更新记录为失败
      await db.update(generationHistory)
        .set({ status: "failed", error: deductResult.error })
        .where(eq(generationHistory.id, historyId));

      return NextResponse.json({
        error: deductResult.error || "Failed to deduct credits",
        remainingCredits: deductResult.remainingCredits
      }, { status: 402 });
    }

    try {
      // 7. 调用火山引擎 API
      let result;
      if (imageUrl) {
        // 图生视频
        result = await volcanoEngine.generateVideoFromImage(
          imageUrl,
          prompt || "Generate video from image",
          { duration, resolution, watermark }
        );
      } else if (prompt) {
        // 文生视频
        result = await volcanoEngine.generateVideoFromText(
          prompt,
          { duration, resolution, watermark }
        );
      }

      // 8. 更新记录为 processing 状态,保存 taskId
      await db.update(generationHistory)
        .set({
          status: "processing",
          taskId: result.taskId,
          updatedAt: new Date(),
        })
        .where(eq(generationHistory.id, historyId));

      // 9. 返回任务信息
      return NextResponse.json({
        id: historyId,
        taskId: result.taskId,
        status: result.status,
        remainingCredits: deductResult.remainingCredits,
      });

    } catch (genError: any) {
      // 生成失败: 更新记录
      await db.update(generationHistory)
        .set({
          status: "failed",
          error: genError.message,
          updatedAt: new Date(),
        })
        .where(eq(generationHistory.id, historyId));

      throw genError;
    }

  } catch (error: any) {
    console.error("Video generation API error:", error);
    return NextResponse.json({
      error: error.message || "Failed to generate video"
    }, { status: 500 });
  }
}

状态查询端点 (GET /api/video/status)

核心实现逻辑

export async function GET(req: NextRequest) {
  try {
    // 1. 身份认证
    const session = await auth.api.getSession({ headers: req.headers });
    if (!session?.session?.userId) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }
    const userId = session.session.userId;

    // 2. 获取查询参数
    const searchParams = req.nextUrl.searchParams;
    const taskId = searchParams.get("taskId");
    const historyId = searchParams.get("historyId");

    if (!taskId || !historyId) {
      return NextResponse.json({
        error: "taskId and historyId are required"
      }, { status: 400 });
    }

    // 3. 验证记录所有权
    const history = await db
      .select()
      .from(generationHistory)
      .where(and(
        eq(generationHistory.id, historyId),
        eq(generationHistory.userId, userId),
        eq(generationHistory.type, "video")
      ))
      .limit(1);

    if (!history || history.length === 0) {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }

    const record = history[0];

    // 4. 如果已经完成或失败,直接返回缓存结果
    if (record.status === "completed" || record.status === "failed") {
      return NextResponse.json({
        id: historyId,
        taskId: record.taskId,
        status: record.status,
        videoUrl: record.resultUrl,
        error: record.error,
      });
    }

    // 5. 查询火山引擎任务状态
    try {
      const status = await volcanoEngine.getVideoStatus(taskId);

      // 解析状态响应
      const taskData = status.data || status;
      const rawTaskStatus = taskData.task_status || taskData.status || status.status || '';
      const taskStatusText = taskData.task_status_text || '';
      const normalizedStatus = normalizeStatus(rawTaskStatus, taskStatusText);

      // 提取视频 URL (可能存在于多个字段)
      const videoUrl = taskData.result?.video_url ||
                      taskData.result?.url ||
                      taskData.result?.video?.[0]?.url ||
                      // ... 其他可能的字段

      const isCompleted = normalizedStatus === 'completed';
      const isFailed = normalizedStatus === 'failed';

      // 6. 任务完成: 上传视频到 R2
      if (isCompleted || videoUrl) {
        if (videoUrl) {
          try {
            // 上传到 R2 (失败则使用原始 URL)
            const finalUrl = await uploadImageFromUrl(videoUrl, userId, 'video')
              .catch(() => videoUrl);

            // 更新记录为完成
            await db.update(generationHistory)
              .set({
                status: "completed",
                resultUrl: finalUrl,
                updatedAt: new Date(),
              })
              .where(eq(generationHistory.id, historyId));

            return NextResponse.json({
              id: historyId,
              taskId,
              status: "completed",
              videoUrl: finalUrl,
            });
          } catch (error) {
            console.error('Error processing video URL:', error);
            return NextResponse.json({
              id: historyId,
              taskId,
              status: "completed",
              videoUrl: videoUrl,
            });
          }
        }
      }

      // 7. 任务失败
      else if (isFailed) {
        const errorMessage = taskData.error ||
                           taskData.result?.error ||
                           status.error ||
                           taskStatusText ||
                           "Video generation failed";

        await db.update(generationHistory)
          .set({
            status: "failed",
            error: errorMessage,
            updatedAt: new Date(),
          })
          .where(eq(generationHistory.id, historyId));

        return NextResponse.json({
          id: historyId,
          taskId,
          status: "failed",
          error: errorMessage,
        });
      }

      // 8. 超时检查 (5 分钟)
      const createdAt = new Date(record.createdAt).getTime();
      const now = Date.now();
      const elapsedMinutes = (now - createdAt) / (1000 * 60);

      if (elapsedMinutes > 5) {
        await db.update(generationHistory)
          .set({
            status: "failed",
            error: "Video generation timeout",
            updatedAt: new Date(),
          })
          .where(eq(generationHistory.id, historyId));

        return NextResponse.json({
          id: historyId,
          taskId,
          status: "failed",
          error: "Video generation timeout after 5 minutes",
        });
      }

      // 9. 仍在处理中: 返回状态
      return NextResponse.json({
        id: historyId,
        taskId,
        status: "processing",
        taskStatus: rawTaskStatus,
        statusText: taskStatusText,
        elapsedSeconds: Math.floor((now - createdAt) / 1000),
      });

    } catch (error: any) {
      console.error("Error checking video status:", error);
      return NextResponse.json({
        id: historyId,
        taskId,
        status: "processing",
        error: "Failed to check status",
      });
    }

  } catch (error: any) {
    console.error("Video status API error:", error);
    return NextResponse.json({
      error: error.message || "Failed to get video status"
    }, { status: 500 });
  }
}

积分扣费机制

扣费规则

  • 消耗标准: 每次视频生成消耗 50 积分
  • 扣费时机: 在提交任务之前扣除,即使生成失败也不退款
  • 失败退款: 当前不会自动退款 (可根据需求自行添加)

积分消耗定义

定义位置: app/api/video/generate/route.ts:36

const creditsNeeded = 50; // 视频生成消耗 50 积分

动态定价建议:

// 根据分辨率和时长动态计算
const baseCost = 50;
const resolutionMultiplier = {
  '480p': 0.8,   // 40 积分
  '720p': 1.0,   // 50 积分
  '1080p': 1.4,  // 70 积分
};

const durationMultiplier = duration > 10 ? 1.5 : 1.0;

const creditsNeeded = Math.ceil(
  baseCost * resolutionMultiplier[resolution] * durationMultiplier
);

客户端集成示例

前端实现 (React)

import { useState, useEffect, useRef } from "react";

export function VideoGeneratorComponent() {
  const [prompt, setPrompt] = useState("");
  const [imageUrl, setImageUrl] = useState("");
  const [videoUrl, setVideoUrl] = useState<string | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);
  const [taskId, setTaskId] = useState<string | null>(null);
  const [historyId, setHistoryId] = useState<string | null>(null);
  const [progress, setProgress] = useState<string>("");
  const [credits, setCredits] = useState<number>(0);

  const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);

  // 提交生成请求
  const handleGenerate = async () => {
    if (!prompt && !imageUrl) {
      alert("请输入提示词或参考图像 URL");
      return;
    }

    setIsGenerating(true);
    setVideoUrl(null);
    setProgress("提交生成请求...");

    try {
      const response = await fetch("/api/video/generate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          prompt,
          imageUrl,
          duration: 10,
          resolution: "720p",
          watermark: false,
        }),
      });

      if (!response.ok) {
        const error = await response.json();

        if (response.status === 402) {
          alert(`积分不足!需要 ${error.creditsNeeded} 积分`);
          return;
        }

        throw new Error(error.error || "Failed to start generation");
      }

      const result = await response.json();
      setTaskId(result.taskId);
      setHistoryId(result.id);
      setCredits(result.remainingCredits);
      setProgress("生成中,请稍候...");

      // 开始轮询
      startPolling(result.taskId, result.id);

    } catch (error: any) {
      console.error("Video generation error:", error);
      alert(error.message);
      setIsGenerating(false);
    }
  };

  // 开始轮询任务状态
  const startPolling = (taskId: string, historyId: string) => {
    // 清除旧的轮询
    if (pollingIntervalRef.current) {
      clearInterval(pollingIntervalRef.current);
    }

    // 每 5 秒轮询一次
    pollingIntervalRef.current = setInterval(async () => {
      try {
        const response = await fetch(
          `/api/video/status?taskId=${taskId}&historyId=${historyId}`
        );

        if (!response.ok) {
          throw new Error("Failed to check status");
        }

        const status = await response.json();

        if (status.status === "completed") {
          // 完成
          setVideoUrl(status.videoUrl);
          setProgress("生成完成!");
          setIsGenerating(false);
          if (pollingIntervalRef.current) {
            clearInterval(pollingIntervalRef.current);
          }
        } else if (status.status === "failed") {
          // 失败
          alert(`生成失败: ${status.error}`);
          setProgress(`失败: ${status.error}`);
          setIsGenerating(false);
          if (pollingIntervalRef.current) {
            clearInterval(pollingIntervalRef.current);
          }
        } else {
          // 仍在处理
          const elapsed = status.elapsedSeconds || 0;
          setProgress(`生成中... 已用时 ${elapsed} 秒`);
        }
      } catch (error) {
        console.error("Polling error:", error);
      }
    }, 5000); // 5 秒轮询一次
  };

  // 组件卸载时清理轮询
  useEffect(() => {
    return () => {
      if (pollingIntervalRef.current) {
        clearInterval(pollingIntervalRef.current);
      }
    };
  }, []);

  return (
    <div>
      {/* 积分显示 */}
      <div>剩余积分: {credits}</div>

      {/* 输入区域 */}
      <div>
        <label>参考图像 URL (图生视频):</label>
        <input
          type="text"
          value={imageUrl}
          onChange={(e) => setImageUrl(e.target.value)}
          placeholder="https://example.com/image.jpg (可选)"
          disabled={isGenerating}
        />
      </div>

      <div>
        <label>提示词:</label>
        <textarea
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="人物缓慢微笑,背景保持静止"
          disabled={isGenerating}
        />
      </div>

      <button
        onClick={handleGenerate}
        disabled={isGenerating || (!prompt && !imageUrl)}
      >
        {isGenerating ? "生成中..." : "生成视频 (50 积分)"}
      </button>

      {/* 进度显示 */}
      {progress && <div>{progress}</div>}

      {/* 结果显示 */}
      {videoUrl && (
        <div>
          <h3>生成结果:</h3>
          <video src={videoUrl} controls style={{ maxWidth: "100%" }} />
          <a href={videoUrl} download>下载视频</a>
        </div>
      )}
    </div>
  );
}

Fetch API 示例 (纯 JavaScript)

async function generateVideo(prompt, imageUrl, options = {}) {
  // 1. 提交生成请求
  const generateResponse = await fetch('/api/video/generate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      prompt,
      imageUrl,
      duration: options.duration || 10,
      resolution: options.resolution || '720p',
      watermark: options.watermark !== undefined ? options.watermark : false,
    }),
  });

  if (!generateResponse.ok) {
    const error = await generateResponse.json();
    throw new Error(error.error);
  }

  const { taskId, id: historyId } = await generateResponse.json();
  console.log('任务已提交, Task ID:', taskId);

  // 2. 轮询任务状态
  return new Promise((resolve, reject) => {
    const pollInterval = setInterval(async () => {
      try {
        const statusResponse = await fetch(
          `/api/video/status?taskId=${taskId}&historyId=${historyId}`
        );

        if (!statusResponse.ok) {
          throw new Error('Failed to check status');
        }

        const status = await statusResponse.json();

        if (status.status === 'completed') {
          clearInterval(pollInterval);
          console.log('视频生成完成!');
          resolve(status.videoUrl);
        } else if (status.status === 'failed') {
          clearInterval(pollInterval);
          reject(new Error(status.error || 'Generation failed'));
        } else {
          console.log('生成中...', status.elapsedSeconds, '秒');
        }
      } catch (error) {
        clearInterval(pollInterval);
        reject(error);
      }
    }, 5000); // 5 秒轮询一次

    // 设置最大等待时间 (10 分钟)
    setTimeout(() => {
      clearInterval(pollInterval);
      reject(new Error('Timeout: Generation took too long'));
    }, 10 * 60 * 1000);
  });
}

// 使用示例
generateVideo(
  '人物缓慢微笑',
  'https://example.com/photo.jpg',
  { resolution: '720p' }
)
  .then(videoUrl => {
    console.log('视频 URL:', videoUrl);
  })
  .catch(error => {
    console.error('生成失败:', error.message);
  });

错误处理与超时

常见错误

错误信息原因解决方案
"Either prompt or imageUrl is required"缺少输入参数提供提示词或参考图像
"Insufficient credits"积分不足提示用户购买积分
"Video generation timeout after 5 minutes"任务超时重试或联系支持
"Failed to upload to R2"R2 存储配置错误检查环境变量

超时保护机制

后端超时检查 (app/api/video/status/route.ts:162-182):

// 检查任务是否超过 5 分钟
const createdAt = new Date(record.createdAt).getTime();
const now = Date.now();
const elapsedMinutes = (now - createdAt) / (1000 * 60);

if (elapsedMinutes > 5) {
  // 标记为失败
  await db.update(generationHistory)
    .set({
      status: "failed",
      error: "Video generation timeout",
      updatedAt: new Date(),
    })
    .where(eq(generationHistory.id, historyId));

  return NextResponse.json({
    status: "failed",
    error: "Video generation timeout after 5 minutes",
  });
}

前端超时保护:

// 设置最大轮询时间 (10 分钟)
const maxPollingTime = 10 * 60 * 1000;
const pollingStartTime = Date.now();

const pollInterval = setInterval(() => {
  if (Date.now() - pollingStartTime > maxPollingTime) {
    clearInterval(pollInterval);
    alert('生成超时,请稍后重试');
    setIsGenerating(false);
  }
  // ... 轮询逻辑
}, 5000);

高级功能

断点续传 (恢复轮询)

如果用户刷新页面,可以从 localStorage 恢复轮询:

// 保存任务信息到 localStorage
localStorage.setItem('videoTask', JSON.stringify({
  taskId,
  historyId,
  startTime: Date.now(),
}));

// 页面加载时恢复
useEffect(() => {
  const savedTask = localStorage.getItem('videoTask');
  if (savedTask) {
    const { taskId, historyId, startTime } = JSON.parse(savedTask);

    // 检查是否超过 10 分钟
    if (Date.now() - startTime < 10 * 60 * 1000) {
      setTaskId(taskId);
      setHistoryId(historyId);
      setIsGenerating(true);
      startPolling(taskId, historyId);
    } else {
      localStorage.removeItem('videoTask');
    }
  }
}, []);

// 完成或失败时清除
if (status.status === 'completed' || status.status === 'failed') {
  localStorage.removeItem('videoTask');
}

批量生成

async function batchGenerateVideos(tasks: Array<{ prompt: string, imageUrl?: string }>) {
  const results = [];

  for (const task of tasks) {
    try {
      const videoUrl = await generateVideo(task.prompt, task.imageUrl);
      results.push({ success: true, videoUrl });
    } catch (error: any) {
      results.push({ success: false, error: error.message });
    }

    // 添加延迟避免频繁调用
    await new Promise(resolve => setTimeout(resolve, 10000));
  }

  return results;
}

进度估算

根据经验数据估算剩余时间:

// 假设平均生成时间为 3 分钟
const averageGenerationTime = 3 * 60; // 秒
const elapsedSeconds = status.elapsedSeconds || 0;
const remainingSeconds = Math.max(0, averageGenerationTime - elapsedSeconds);

const progressPercent = Math.min(100, (elapsedSeconds / averageGenerationTime) * 100);

console.log(`进度: ${progressPercent.toFixed(0)}%`);
console.log(`预计剩余时间: ${remainingSeconds} 秒`);

性能优化建议

1. 轮询频率优化

// 动态调整轮询间隔
let pollInterval = 5000; // 初始 5 秒

if (elapsedSeconds > 60) {
  pollInterval = 10000; // 超过 1 分钟后改为 10 秒
}
if (elapsedSeconds > 180) {
  pollInterval = 15000; // 超过 3 分钟后改为 15 秒
}

2. WebSocket 替代轮询

对于大量并发任务,可以使用 WebSocket 推送状态:

// 服务端 (伪代码)
wss.on('connection', (ws) => {
  ws.on('message', async (taskId) => {
    const status = await checkVideoStatus(taskId);
    ws.send(JSON.stringify(status));
  });
});

// 客户端
const ws = new WebSocket('wss://your-domain.com/video-status');
ws.send(taskId);
ws.onmessage = (event) => {
  const status = JSON.parse(event.data);
  if (status.status === 'completed') {
    setVideoUrl(status.videoUrl);
  }
};

3. 缓存任务状态

使用 Redis 缓存任务状态,减少数据库查询:

import { redis } from "@/lib/redis";

// 缓存任务状态
await redis.set(`video:task:${taskId}`, JSON.stringify(status), { ex: 600 });

// 读取缓存
const cached = await redis.get(`video:task:${taskId}`);
if (cached) return JSON.parse(cached);

相关文档

总结

AI 视频生成功能通过以下关键设计提供稳定的异步任务处理:

  1. 异步任务机制: 提交任务后通过轮询获取结果,避免长时间阻塞
  2. 超时保护: 5 分钟超时机制防止任务卡死
  3. 断点续传: 支持页面刷新后恢复轮询
  4. 长期存储: 自动上传到 R2,避免临时 URL 失效
  5. 事务性扣费: 确保积分扣除和记录的一致性

按照本文档的指导,你可以快速集成视频生成功能并根据业务需求进行优化。