AI 视频生成接口集成
AI 视频生成是整个平台最核心也最复杂的功能。用户提交一段文本描述,后台调用 AI 模型 API 生成视频,这个过程通常需要数十秒到数分钟。在这个异步、不确定的流程中,如何设计可靠的接口集成、状态同步、错误恢复和用户体验,是本章要解决的问题。
1. AI 视频生成的本质
1.1 异步长时任务
AI 视频生成不是一个请求-响应模型——用户提交 prompt 后,AI 模型需要 30s ~ 5min(甚至更久)才能生成结果。这决定了整个技术方案:
用户提交 prompt → 创建任务 → 立即返回 taskId
↓
后台轮询/回调 AI API
↓
生成完成 → 更新任务状态
↓
前端轮询/SSE → 展示结果
1.2 主流 AI 视频 API 对比
| 平台 | API 模式 | 生成时长 | 特点 |
|---|---|---|---|
| 可灵 (Kling) | 异步 + 轮询 | 30s ~ 3min | 国内访问快,中文支持好 |
| Runway Gen-3 | 异步 + Webhook | 1 ~ 5min | 画质高,风格多 |
| Sora | 异步 + 回调 | 1 ~ 10min | OpenAI 生态 |
| Pika | 异步 + 轮询 | 30s ~ 2min | 快速迭代 |
所有平台都是异步模式——提交任务后返回 taskId,通过轮询或回调获取结果。
2. BFF 层:适配器模式
2.1 为什么需要 BFF 层
直接在前端调用 AI API 有三个问题:
- API Key 暴露:前端代码中无法安全存放 API Key
- 接口差异:不同 AI 平台的 API 格式不同,前端不应关心
- 业务逻辑:配额检查、任务记录、错误处理属于后端逻辑
BFF 层的核心价值:统一接口,隔离差异。
2.2 适配器接口定义
// server/utils/ai-adapters/types.ts
export interface AiVideoAdapter {
name: string
createTask(params: CreateTaskParams): Promise<ExternalTaskId>
queryTask(externalId: string): Promise<TaskStatus>
cancelTask(externalId: string): Promise<void>
}
export interface CreateTaskParams {
prompt: string
style?: string
duration?: number
aspectRatio?: '16:9' | '9:16' | '1:1'
}
export interface TaskStatus {
status: 'pending' | 'running' | 'completed' | 'failed'
progress: number // 0-100
videoUrl?: string // 生成完成的视频地址
error?: string
}
type ExternalTaskId = string2.3 可灵适配器实现
// server/utils/ai-adapters/kling.ts
export class KlingAdapter implements AiVideoAdapter {
name = 'kling'
private baseUrl = 'https://api.klingai.com/v1'
private apiKey: string
constructor() {
this.apiKey = useRuntimeConfig().klingApiKey
if (!this.apiKey) throw new Error('KLING_API_KEY not configured')
}
async createTask(params: CreateTaskParams): Promise<string> {
const response = await $fetch(`${this.baseUrl}/videos/generations`, {
method: 'POST',
headers: { Authorization: `Bearer ${this.apiKey}` },
body: {
prompt: params.prompt,
model: 'kling-v1',
duration: params.duration || 5,
aspect_ratio: params.aspectRatio || '16:9',
style: params.style,
},
})
return response.data.task_id
}
async queryTask(externalId: string): Promise<TaskStatus> {
const response = await $fetch(
`${this.baseUrl}/videos/generations/${externalId}`,
{ headers: { Authorization: `Bearer ${this.apiKey}` } }
)
const statusMap: Record<string, TaskStatus['status']> = {
submitted: 'pending',
processing: 'running',
succeed: 'completed',
failed: 'failed',
}
return {
status: statusMap[response.data.status] || 'pending',
progress: response.data.progress || 0,
videoUrl: response.data.video_url,
error: response.data.error_message,
}
}
async cancelTask(externalId: string): Promise<void> {
await $fetch(`${this.baseUrl}/videos/generations/${externalId}/cancel`, {
method: 'POST',
headers: { Authorization: `Bearer ${this.apiKey}` },
})
}
}2.4 适配器工厂
// server/utils/ai-adapters/factory.ts
const adapters = new Map<string, AiVideoAdapter>()
export function getAiAdapter(model: string): AiVideoAdapter {
if (!adapters.has(model)) {
switch (model) {
case 'kling':
adapters.set(model, new KlingAdapter())
break
case 'runway':
adapters.set(model, new RunwayAdapter())
break
default:
throw createError({ statusCode: 400, message: `Unknown model: ${model}` })
}
}
return adapters.get(model)!
}新增 AI 模型只需添加一个适配器类 + 注册到工厂,不需要修改任何业务代码。
3. 任务生命周期
3.1 创建任务
// server/api/ai/generate.post.ts
import { z } from 'zod'
const schema = z.object({
prompt: z.string().min(10).max(500),
model: z.enum(['kling', 'runway']),
style: z.enum(['realistic', 'anime', 'cinematic']).optional(),
duration: z.number().min(3).max(60).default(5),
})
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const body = await readValidatedBody(event, schema.parse)
// 1. 配额检查
const quota = await checkUserQuota(session.user.id)
if (quota.remaining <= 0) {
throw createError({ statusCode: 429, message: '生成配额已用完' })
}
// 2. 调用 AI API 创建任务
const adapter = getAiAdapter(body.model)
const externalTaskId = await adapter.createTask({
prompt: body.prompt,
style: body.style,
duration: body.duration,
})
// 3. 记录任务到数据库
const db = useDB()
const [task] = await db.insert(aiTasks).values({
userId: session.user.id,
prompt: body.prompt,
model: body.model,
style: body.style,
externalId: externalTaskId,
status: 'pending',
}).returning()
// 4. 扣减配额
await deductQuota(session.user.id)
return { taskId: task.id }
})3.2 后台轮询:Task Runner
AI 任务创建后,需要一个后台进程定期检查任务状态:
// server/plugins/task-runner.ts
export default defineNitroPlugin((nitro) => {
const POLL_INTERVAL = 5000 // 5 秒
async function pollPendingTasks() {
const db = useDB()
const pendingTasks = await db.select()
.from(aiTasks)
.where(inArray(aiTasks.status, ['pending', 'running']))
.limit(20)
for (const task of pendingTasks) {
try {
const adapter = getAiAdapter(task.model)
const status = await adapter.queryTask(task.externalId)
await db.update(aiTasks)
.set({
status: status.status,
progress: status.progress,
...(status.status === 'completed' && {
completedAt: new Date(),
}),
...(status.error && {
errorMessage: status.error,
}),
})
.where(eq(aiTasks.id, task.id))
// 生成完成:创建视频记录
if (status.status === 'completed' && status.videoUrl) {
await createVideoFromAiTask(task, status.videoUrl)
}
} catch (error) {
console.error(`Task ${task.id} poll failed:`, error)
}
}
}
// 定时执行
const timer = setInterval(pollPendingTasks, POLL_INTERVAL)
// 服务关闭时清理
nitro.hooks.hook('close', () => clearInterval(timer))
})3.3 从 AI 结果创建视频
// server/utils/ai-task-handler.ts
async function createVideoFromAiTask(task: AiTask, videoUrl: string) {
const db = useDB()
const [video] = await db.insert(videos).values({
title: `AI 生成 - ${task.prompt.slice(0, 30)}`,
userId: task.userId,
sourceType: 'ai_generated',
sourceUrl: videoUrl,
status: 'processing', // 还需要转码处理
}).returning()
// 关联任务和视频
await db.update(aiTasks)
.set({ videoId: video.id })
.where(eq(aiTasks.id, task.id))
// 触发转码流水线(下一章详述)
await triggerTranscoding(video.id, videoUrl)
}4. 前端状态同步
4.1 轮询 vs SSE vs WebSocket
| 方案 | 复杂度 | 实时性 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 低 | 秒级 | 高 | AI 任务(不需要毫秒级实时) |
| SSE | 中 | 亚秒 | 中 | 实时进度流 |
| WebSocket | 高 | 毫秒 | 低 | 双向通信(弹幕、聊天) |
对于 AI 视频生成(30s ~ 5min),轮询是最佳选择——实现简单、不怕断连、服务端无状态。
4.2 前端轮询 Composable
// composables/useAiTask.ts
export function useAiTask(taskId: Ref<string | null>) {
const task = ref<AiTask | null>(null)
const isPolling = ref(false)
const { data, refresh } = useFetch(() =>
taskId.value ? `/api/ai/tasks/${taskId.value}` : null,
{ watch: false }
)
let timer: ReturnType<typeof setInterval> | null = null
function startPolling() {
if (isPolling.value) return
isPolling.value = true
timer = setInterval(async () => {
await refresh()
task.value = data.value
// 终态停止轮询
if (task.value && ['completed', 'failed', 'cancelled'].includes(task.value.status)) {
stopPolling()
}
}, 3000) // 3 秒间隔
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
isPolling.value = false
}
// 组件卸载时清理
onUnmounted(stopPolling)
return { task, isPolling, startPolling, stopPolling }
}4.3 生成页面 UI
<!-- pages/create/index.vue -->
<script setup>
const prompt = ref('')
const selectedModel = ref('kling')
const taskId = ref<string | null>(null)
const { task, isPolling, startPolling } = useAiTask(taskId)
async function handleGenerate() {
const { data } = await useFetch('/api/ai/generate', {
method: 'POST',
body: {
prompt: prompt.value,
model: selectedModel.value,
},
})
if (data.value?.taskId) {
taskId.value = data.value.taskId
startPolling()
}
}
</script>
<template>
<div class="max-w-2xl mx-auto p-6">
<!-- 输入区域 -->
<div v-if="!taskId">
<UTextarea v-model="prompt" placeholder="描述你想生成的视频..." rows="4" />
<UButton @click="handleGenerate" :disabled="!prompt" class="mt-4">
生成视频
</UButton>
</div>
<!-- 进度展示 -->
<div v-else-if="task">
<div v-if="task.status === 'running'" class="text-center">
<UProgress :value="task.progress" class="mb-4" />
<p class="text-gray-500">生成中... {{ task.progress }}%</p>
</div>
<div v-else-if="task.status === 'completed'">
<p class="text-green-600 mb-4">生成完成!</p>
<NuxtLink :to="`/video/${task.videoId}`">
<UButton>查看视频</UButton>
</NuxtLink>
</div>
<div v-else-if="task.status === 'failed'">
<p class="text-red-600">生成失败:{{ task.errorMessage }}</p>
<UButton variant="outline" @click="taskId = null">重新生成</UButton>
</div>
</div>
</div>
</template>5. 错误处理与重试
5.1 重试策略
AI API 调用可能因为网络波动、服务过载等原因失败。采用指数退避重试:
// server/utils/retry.ts
export async function withRetry<T>(
fn: () => Promise<T>,
options: { maxRetries?: number; baseDelay?: number } = {}
): Promise<T> {
const { maxRetries = 3, baseDelay = 1000 } = options
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error: any) {
if (attempt === maxRetries) throw error
// 不重试的错误
if (error.statusCode === 400 || error.statusCode === 401) throw error
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw new Error('Unreachable')
}5.2 在任务轮询中使用
// Task Runner 中增加重试计数
if (status.status === 'failed' && task.retryCount < 3) {
// 重新提交到 AI API
const adapter = getAiAdapter(task.model)
const newExternalId = await withRetry(() =>
adapter.createTask({ prompt: task.prompt, style: task.style })
)
await db.update(aiTasks).set({
externalId: newExternalId,
status: 'pending',
retryCount: task.retryCount + 1,
errorMessage: null,
}).where(eq(aiTasks.id, task.id))
}5.3 配额补偿
如果任务最终失败(重试次数用完),应该退还用户配额:
if (status.status === 'failed' && task.retryCount >= 3) {
await db.update(aiTasks).set({
status: 'failed',
errorMessage: status.error || '生成失败,已自动退还配额',
}).where(eq(aiTasks.id, task.id))
// 退还配额
await refundQuota(task.userId)
}6. 安全与限流
6.1 接口限流
AI 生成接口是昂贵的——必须限流:
// server/middleware/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
export default defineEventHandler((event) => {
if (!event.path.startsWith('/api/ai/generate')) return
const userId = event.context.session?.user?.id
if (!userId) return
const now = Date.now()
const limit = rateLimitMap.get(userId)
if (limit && now < limit.resetAt) {
if (limit.count >= 10) { // 每小时 10 次
throw createError({
statusCode: 429,
message: '请求过于频繁,请稍后再试',
})
}
limit.count++
} else {
rateLimitMap.set(userId, { count: 1, resetAt: now + 3600000 })
}
})6.2 Prompt 安全过滤
用户输入的 prompt 需要检查:
// server/utils/prompt-filter.ts
const BLOCKED_KEYWORDS = ['violence', 'explicit', /* ... */]
export function validatePrompt(prompt: string): { valid: boolean; reason?: string } {
const lower = prompt.toLowerCase()
for (const keyword of BLOCKED_KEYWORDS) {
if (lower.includes(keyword)) {
return { valid: false, reason: '内容包含违规关键词' }
}
}
if (prompt.length < 10) {
return { valid: false, reason: '描述太短,请提供更多细节' }
}
return { valid: true }
}本章小结
- 异步架构:AI 视频生成是长时异步任务,采用"创建任务 → 后台轮询 → 前端同步"三段式
- 适配器模式:BFF 层统一封装不同 AI 平台 API,新增模型只需添加适配器类
- 任务生命周期:pending → running → completed/failed,Nitro Plugin 后台定时轮询
- 前端轮询:3s 间隔的
useAiTaskComposable,终态自动停止,组件卸载自动清理 - 可靠性:指数退避重试(最多 3 次)+ 配额补偿(失败退还)
- 安全:接口限流(每小时 10 次)+ Prompt 安全过滤