ai-chatbot/lib/ai/providers/volcano-video.ts
Your Name 810b530eb0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: harden video generation fallback
2026-03-13 16:35:55 +08:00

873 lines
25 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";
const VOLCANO_MAX_INPUT_IMAGE_BYTES = 30 * 1024 * 1024;
const VOLCANO_SAFE_INPUT_IMAGE_BYTES = VOLCANO_MAX_INPUT_IMAGE_BYTES - 2 * 1024 * 1024;
const VOLCANO_OPTIMIZE_SIDE_STEPS = [3072, 2560, 2048, 1536, 1280, 1024];
const VOLCANO_OPTIMIZE_QUALITY_STEPS = [84, 76, 68, 60];
const RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
const RETRYABLE_FETCH_CODES = new Set([
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"ETIMEDOUT",
"ECONNRESET",
"ECONNREFUSED",
"EAI_AGAIN",
"ENOTFOUND",
]);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function sanitizeUrlForLog(url: string): string {
const trimmed = String(url || "").trim();
if (!trimmed) return "";
if (trimmed.startsWith("data:image/")) {
return `data:image(base64,${trimmed.length} chars)`;
}
try {
const parsed = new URL(trimmed);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return trimmed.slice(0, 120);
}
}
function extractPublicHttpUrl(rawValue: string): string | null {
const trimmed = rawValue.trim();
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
const markdownMatch = trimmed.match(/\((https?:\/\/[^)\s]+)\)/i);
if (markdownMatch?.[1]) {
return markdownMatch[1];
}
const inlineMatch = trimmed.match(/https?:\/\/[^\s"'<>]+/i);
if (inlineMatch?.[0]) {
return inlineMatch[0];
}
return null;
}
function mimeTypeToVolcanoImageExtension(mimeType?: string | null): string {
const normalized = String(mimeType || "").toLowerCase();
if (normalized.includes("png")) return "png";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
return "jpg";
}
function isVolcanoOversizeImageError(errorText: string): boolean {
const normalized = String(errorText || "").toLowerCase();
return (
normalized.includes("oversizeimage") ||
normalized.includes("exceeds the limit") ||
normalized.includes("image (") && normalized.includes("limit (")
);
}
async function optimizeImageBufferForVolcano(
inputBuffer: Buffer,
): Promise<{ buffer: Buffer; extension: string; mimeType: string }> {
const sharp = (await import("sharp")).default;
const metadata = await sharp(inputBuffer, { failOn: "none" }).metadata();
const maxOriginalSide = Math.max(metadata.width || 0, metadata.height || 0);
const sideCandidates = Array.from(
new Set(
[
maxOriginalSide > 0 ? Math.min(maxOriginalSide, VOLCANO_OPTIMIZE_SIDE_STEPS[0]!) : undefined,
...VOLCANO_OPTIMIZE_SIDE_STEPS,
].filter((value): value is number => Boolean(value && value > 0))
)
);
let bestBuffer = inputBuffer;
for (const maxSide of sideCandidates) {
for (const quality of VOLCANO_OPTIMIZE_QUALITY_STEPS) {
const pipeline = sharp(inputBuffer, { failOn: "none" })
.rotate()
.resize({
width: maxSide,
height: maxSide,
fit: "inside",
withoutEnlargement: true,
})
.flatten({ background: "#ffffff" })
.jpeg({
quality,
mozjpeg: true,
progressive: true,
});
const optimizedBuffer = await pipeline.toBuffer();
if (optimizedBuffer.length < bestBuffer.length) {
bestBuffer = optimizedBuffer;
}
if (optimizedBuffer.length <= VOLCANO_SAFE_INPUT_IMAGE_BYTES) {
return {
buffer: optimizedBuffer,
extension: "jpg",
mimeType: "image/jpeg",
};
}
}
}
return {
buffer: bestBuffer,
extension: "jpg",
mimeType: "image/jpeg",
};
}
async function uploadVolcanoPreparedImage(params: {
buffer: Buffer;
extension?: string;
}) {
const filename = `volcano-video-input-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${
params.extension || "jpg"
}`;
const { url } = await uploadFile(filename, params.buffer as any);
return url;
}
function isRetryableFetchError(error: any): boolean {
const code = error?.code || error?.cause?.code;
if (typeof code === "string" && RETRYABLE_FETCH_CODES.has(code)) {
return true;
}
if (error?.name === "AbortError") {
return true;
}
const message = String(error?.message || "").toLowerCase();
return (
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("fetch failed")
);
}
async function fetchWithRetry(
url: string,
init: RequestInit,
options: {
label: string;
timeoutMs: number;
retries: number;
retryDelayMs?: number;
}
): Promise<Response> {
const { label, timeoutMs, retries, retryDelayMs = 1500 } = options;
let attempt = 0;
while (attempt <= retries) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
clearTimeout(timer);
if (
RETRYABLE_HTTP_STATUS.has(response.status) &&
attempt < retries
) {
const bodyText = await response.text().catch(() => "");
console.warn(
`[Volcano Video] ${label} transient status ${response.status}, retry ${attempt + 1}/${retries}`,
bodyText?.slice(0, 300)
);
await sleep(retryDelayMs * (attempt + 1));
attempt += 1;
continue;
}
return response;
} catch (error: any) {
clearTimeout(timer);
if (isRetryableFetchError(error) && attempt < retries) {
console.warn(
`[Volcano Video] ${label} request failed, retry ${attempt + 1}/${retries}`,
error?.cause?.code || error?.code || error?.message
);
await sleep(retryDelayMs * (attempt + 1));
attempt += 1;
continue;
}
throw error;
}
}
throw new Error(`[Volcano Video] ${label} request failed after retries`);
}
/**
* 规范化图片地址为可公开访问 URL(优先直传 URL,避免大体积 base64)
*/
async function normalizeImageUrlForVolcano(
imageUrl: string,
options?: { forceOptimize?: boolean },
): Promise<string> {
try {
const rawInput = String(imageUrl || "").trim();
if (!rawInput) {
throw new Error("Empty image url");
}
// Base64 输入:上传到 S3,返回永久 URL
if (rawInput.startsWith("data:image/")) {
const matches = rawInput.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
if (!matches) {
throw new Error("Invalid base64 image format");
}
const mimeType = matches[1];
const buffer = Buffer.from(matches[2], "base64");
const shouldOptimize =
options?.forceOptimize === true || buffer.length > VOLCANO_SAFE_INPUT_IMAGE_BYTES;
const prepared = shouldOptimize
? await optimizeImageBufferForVolcano(buffer)
: {
buffer,
extension: mimeTypeToVolcanoImageExtension(mimeType),
};
const permanentUrl = await uploadVolcanoPreparedImage(prepared);
return permanentUrl;
}
// 已经是公网 URL,直接使用
const publicHttpUrl = extractPublicHttpUrl(rawInput);
if (publicHttpUrl && options?.forceOptimize !== true) {
return publicHttpUrl;
}
if (rawInput.startsWith("blob:") || rawInput.startsWith("file:")) {
throw new Error("Unsupported local image URL. Please upload image first.");
}
// 其他格式:尝试下载后上传到 S3,返回永久 URL
console.log(`[Volcano Video] Downloading image: ${sanitizeUrlForLog(rawInput)}`);
const response = await fetchWithRetry(
rawInput,
{ method: "GET" },
{
label: "download-image",
timeoutMs: 30000,
retries: 2,
}
);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const bufferNode = Buffer.from(buffer);
const contentType = response.headers.get("content-type");
const shouldOptimize =
options?.forceOptimize === true || bufferNode.length > VOLCANO_SAFE_INPUT_IMAGE_BYTES;
const prepared = shouldOptimize
? await optimizeImageBufferForVolcano(bufferNode)
: {
buffer: bufferNode,
extension: mimeTypeToVolcanoImageExtension(contentType),
};
const permanentUrl = await uploadVolcanoPreparedImage(prepared);
console.log(`[Volcano Video] Image uploaded to S3: ${sanitizeUrlForLog(permanentUrl)}`);
return permanentUrl;
} catch (error) {
console.error("[Volcano Video] Image processing failed:", error);
throw new Error("图片处理失败,请重试");
}
}
async function buildVolcanoTaskRequestPayload(
options: VideoGenerationOptions,
requestBody?: Record<string, any>,
requestMeta?: { forceOptimizeImages?: boolean },
) {
const content: any[] = [];
const inputImageUrls: any = {};
const fullPrompt = buildFullPrompt(options);
console.log("[Volcano Video] Full Prompt:", fullPrompt);
content.push({
type: "text",
text: fullPrompt,
});
if (options.mode === "first-frame" && options.firstFrameImage) {
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage, {
forceOptimize: requestMeta?.forceOptimizeImages,
});
inputImageUrls.firstFrame = firstFrameUrl;
content.push({
type: "image_url",
image_url: {
url: firstFrameUrl,
},
role: "first_frame",
});
} else if (
options.mode === "first-last-frame" &&
options.firstFrameImage &&
options.lastFrameImage
) {
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage, {
forceOptimize: requestMeta?.forceOptimizeImages,
});
const lastFrameUrl = await normalizeImageUrlForVolcano(options.lastFrameImage, {
forceOptimize: requestMeta?.forceOptimizeImages,
});
inputImageUrls.firstFrame = firstFrameUrl;
inputImageUrls.lastFrame = lastFrameUrl;
content.push(
{
type: "image_url",
image_url: {
url: firstFrameUrl,
},
role: "first_frame",
},
{
type: "image_url",
image_url: {
url: lastFrameUrl,
},
role: "last_frame",
},
);
} else if (options.mode === "reference-images" && options.referenceImages) {
inputImageUrls.references = [];
for (const imageUrl of options.referenceImages) {
const normalizedUrl = await normalizeImageUrlForVolcano(imageUrl, {
forceOptimize: requestMeta?.forceOptimizeImages,
});
inputImageUrls.references.push(normalizedUrl);
content.push({
type: "image_url",
image_url: {
url: normalizedUrl,
},
role: "reference_image",
});
}
}
const nextRequestBody: any = requestBody || {
model: options.model,
};
nextRequestBody.content = content;
const taskTypeMap: Record<string, string> = {
"text-to-video": "t2v",
"first-frame": "i2v",
"first-last-frame": "flf2v",
"reference-images": "i2v",
};
nextRequestBody.task_type = taskTypeMap[options.mode] || "t2v";
if (options.returnLastFrame) {
nextRequestBody.return_last_frame = true;
}
return {
requestBody: nextRequestBody,
inputImageUrls,
fullPrompt,
contentCount: content.length,
};
}
async function sendCreateVideoTaskRequest(params: {
url: string;
apiKey: string;
requestBody: Record<string, any>;
}) {
return fetchWithRetry(
params.url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify(params.requestBody),
},
{
label: "create-task",
timeoutMs: 90000,
retries: 2,
}
);
}
/**
* 🎬 运镜模式映射表(转换为自然语言提示词)
* 根据火山引擎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`;
// 3. 🔧 构建请求体(火山引擎API格式)
const baseRequestBody: any = {
model: options.model,
};
let payload = await buildVolcanoTaskRequestPayload(options, baseRequestBody, {
forceOptimizeImages: false,
});
console.log("[Volcano Video] Creating task:", {
model: options.model,
mode: options.mode,
promptLength: options.prompt?.length || 0,
fullPromptLength: payload.fullPrompt.length,
cameraMove: options.cameraMove,
contentItems: payload.contentCount,
imageRefs: Object.values(payload.inputImageUrls)
.flat()
.map((value: any) => sanitizeUrlForLog(String(value)))
.slice(0, 6),
});
// 4. 发送请求
console.log("[Volcano Video] Request URL:", url);
let response = await sendCreateVideoTaskRequest({
url,
apiKey,
requestBody: payload.requestBody,
});
console.log("[Volcano Video] Response status:", response.status);
if (!response.ok) {
let errorText = await response.text();
console.error("[Volcano Video] ❌ Task creation failed");
console.error("[Volcano Video] ❌ Status:", response.status);
console.error("[Volcano Video] ❌ Response:", errorText);
const hasImageInputs =
Boolean(options.firstFrameImage) ||
Boolean(options.lastFrameImage) ||
Boolean(options.referenceImages?.length);
if (hasImageInputs && isVolcanoOversizeImageError(errorText)) {
console.warn(
"[Volcano Video] Oversize image detected, retrying with optimized uploaded images",
);
payload = await buildVolcanoTaskRequestPayload(options, baseRequestBody, {
forceOptimizeImages: true,
});
response = await sendCreateVideoTaskRequest({
url,
apiKey,
requestBody: payload.requestBody,
});
console.log("[Volcano Video] Retry response status:", response.status);
if (!response.ok) {
errorText = await response.text();
console.error("[Volcano Video] ❌ Retry task creation failed");
console.error("[Volcano Video] ❌ Retry response:", errorText);
throw new Error(`视频任务创建失败: ${response.status} ${errorText}`);
}
} else {
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: payload.inputImageUrls
};
}
/**
* 查询任务状态
*/
async function fetchTaskStatus(
baseURL: string,
apiKey: string,
taskId: string
): Promise<any> {
const url = `${baseURL}/contents/generations/tasks/${taskId}`;
const response = await fetchWithRetry(
url,
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
{
label: `task-status:${taskId}`,
timeoutMs: 45000,
retries: 2,
}
);
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++) {
let status: any;
try {
status = await fetchTaskStatus(baseURL, apiKey, taskId);
} catch (error: any) {
if (isRetryableFetchError(error)) {
console.warn(
`[Volcano Video] Poll ${i + 1}/${maxRetries} transient error, continue waiting:`,
error?.cause?.code || error?.code || error?.message
);
await sleep(pollInterval);
continue;
}
throw error;
}
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 sleep(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++) {
let status: any;
try {
status = await fetchTaskStatus(baseURL, apiKey, taskId);
} catch (error: any) {
if (isRetryableFetchError(error)) {
console.warn(
`[Volcano Video] Stream poll ${i + 1}/${maxRetries} transient error, continue waiting:`,
error?.cause?.code || error?.code || error?.message
);
await sleep(pollInterval);
continue;
}
throw error;
}
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:", sanitizeUrlForLog(videoUrl || ""));
console.log("[Volcano Video] Last frame URL:", sanitizeUrlForLog(lastFrameUrl || ""));
const videoResponse = formatVideoResponse({
taskId: taskId,
status: "succeeded",
videoUrl: videoUrl,
lastFrameUrl: lastFrameUrl,
usage: status.usage,
createdAt: Date.now(),
updatedAt: Date.now(),
});
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 sleep(pollInterval);
}
throw new Error("视频生成超时,请稍后重试");
} catch (error: any) {
console.error("[Volcano Video] ❌ Stream generation failed:", error);
yield `\n\n❌ 错误:${error.message}\n`;
throw error;
}
}