ai-chatbot/lib/ai/providers/volcano-video.ts
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

518 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 火山引擎视频生成 Provider(豆包 Seedance 模型)
* 支持:文生视频、图生视频(首帧/首尾帧/参考图)
* API 文档:https://www.volcengine.com/docs/82379/1298442
*/
import type {
VideoGenerationOptions,
VideoGenerationResult,
VideoModelCapabilities,
} from "@/types/ai";
import { uploadFile } from "@/lib/storage/service";
/**
* 将图片 URL 转换为 Base64(火山引擎 API 要求)
*/
async function convertImageUrlToBase64(imageUrl: string): Promise<{
permanentUrl: string;
base64: string;
}> {
try {
// 如果已经是 Base64,直接返回
if (imageUrl.startsWith("data:image/")) {
const matches = imageUrl.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid base64 image format");
}
// 上传到 S3 获取永久 URL
const buffer = Buffer.from(matches[2], "base64");
const filename = `volcano-video-input-${Date.now()}.${matches[1]}`;
const { url: permanentUrl } = await uploadFile(filename, buffer as any);
return { permanentUrl, base64: matches[2] };
}
// 下载图片
console.log(`[Volcano Video] Downloading image: ${imageUrl}`);
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const bufferNode = Buffer.from(buffer);
// 上传到 S3 获取永久 URL
const filename = `volcano-video-input-${Date.now()}.jpg`;
const { url: permanentUrl } = await uploadFile(filename, bufferNode as any);
console.log(`[Volcano Video] Image uploaded to S3: ${permanentUrl}`);
// 转换为 Base64
const base64 = bufferNode.toString("base64");
return { permanentUrl, base64 };
} catch (error) {
console.error("[Volcano Video] Image processing failed:", error);
throw new Error("图片处理失败,请重试");
}
}
/**
* 🎬 运镜模式映射表(转换为自然语言提示词)
* 根据火山引擎API文档,运镜效果通过文本描述实现
*/
// 运镜描述映射(与官方一致)
const CAMERA_MOVE_DESCRIPTIONS: Record<string, string> = {
"none": "", // 智能运镜,保持空字符串
"static": "固定镜头拍摄", // 固定镜头
"hitchcock-push": "希区柯克式镜头前推,背景同时拉远", // 希区柯克推进
"hitchcock-pull": "希区柯克式镜头后拉,背景同时推进", // 希区柯克拉远
"mechanical-arm": "机械臂运镜,多角度平滑运动", // 机械臂
"dynamic-orbit": "动感环绕运镜,快速360度旋转", // 动感环绕
"center-orbit": "以画面中心点环绕旋转", // 中心环绕
"crane": "起重机式垂直升降运镜", // 起重机
"super-pull": "镜头快速后退超级拉远", // 超级拉远
"ccw-rotate": "镜头逆时针旋转运动", // 逆时针回旋
"cw-rotate": "镜头顺时针旋转运动", // 顺时针回旋
"handheld": "手持摄像机拍摄效果", // 手持运镜
"fast-push-pull": "镜头快速推拉来回运动", // 快速推拉
};
/**
* 构建视频参数字符串
* 格式:--ratio 16:9 --dur 5 --rs 720p --cf false --wm true
*/
function buildParameterString(options: VideoGenerationOptions): string {
const params: string[] = [];
// 宽高比
if (options.ratio && options.ratio !== "adaptive") {
params.push(`--rt ${options.ratio}`);
}
// 时长
if (options.duration) {
params.push(`--dur ${options.duration}`);
}
// 分辨率
if (options.resolution) {
params.push(`--rs ${options.resolution}`);
}
// 🔧 修复:固定摄像头(使用正确的参数名)
if (options.cameraFixed !== undefined) {
params.push(`--cf ${options.cameraFixed}`);
}
// 水印
if (options.watermark !== undefined) {
params.push(`--wm ${options.watermark}`);
}
// 种子
if (options.seed) {
params.push(`--seed ${options.seed}`);
}
return params.join(" ");
}
/**
* 🆕 构建完整提示词(包含运镜描述)
* 将用户提示词 + 运镜描述 + API参数组合
*/
function buildFullPrompt(options: VideoGenerationOptions): string {
const parts: string[] = [options.prompt];
// 🎬 添加运镜描述(如果用户选择了运镜模式)
if (options.cameraMove && options.cameraMove !== "none") {
const cameraDescription = CAMERA_MOVE_DESCRIPTIONS[options.cameraMove];
if (cameraDescription) {
parts.push(cameraDescription);
}
}
// 添加API参数
const params = buildParameterString(options);
if (params) {
parts.push(params);
}
return parts.join(" ");
}
/**
* 创建视频生成任务
*/
async function createVideoTask(
baseURL: string,
apiKey: string,
options: VideoGenerationOptions
): Promise<{ taskId: string; inputImageUrls: any }> {
// 火山引擎官方文档:/contents/generations/tasks(复数形式)
// https://www.volcengine.com/docs/82379/1520758
const url = `${baseURL}/contents/generations/tasks`;
// 构建 content 数组
const content: any[] = [];
const inputImageUrls: any = {};
// 1. 🆕 添加文本提示词(包含运镜描述 + API参数)
const fullPrompt = buildFullPrompt(options);
console.log("[Volcano Video] Full Prompt:", fullPrompt);
content.push({
type: "text",
text: fullPrompt,
});
// 2. 处理图片输入(根据模式)
if (options.mode === "first-frame" && options.firstFrameImage) {
// 首帧模式
const { permanentUrl, base64 } = await convertImageUrlToBase64(
options.firstFrameImage
);
inputImageUrls.firstFrame = permanentUrl;
content.push({
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64}`,
},
role: "first_frame",
});
} else if (
options.mode === "first-last-frame" &&
options.firstFrameImage &&
options.lastFrameImage
) {
// 首尾帧模式
const firstFrame = await convertImageUrlToBase64(options.firstFrameImage);
const lastFrame = await convertImageUrlToBase64(options.lastFrameImage);
inputImageUrls.firstFrame = firstFrame.permanentUrl;
inputImageUrls.lastFrame = lastFrame.permanentUrl;
content.push(
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${firstFrame.base64}`,
},
role: "first_frame",
},
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${lastFrame.base64}`,
},
role: "last_frame",
}
);
} else if (options.mode === "reference-images" && options.referenceImages) {
// 参考图模式
inputImageUrls.references = [];
for (const imageUrl of options.referenceImages) {
const { permanentUrl, base64 } = await convertImageUrlToBase64(imageUrl);
inputImageUrls.references.push(permanentUrl);
content.push({
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64}`,
},
role: "reference_image",
});
}
}
// 3. 🔧 构建请求体(火山引擎API格式)
const requestBody: any = {
model: options.model,
content: content, // 直接传content数组,不使用messages包装
};
// 可选参数:返回尾帧(用于连续生成视频)
if (options.returnLastFrame) {
requestBody.return_last_frame = true;
}
console.log("[Volcano Video] Creating task:", {
model: options.model,
mode: options.mode,
prompt: options.prompt,
fullPrompt,
cameraMove: options.cameraMove,
});
// 4. 发送请求
console.log("[Volcano Video] 🔍 Request URL:", url);
console.log("[Volcano Video] 🔍 Request method: POST");
console.log("[Volcano Video] 🔍 Request body:", JSON.stringify(requestBody, null, 2));
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});
console.log("[Volcano Video] 🔍 Response status:", response.status);
console.log("[Volcano Video] 🔍 Response headers:", JSON.stringify(Object.fromEntries(response.headers.entries())));
if (!response.ok) {
const errorText = await response.text();
console.error("[Volcano Video] ❌ Task creation failed");
console.error("[Volcano Video] ❌ Status:", response.status);
console.error("[Volcano Video] ❌ Response:", errorText);
throw new Error(`视频任务创建失败: ${response.status} ${errorText}`);
}
const data = await response.json();
if (!data.id) {
console.error("[Volcano Video] Invalid response:", data);
throw new Error("任务创建失败:未返回任务ID");
}
console.log("[Volcano Video] Task created:", data.id);
return {
taskId: data.id,
inputImageUrls
};
}
/**
* 查询任务状态
*/
async function fetchTaskStatus(
baseURL: string,
apiKey: string,
taskId: string
): Promise<any> {
const url = `${baseURL}/contents/generations/tasks/${taskId}`;
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`任务查询失败: ${response.status} ${errorText}`);
}
return await response.json();
}
/**
* 轮询任务状态直到完成
*/
async function pollTaskStatus(
baseURL: string,
apiKey: string,
taskId: string,
maxRetries = 60, // 最多轮询 60 次
pollInterval = 10000 // 每 10 秒轮询一次
): Promise<VideoGenerationResult> {
console.log("[Volcano Video] Polling task:", taskId);
for (let i = 0; i < maxRetries; i++) {
const status = await fetchTaskStatus(baseURL, apiKey, taskId);
console.log(
`[Volcano Video] Poll ${i + 1}/${maxRetries}, status: ${status.status}`
);
if (status.status === "succeeded") {
// 任务成功 - 火山引擎API返回格式:status.content.video_url
const videoUrl = status.content?.video_url || status.output?.video_url || status.video_url;
const lastFrameUrl = status.content?.last_frame_url || status.output?.last_frame_url || status.last_frame_url;
if (!videoUrl) {
throw new Error("视频生成成功但未返回视频URL");
}
console.log("[Volcano Video] ✅ Video generated:", videoUrl);
return {
taskId: taskId,
status: "succeeded",
videoUrl: videoUrl,
lastFrameUrl: lastFrameUrl,
usage: status.usage,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
if (status.status === "failed") {
// 任务失败
const errorMsg =
status.error?.message || status.message || "未知错误";
console.error("[Volcano Video] ❌ Task failed:", errorMsg);
throw new Error(`视频生成失败: ${errorMsg}`);
}
if (status.status === "cancelled") {
throw new Error("视频生成任务已取消");
}
// 继续等待
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new Error("视频生成超时,请稍后重试");
}
/**
* 格式化视频响应为 Markdown
*/
function formatVideoResponse(result: VideoGenerationResult): string {
// 只显示视频和下载按钮,不显示额外文字
return `<video src="${result.videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>
<a href="${result.videoUrl}" download style="display: inline-block; padding: 8px 16px; background: #000000; color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 500; transition: all 0.2s; hover:background: #333333;">下载视频</a>`;
}
/**
* 主函数:生成视频
*/
export async function doGenerate(
apiKey: string,
baseURL: string,
options: VideoGenerationOptions
): Promise<{ text: string; videoMetadata: any }> {
try {
console.log("[Volcano Video] 🎬 Starting video generation...");
console.log("[Volcano Video] Options:", {
mode: options.mode,
model: options.model,
resolution: options.resolution,
ratio: options.ratio,
duration: options.duration,
});
// 1. 创建任务
const { taskId, inputImageUrls } = await createVideoTask(baseURL, apiKey, options);
// 2. 轮询任务状态
const result = await pollTaskStatus(baseURL, apiKey, taskId);
// 3. 格式化输出
const markdown = formatVideoResponse(result);
// 4. 返回视频元数据(用于保存到数据库)
const videoMetadata = {
taskId: result.taskId,
duration: options.duration,
resolution: options.resolution,
ratio: options.ratio,
inputImages: inputImageUrls,
};
return {
text: markdown,
videoMetadata: {
...videoMetadata,
temporaryVideoUrl: result.videoUrl,
lastFrameUrl: result.lastFrameUrl,
},
};
} catch (error: any) {
console.error("[Volcano Video] ❌ Generation failed:", error);
throw error;
}
}
/**
* 流式生成(模拟进度推送)
*/
export async function* doGenerateStream(
apiKey: string,
baseURL: string,
options: VideoGenerationOptions
): AsyncGenerator<string, void, unknown> {
try {
// 1. 创建任务(不输出进度文字)
const { taskId, inputImageUrls } = await createVideoTask(baseURL, apiKey, options);
// 2. 轮询任务状态(静默等待)
const maxRetries = 60;
const pollInterval = 10000;
for (let i = 0; i < maxRetries; i++) {
const status = await fetchTaskStatus(baseURL, apiKey, taskId);
if (status.status === "succeeded") {
// 火山引擎API返回格式:status.content.video_url
const videoUrl = status.content?.video_url || status.output?.video_url || status.video_url;
const lastFrameUrl = status.content?.last_frame_url || status.output?.last_frame_url || status.last_frame_url;
console.log("[Volcano Video] ✅ Task succeeded!");
console.log("[Volcano Video] Video URL:", videoUrl);
console.log("[Volcano Video] Last frame URL:", lastFrameUrl);
console.log("[Volcano Video] Full status response:", JSON.stringify(status, null, 2));
const videoResponse = formatVideoResponse({
taskId: taskId,
status: "succeeded",
videoUrl: videoUrl,
lastFrameUrl: lastFrameUrl,
usage: status.usage,
createdAt: Date.now(),
updatedAt: Date.now(),
});
console.log("[Volcano Video] Formatted response:", videoResponse);
yield videoResponse;
// 输出视频元数据(JSON格式,前端可解析)
yield `\n\n<!-- VIDEO_METADATA:${JSON.stringify({
taskId: taskId,
temporaryVideoUrl: videoUrl,
lastFrameUrl: lastFrameUrl,
duration: options.duration,
resolution: options.resolution,
ratio: options.ratio,
inputImages: inputImageUrls,
})} -->`;
return;
}
if (status.status === "failed") {
const errorMsg = status.error?.message || status.message || "未知错误";
throw new Error(`视频生成失败: ${errorMsg}`);
}
if (status.status === "cancelled") {
throw new Error("视频生成任务已取消");
}
// 静默等待,不输出进度文字
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new Error("视频生成超时,请稍后重试");
} catch (error: any) {
console.error("[Volcano Video] ❌ Stream generation failed:", error);
yield `\n\n❌ 错误:${error.message}\n`;
throw error;
}
}