JiMengIntl / src /api /controllers /videos.ts
github-actions[bot]
Deploy from GitHub Actions
8e02bdb
import _ from "lodash";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import APIException from "@/lib/exceptions/APIException.ts";
import EX from "@/api/consts/exceptions.ts";
import util from "@/lib/util.ts";
import { getCredit, receiveCredit, request, DEFAULT_ASSISTANT_ID as CORE_ASSISTANT_ID, WEB_ID, acquireToken } from "./core.ts";
import logger from "@/lib/logger.ts";
import browserService from "@/lib/browser-service.ts";
import provider from "@/lib/upstream-provider.ts";
function createRequestSign(uri: string, deviceTime: number) {
return util.md5(`9e2c|${uri.slice(-7)}|7|8.4.0|${deviceTime}||11ac`);
}
const DEFAULT_ASSISTANT_ID = provider.assistantId;
export const DEFAULT_MODEL = "dreamina_ic_generate_video_model_vgfm_3.0_fast";
const DEFAULT_DRAFT_VERSION = "3.2.8";
const MODEL_DRAFT_VERSIONS: { [key: string]: string } = {
"dreamina_ic_generate_video_model_vgfm_3.0_fast": "3.3.12",
"jimeng-video-3.5-pro": "3.3.4",
"jimeng-video-3.0-pro": "3.2.8",
"jimeng-video-3.0": "3.2.8",
"jimeng-video-2.0": "3.2.8",
"jimeng-video-2.0-pro": "3.2.8",
// Seedance 模型(与上游 iptag/jimeng-api 保持一致)
"jimeng-video-seedance-2.0": "3.3.9",
"seedance-2.0": "3.3.9",
"seedance-2.0-pro": "3.3.9",
// Seedance 2.0-fast 模型(v1.9.3 新增)
"jimeng-video-seedance-2.0-fast": "3.3.9",
"seedance-2.0-fast": "3.3.9",
};
const MODEL_MAP = {
"dreamina_ic_generate_video_model_vgfm_3.0_fast": "dreamina_ic_generate_video_model_vgfm_3.0_fast",
"jimeng-video-3.5-pro": "dreamina_ic_generate_video_model_vgfm_3.5_pro",
"jimeng-video-3.0-pro": "dreamina_ic_generate_video_model_vgfm_3.0_pro",
"jimeng-video-3.0": "dreamina_ic_generate_video_model_vgfm_3.0",
"jimeng-video-2.0": "dreamina_ic_generate_video_model_vgfm_lite",
"jimeng-video-2.0-pro": "dreamina_ic_generate_video_model_vgfm1.0",
// Seedance 多图智能视频生成模型(jimeng-video-seedance-2.0 为上游标准名称)
"jimeng-video-seedance-2.0": "dreamina_seedance_40_pro",
"seedance-2.0": "dreamina_seedance_40_pro",
"seedance-2.0-pro": "dreamina_seedance_40_pro",
// Seedance 2.0-fast 快速生成模型(v1.9.3 新增,内部模型为 dreamina_seedance_40)
"jimeng-video-seedance-2.0-fast": "dreamina_seedance_40",
"seedance-2.0-fast": "dreamina_seedance_40",
};
// Seedance 模型的 benefit_type 映射
const SEEDANCE_BENEFIT_TYPE_MAP: { [key: string]: string } = {
"jimeng-video-seedance-2.0": "dreamina_video_seedance_20_pro",
"seedance-2.0": "dreamina_video_seedance_20_pro",
"seedance-2.0-pro": "dreamina_video_seedance_20_pro",
// Seedance 2.0-fast(v1.9.3 新增,注意:无 "video_" 前缀)
"jimeng-video-seedance-2.0-fast": "dreamina_seedance_20_fast",
"seedance-2.0-fast": "dreamina_seedance_20_fast",
};
// 判断是否为 Seedance 模型
export function isSeedanceModel(model: string): boolean {
return model.startsWith("seedance-") || model.startsWith("jimeng-video-seedance-");
}
// ========== Seedance 多类型素材支持 ==========
// 素材类型
type SeedanceMaterialType = "image" | "video" | "audio";
// 上传结果统一接口
interface UploadedMaterial {
type: SeedanceMaterialType;
// 图片
uri?: string;
// 视频/音频(VOD)
vid?: string;
// 通用
width?: number;
height?: number;
duration?: number;
fps?: number;
name?: string;
}
// MIME 类型 → 素材类型映射
const MIME_TO_MATERIAL_TYPE: Record<string, SeedanceMaterialType> = {
"image/jpeg": "image", "image/png": "image", "image/webp": "image",
"image/gif": "image", "image/bmp": "image",
"video/mp4": "video", "video/quicktime": "video", "video/x-m4v": "video",
"audio/mpeg": "audio", "audio/wav": "audio", "audio/x-wav": "audio",
"audio/mp3": "audio",
};
// 扩展名 → 素材类型映射(兜底)
const EXT_TO_MATERIAL_TYPE: Record<string, SeedanceMaterialType> = {
".jpg": "image", ".jpeg": "image", ".png": "image", ".webp": "image",
".gif": "image", ".bmp": "image",
".mp4": "video", ".mov": "video", ".m4v": "video",
".mp3": "audio", ".wav": "audio",
};
// materialTypes 编码映射
const MATERIAL_TYPE_CODE: Record<SeedanceMaterialType, number> = {
image: 1, video: 2, audio: 3,
};
/**
* 检测上传文件的素材类型
* 优先通过 MIME 类型判断,兜底通过文件扩展名
*/
function detectMaterialType(file: any): SeedanceMaterialType {
// 优先通过 MIME 类型判断
const mime = (file.mimetype || file.mimeType || "").toLowerCase();
if (mime && MIME_TO_MATERIAL_TYPE[mime]) return MIME_TO_MATERIAL_TYPE[mime];
// 兜底:通过文件扩展名判断
const filename = (file.originalFilename || file.newFilename || "").toLowerCase();
const dotIdx = filename.lastIndexOf(".");
if (dotIdx >= 0) {
const ext = filename.substring(dotIdx);
if (EXT_TO_MATERIAL_TYPE[ext]) return EXT_TO_MATERIAL_TYPE[ext];
}
// 默认视为图片(向后兼容)
return "image";
}
/**
* 从 URL 检测素材类型
* 通过 URL 路径的扩展名判断
*/
function detectMaterialTypeFromUrl(url: string): SeedanceMaterialType {
try {
const pathname = new URL(url).pathname.toLowerCase();
const dotIdx = pathname.lastIndexOf(".");
if (dotIdx >= 0) {
const ext = pathname.substring(dotIdx);
if (EXT_TO_MATERIAL_TYPE[ext]) return EXT_TO_MATERIAL_TYPE[ext];
}
} catch {}
// 默认视为图片(向后兼容)
return "image";
}
// 视频支持的分辨率和比例配置
const VIDEO_RESOLUTION_OPTIONS: {
[resolution: string]: {
[ratio: string]: { width: number; height: number };
};
} = {
"480p": {
"1:1": { width: 480, height: 480 },
"4:3": { width: 640, height: 480 },
"3:4": { width: 480, height: 640 },
"16:9": { width: 854, height: 480 },
"9:16": { width: 480, height: 854 },
},
"720p": {
"1:1": { width: 720, height: 720 },
"4:3": { width: 960, height: 720 },
"3:4": { width: 720, height: 960 },
"16:9": { width: 1280, height: 720 },
"9:16": { width: 720, height: 1280 },
},
"1080p": {
"1:1": { width: 1080, height: 1080 },
"4:3": { width: 1440, height: 1080 },
"3:4": { width: 1080, height: 1440 },
"16:9": { width: 1920, height: 1080 },
"9:16": { width: 1080, height: 1920 },
},
};
// 解析视频分辨率参数
function resolveVideoResolution(
resolution: string = "720p",
ratio: string = "1:1"
): { width: number; height: number } {
const resolutionGroup = VIDEO_RESOLUTION_OPTIONS[resolution];
if (!resolutionGroup) {
const supportedResolutions = Object.keys(VIDEO_RESOLUTION_OPTIONS).join(", ");
throw new Error(`不支持的视频分辨率 "${resolution}"。支持的分辨率: ${supportedResolutions}`);
}
const ratioConfig = resolutionGroup[ratio];
if (!ratioConfig) {
const supportedRatios = Object.keys(resolutionGroup).join(", ");
throw new Error(`在 "${resolution}" 分辨率下,不支持的比例 "${ratio}"。支持的比例: ${supportedRatios}`);
}
return {
width: ratioConfig.width,
height: ratioConfig.height,
};
}
export function getModel(model: string) {
return MODEL_MAP[model] || MODEL_MAP[DEFAULT_MODEL];
}
// AWS4-HMAC-SHA256 签名生成函数(从 images.ts 复制)
function createSignature(
method: string,
url: string,
headers: { [key: string]: string },
accessKeyId: string,
secretAccessKey: string,
sessionToken?: string,
payload: string = '',
awsRegion: string = provider.imageAwsRegion,
serviceName: string = 'imagex'
) {
const urlObj = new URL(url);
const pathname = urlObj.pathname || '/';
const search = urlObj.search;
// 创建规范请求
const timestamp = headers['x-amz-date'];
const date = timestamp.substr(0, 8);
const region = awsRegion;
const service = serviceName;
// 规范化查询参数
const queryParams: Array<[string, string]> = [];
const searchParams = new URLSearchParams(search);
searchParams.forEach((value, key) => {
queryParams.push([key, value]);
});
// 按键名排序
queryParams.sort(([a], [b]) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
const canonicalQueryString = queryParams
.map(([key, value]) => `${key}=${value}`)
.join('&');
// 规范化头部
const headersToSign: { [key: string]: string } = {
'x-amz-date': timestamp
};
if (sessionToken) {
headersToSign['x-amz-security-token'] = sessionToken;
}
let payloadHash = crypto.createHash('sha256').update('').digest('hex');
if (method.toUpperCase() === 'POST' && payload) {
payloadHash = crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
headersToSign['x-amz-content-sha256'] = payloadHash;
}
const signedHeaders = Object.keys(headersToSign)
.map(key => key.toLowerCase())
.sort()
.join(';');
const canonicalHeaders = Object.keys(headersToSign)
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(key => `${key.toLowerCase()}:${headersToSign[key].trim()}\n`)
.join('');
const canonicalRequest = [
method.toUpperCase(),
pathname,
canonicalQueryString,
canonicalHeaders,
signedHeaders,
payloadHash
].join('\n');
// 创建待签名字符串
const credentialScope = `${date}/${region}/${service}/aws4_request`;
const stringToSign = [
'AWS4-HMAC-SHA256',
timestamp,
credentialScope,
crypto.createHash('sha256').update(canonicalRequest, 'utf8').digest('hex')
].join('\n');
// 生成签名
const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(date).digest();
const kRegion = crypto.createHmac('sha256', kDate).update(region).digest();
const kService = crypto.createHmac('sha256', kRegion).update(service).digest();
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex');
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}
// 计算文件的CRC32值(从 images.ts 复制)
function calculateCRC32(buffer: ArrayBuffer): string {
const crcTable = [];
for (let i = 0; i < 256; i++) {
let crc = i;
for (let j = 0; j < 8; j++) {
crc = (crc & 1) ? (0xEDB88320 ^ (crc >>> 1)) : (crc >>> 1);
}
crcTable[i] = crc;
}
let crc = 0 ^ (-1);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ bytes[i]) & 0xFF];
}
return ((crc ^ (-1)) >>> 0).toString(16).padStart(8, '0');
}
// 视频专用图片上传功能(基于 images.ts 的 uploadImageFromUrl)
async function uploadImageForVideo(imageUrl: string, refreshToken: string): Promise<string> {
try {
logger.info(`开始上传视频图片: ${imageUrl}`);
// 第一步:获取上传令牌
const tokenResult = await request("post", "/mweb/v1/get_upload_token", refreshToken, {
data: {
scene: 2, // AIGC 图片上传场景
},
});
const { access_key_id, secret_access_key, session_token, service_id } = tokenResult;
if (!access_key_id || !secret_access_key || !session_token) {
throw new Error("获取上传令牌失败");
}
const actualServiceId = service_id || "tb4s082cfz";
logger.info(`获取上传令牌成功: service_id=${actualServiceId}`);
// 下载图片数据
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error(`下载图片失败: ${imageResponse.status}`);
}
const imageBuffer = await imageResponse.arrayBuffer();
const fileSize = imageBuffer.byteLength;
const crc32 = calculateCRC32(imageBuffer);
logger.info(`图片下载完成: 大小=${fileSize}字节, CRC32=${crc32}`);
// 第二步:申请图片上传权限
const now = new Date();
const timestamp = now.toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
const randomStr = Math.random().toString(36).substring(2, 12);
const applyUrl = `https://imagex.bytedanceapi.com/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${actualServiceId}&FileSize=${fileSize}&s=${randomStr}`;
const requestHeaders = {
'x-amz-date': timestamp,
'x-amz-security-token': session_token
};
const authorization = createSignature('GET', applyUrl, requestHeaders, access_key_id, secret_access_key, session_token);
logger.info(`申请上传权限: ${applyUrl}`);
const applyResponse = await fetch(applyUrl, {
method: 'GET',
headers: {
'accept': '*/*',
'accept-language': provider.acceptLanguage,
'authorization': authorization,
'origin': provider.origin,
'referer': provider.generateVideoReferer,
'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Google Chrome";v="132"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'cross-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'x-amz-date': timestamp,
'x-amz-security-token': session_token,
},
});
if (!applyResponse.ok) {
const errorText = await applyResponse.text();
throw new Error(`申请上传权限失败: ${applyResponse.status} - ${errorText}`);
}
const applyResult = await applyResponse.json();
if (applyResult?.ResponseMetadata?.Error) {
throw new Error(`申请上传权限失败: ${JSON.stringify(applyResult.ResponseMetadata.Error)}`);
}
logger.info(`申请上传权限成功`);
// 解析上传信息
const uploadAddress = applyResult?.Result?.UploadAddress;
if (!uploadAddress || !uploadAddress.StoreInfos || !uploadAddress.UploadHosts) {
throw new Error(`获取上传地址失败: ${JSON.stringify(applyResult)}`);
}
const storeInfo = uploadAddress.StoreInfos[0];
const uploadHost = uploadAddress.UploadHosts[0];
const auth = storeInfo.Auth;
const uploadUrl = `https://${uploadHost}/upload/v1/${storeInfo.StoreUri}`;
const imageId = storeInfo.StoreUri.split('/').pop();
logger.info(`准备上传图片: imageId=${imageId}, uploadUrl=${uploadUrl}`);
// 第三步:上传图片文件
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Accept': '*/*',
'Accept-Language': provider.acceptLanguage,
'Authorization': auth,
'Connection': 'keep-alive',
'Content-CRC32': crc32,
'Content-Disposition': 'attachment; filename="undefined"',
'Content-Type': 'application/octet-stream',
'Origin': provider.origin,
'Referer': provider.generateVideoReferer,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'X-Storage-U': '704135154117550',
},
body: imageBuffer,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`图片上传失败: ${uploadResponse.status} - ${errorText}`);
}
logger.info(`图片文件上传成功`);
// 第四步:提交上传
const commitUrl = `https://imagex.bytedanceapi.com/?Action=CommitImageUpload&Version=2018-08-01&ServiceId=${actualServiceId}`;
const commitTimestamp = new Date().toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
const commitPayload = JSON.stringify({
SessionKey: uploadAddress.SessionKey,
SuccessActionStatus: "200"
});
const payloadHash = crypto.createHash('sha256').update(commitPayload, 'utf8').digest('hex');
const commitRequestHeaders = {
'x-amz-date': commitTimestamp,
'x-amz-security-token': session_token,
'x-amz-content-sha256': payloadHash
};
const commitAuthorization = createSignature('POST', commitUrl, commitRequestHeaders, access_key_id, secret_access_key, session_token, commitPayload);
const commitResponse = await fetch(commitUrl, {
method: 'POST',
headers: {
'accept': '*/*',
'accept-language': provider.acceptLanguage,
'authorization': commitAuthorization,
'content-type': 'application/json',
'origin': provider.origin,
'referer': provider.generateVideoReferer,
'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Google Chrome";v="132"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'cross-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'x-amz-date': commitTimestamp,
'x-amz-security-token': session_token,
'x-amz-content-sha256': payloadHash,
},
body: commitPayload,
});
if (!commitResponse.ok) {
const errorText = await commitResponse.text();
throw new Error(`提交上传失败: ${commitResponse.status} - ${errorText}`);
}
const commitResult = await commitResponse.json();
if (commitResult?.ResponseMetadata?.Error) {
throw new Error(`提交上传失败: ${JSON.stringify(commitResult.ResponseMetadata.Error)}`);
}
if (!commitResult?.Result?.Results || commitResult.Result.Results.length === 0) {
throw new Error(`提交上传响应缺少结果: ${JSON.stringify(commitResult)}`);
}
const uploadResult = commitResult.Result.Results[0];
if (uploadResult.UriStatus !== 2000) {
throw new Error(`图片上传状态异常: UriStatus=${uploadResult.UriStatus}`);
}
const fullImageUri = uploadResult.Uri;
// 验证图片信息
const pluginResult = commitResult.Result?.PluginResult?.[0];
if (pluginResult && pluginResult.ImageUri) {
logger.info(`视频图片上传完成: ${pluginResult.ImageUri}`);
return pluginResult.ImageUri;
}
logger.info(`视频图片上传完成: ${fullImageUri}`);
return fullImageUri;
} catch (error) {
logger.error(`视频图片上传失败: ${error.message}`);
throw error;
}
}
// 从Buffer上传视频图片
async function uploadImageBufferForVideo(buffer: Buffer, refreshToken: string): Promise<string> {
try {
logger.info(`开始从Buffer上传视频图片,大小: ${buffer.length}字节`);
// 第一步:获取上传令牌
const tokenResult = await request("post", "/mweb/v1/get_upload_token", refreshToken, {
data: {
scene: 2,
},
});
const { access_key_id, secret_access_key, session_token, service_id } = tokenResult;
if (!access_key_id || !secret_access_key || !session_token) {
throw new Error("获取上传令牌失败");
}
const actualServiceId = service_id || "tb4s082cfz";
logger.info(`获取上传令牌成功: service_id=${actualServiceId}`);
const fileSize = buffer.length;
const crc32 = calculateCRC32(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
logger.info(`Buffer大小: ${fileSize}字节, CRC32=${crc32}`);
// 第二步:申请图片上传权限
const now = new Date();
const timestamp = now.toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
const randomStr = Math.random().toString(36).substring(2, 12);
const applyUrl = `https://imagex.bytedanceapi.com/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${actualServiceId}&FileSize=${fileSize}&s=${randomStr}`;
const requestHeaders = {
'x-amz-date': timestamp,
'x-amz-security-token': session_token
};
const authorization = createSignature('GET', applyUrl, requestHeaders, access_key_id, secret_access_key, session_token);
const applyResponse = await fetch(applyUrl, {
method: 'GET',
headers: {
'accept': '*/*',
'accept-language': provider.acceptLanguage,
'authorization': authorization,
'origin': provider.origin,
'referer': provider.generateVideoReferer,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'x-amz-date': timestamp,
'x-amz-security-token': session_token,
},
});
if (!applyResponse.ok) {
const errorText = await applyResponse.text();
throw new Error(`申请上传权限失败: ${applyResponse.status} - ${errorText}`);
}
const applyResult = await applyResponse.json();
if (applyResult?.ResponseMetadata?.Error) {
throw new Error(`申请上传权限失败: ${JSON.stringify(applyResult.ResponseMetadata.Error)}`);
}
const uploadAddress = applyResult?.Result?.UploadAddress;
if (!uploadAddress || !uploadAddress.StoreInfos || !uploadAddress.UploadHosts) {
throw new Error(`获取上传地址失败: ${JSON.stringify(applyResult)}`);
}
const storeInfo = uploadAddress.StoreInfos[0];
const uploadHost = uploadAddress.UploadHosts[0];
const auth = storeInfo.Auth;
const uploadUrl = `https://${uploadHost}/upload/v1/${storeInfo.StoreUri}`;
// 第三步:上传图片文件
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Accept': '*/*',
'Authorization': auth,
'Content-CRC32': crc32,
'Content-Disposition': 'attachment; filename="undefined"',
'Content-Type': 'application/octet-stream',
'Origin': provider.origin,
'Referer': provider.generateVideoReferer,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
},
body: buffer,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`图片上传失败: ${uploadResponse.status} - ${errorText}`);
}
logger.info(`Buffer图片文件上传成功`);
// 第四步:提交上传
const commitUrl = `https://imagex.bytedanceapi.com/?Action=CommitImageUpload&Version=2018-08-01&ServiceId=${actualServiceId}`;
const commitTimestamp = new Date().toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
const commitPayload = JSON.stringify({
SessionKey: uploadAddress.SessionKey,
SuccessActionStatus: "200"
});
const payloadHash = crypto.createHash('sha256').update(commitPayload, 'utf8').digest('hex');
const commitRequestHeaders = {
'x-amz-date': commitTimestamp,
'x-amz-security-token': session_token,
'x-amz-content-sha256': payloadHash
};
const commitAuthorization = createSignature('POST', commitUrl, commitRequestHeaders, access_key_id, secret_access_key, session_token, commitPayload);
const commitResponse = await fetch(commitUrl, {
method: 'POST',
headers: {
'accept': '*/*',
'authorization': commitAuthorization,
'content-type': 'application/json',
'origin': provider.origin,
'referer': provider.generateVideoReferer,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'x-amz-date': commitTimestamp,
'x-amz-security-token': session_token,
'x-amz-content-sha256': payloadHash,
},
body: commitPayload,
});
if (!commitResponse.ok) {
const errorText = await commitResponse.text();
throw new Error(`提交上传失败: ${commitResponse.status} - ${errorText}`);
}
const commitResult = await commitResponse.json();
if (commitResult?.ResponseMetadata?.Error) {
throw new Error(`提交上传失败: ${JSON.stringify(commitResult.ResponseMetadata.Error)}`);
}
if (!commitResult?.Result?.Results || commitResult.Result.Results.length === 0) {
throw new Error(`提交上传响应缺少结果: ${JSON.stringify(commitResult)}`);
}
const uploadResult = commitResult.Result.Results[0];
if (uploadResult.UriStatus !== 2000) {
throw new Error(`图片上传状态异常: UriStatus=${uploadResult.UriStatus}`);
}
const fullImageUri = uploadResult.Uri;
const pluginResult = commitResult.Result?.PluginResult?.[0];
if (pluginResult && pluginResult.ImageUri) {
logger.info(`Buffer视频图片上传完成: ${pluginResult.ImageUri}`);
return pluginResult.ImageUri;
}
logger.info(`Buffer视频图片上传完成: ${fullImageUri}`);
return fullImageUri;
} catch (error) {
logger.error(`Buffer视频图片上传失败: ${error.message}`);
throw error;
}
}
/**
* 解析音频文件时长(毫秒)
* 支持 WAV 格式精确解析,其他格式按 128kbps 估算
*/
function parseAudioDuration(buffer: Buffer): number {
try {
// WAV: RIFF header check
if (buffer.length >= 44 &&
buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x41 && buffer[10] === 0x56 && buffer[11] === 0x45) {
const byteRate = buffer.readUInt32LE(28);
if (byteRate > 0) {
// 查找 data chunk 获取精确大小
let offset = 12;
while (offset < buffer.length - 8) {
const chunkId = buffer.toString('ascii', offset, offset + 4);
const chunkSize = buffer.readUInt32LE(offset + 4);
if (chunkId === 'data') {
return Math.round(chunkSize / byteRate * 1000);
}
offset += 8 + chunkSize;
}
// 兜底:用文件大小估算
return Math.round((buffer.length - 44) / byteRate * 1000);
}
}
// 非 WAV:按 128kbps 估算
return Math.round(buffer.length / (128 * 1000 / 8) * 1000);
} catch {
return 0;
}
}
/**
* 上传视频/音频文件
* 通过 ByteDance VOD (视频点播) API 上传
* 流程: get_upload_token(scene=1) → ApplyUploadInner → Upload → CommitUploadInner
*
* @param buffer 文件 Buffer
* @param mediaType "video" 或 "audio"
* @param refreshToken 刷新令牌
* @param filename 原始文件名(可选)
* @returns { vid, width?, height?, duration?, fps? }
*/
async function uploadMediaForVideo(
buffer: Buffer,
mediaType: "video" | "audio",
refreshToken: string,
filename?: string
): Promise<{ vid: string; width?: number; height?: number; duration?: number; fps?: number }> {
const label = mediaType === "audio" ? "音频" : "视频";
const fileSize = buffer.length;
logger.info(`开始上传${label}文件,大小: ${fileSize} 字节`);
// 第一步:获取 VOD 上传令牌(scene=1)
const tokenResult = await request("post", "/mweb/v1/get_upload_token", refreshToken, {
data: { scene: 1 },
});
const { access_key_id, secret_access_key, session_token, space_name } = tokenResult;
if (!access_key_id || !secret_access_key || !session_token) {
throw new Error(`获取${label}上传令牌失败`);
}
const spaceName = space_name || "dreamina";
logger.info(`获取${label}上传令牌成功: spaceName=${spaceName}`);
// 第二步:申请 VOD 上传权限(ApplyUploadInner)
const now = new Date();
const timestamp = now.toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
const randomStr = Math.random().toString(36).substring(2, 12);
const vodHost = "https://vod.bytedanceapi.com";
const applyUrl = `${vodHost}/?Action=ApplyUploadInner&Version=2020-11-19&SpaceName=${spaceName}&FileType=video&IsInner=1&FileSize=${fileSize}&s=${randomStr}`;
const requestHeaders: Record<string, string> = {
'x-amz-date': timestamp,
'x-amz-security-token': session_token,
};
const authorization = createSignature(
'GET', applyUrl, requestHeaders,
access_key_id, secret_access_key, session_token,
'', provider.vodAwsRegion, 'vod'
);
logger.info(`申请${label}上传权限: ${applyUrl}`);
const applyResponse = await fetch(applyUrl, {
method: 'GET',
headers: {
'accept': '*/*',
'accept-language': provider.acceptLanguage,
'authorization': authorization,
'origin': provider.origin,
'referer': provider.generateVideoReferer,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'x-amz-date': timestamp,
'x-amz-security-token': session_token,
},
});
if (!applyResponse.ok) {
const errorText = await applyResponse.text();
throw new Error(`申请${label}上传权限失败: ${applyResponse.status} - ${errorText}`);
}
const applyResult: any = await applyResponse.json();
if (applyResult?.ResponseMetadata?.Error) {
throw new Error(`申请${label}上传权限失败: ${JSON.stringify(applyResult.ResponseMetadata.Error)}`);
}
const uploadNodes = applyResult?.Result?.InnerUploadAddress?.UploadNodes;
if (!uploadNodes || uploadNodes.length === 0) {
throw new Error(`获取${label}上传节点失败: ${JSON.stringify(applyResult)}`);
}
const uploadNode = uploadNodes[0];
const storeInfo = uploadNode.StoreInfos?.[0];
if (!storeInfo) {
throw new Error(`获取${label}上传存储信息失败: ${JSON.stringify(uploadNode)}`);
}
const uploadHost = uploadNode.UploadHost;
const storeUri = storeInfo.StoreUri;
const auth = storeInfo.Auth;
const sessionKey = uploadNode.SessionKey;
const vid = uploadNode.Vid;
logger.info(`获取${label}上传节点成功: host=${uploadHost}, vid=${vid}`);
// 第三步:上传文件
const uploadUrl = `https://${uploadHost}/upload/v1/${storeUri}`;
const crc32 = calculateCRC32(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
logger.info(`开始上传${label}文件: ${uploadUrl}, CRC32=${crc32}`);
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Accept': '*/*',
'Authorization': auth,
'Content-CRC32': crc32,
'Content-Type': 'application/octet-stream',
'Origin': provider.origin,
'Referer': provider.generateVideoReferer,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
},
body: buffer,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`${label}文件上传失败: ${uploadResponse.status} - ${errorText}`);
}
const uploadData: any = await uploadResponse.json();
if (uploadData?.code !== 2000) {
throw new Error(`${label}文件上传失败: code=${uploadData?.code}, message=${uploadData?.message}`);
}
logger.info(`${label}文件上传成功: crc32=${uploadData.data?.crc32}`);
// 第四步:确认上传(CommitUploadInner)
const commitUrl = `${vodHost}/?Action=CommitUploadInner&Version=2020-11-19&SpaceName=${spaceName}`;
const commitTimestamp = new Date().toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
const commitPayload = JSON.stringify({
SessionKey: sessionKey,
Functions: [],
});
const payloadHash = crypto.createHash('sha256').update(commitPayload, 'utf8').digest('hex');
const commitRequestHeaders: Record<string, string> = {
'x-amz-date': commitTimestamp,
'x-amz-security-token': session_token,
'x-amz-content-sha256': payloadHash,
};
const commitAuthorization = createSignature(
'POST', commitUrl, commitRequestHeaders,
access_key_id, secret_access_key, session_token,
commitPayload, provider.vodAwsRegion, 'vod'
);
logger.info(`提交${label}上传确认: ${commitUrl}`);
const commitResponse = await fetch(commitUrl, {
method: 'POST',
headers: {
'accept': '*/*',
'authorization': commitAuthorization,
'content-type': 'application/json',
'origin': provider.origin,
'referer': provider.generateVideoReferer,
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
'x-amz-date': commitTimestamp,
'x-amz-security-token': session_token,
'x-amz-content-sha256': payloadHash,
},
body: commitPayload,
});
if (!commitResponse.ok) {
const errorText = await commitResponse.text();
throw new Error(`提交${label}上传失败: ${commitResponse.status} - ${errorText}`);
}
const commitResult: any = await commitResponse.json();
if (commitResult?.ResponseMetadata?.Error) {
throw new Error(`提交${label}上传失败: ${JSON.stringify(commitResult.ResponseMetadata.Error)}`);
}
if (!commitResult?.Result?.Results || commitResult.Result.Results.length === 0) {
throw new Error(`提交${label}上传响应缺少结果: ${JSON.stringify(commitResult)}`);
}
const result = commitResult.Result.Results[0];
if (!result.Vid) {
throw new Error(`提交${label}上传响应缺少 Vid: ${JSON.stringify(result)}`);
}
// 从 VOD 返回的元数据中获取信息(音频有 Duration)
const videoMeta = result.VideoMeta || {};
let duration = videoMeta.Duration ? Math.round(videoMeta.Duration * 1000) : 0;
// 如果 VOD 未返回时长,用本地解析兜底
if (duration <= 0 && mediaType === "audio") {
duration = parseAudioDuration(buffer);
logger.info(`VOD 未返回${label}时长,本地解析: ${duration}ms`);
}
logger.info(`${label}上传完成: vid=${result.Vid}, duration=${duration}ms`);
return {
vid: result.Vid,
width: videoMeta.Width || 0,
height: videoMeta.Height || 0,
duration,
fps: videoMeta.Fps || 0,
};
}
/**
* 通过 get_local_item_list API 获取高质量视频下载URL
* 浏览器下载视频时使用此API获取高码率版本(~6297 vs 预览版 ~1152)
*
* @param itemId 视频项目ID
* @param refreshToken 刷新令牌
* @returns 高质量视频URL,失败时返回 null
*/
async function fetchHighQualityVideoUrl(itemId: string, refreshToken: string): Promise<string | null> {
try {
logger.info(`尝试获取高质量视频下载URL,item_id: ${itemId}`);
const result = await request("post", "/mweb/v1/get_local_item_list", refreshToken, {
data: {
item_id_list: [itemId],
pack_item_opt: {
scene: 1,
need_data_integrity: true,
},
is_for_video_download: true,
},
});
const responseStr = JSON.stringify(result);
logger.info(`get_local_item_list 响应大小: ${responseStr.length} 字符`);
// 策略1: 从结构化字段中提取视频URL
const itemList = result.item_list || result.local_item_list || [];
if (itemList.length > 0) {
const item = itemList[0];
const videoUrl =
item?.video?.transcoded_video?.origin?.video_url ||
item?.video?.download_url ||
item?.video?.play_url ||
item?.video?.url;
if (videoUrl) {
logger.info(`从get_local_item_list结构化字段获取到高清视频URL: ${videoUrl}`);
return videoUrl;
}
}
// 策略2: 正则匹配 dreamnia.jimeng.com 高质量URL
const hqUrlMatch = responseStr.match(/https:\/\/v[0-9]+-dreamnia\.jimeng\.com\/[^"\s\\]+/);
if (hqUrlMatch && hqUrlMatch[0]) {
logger.info(`正则提取到高质量视频URL (dreamnia): ${hqUrlMatch[0]}`);
return hqUrlMatch[0];
}
// 策略3: 匹配任何 jimeng.com 域名的视频URL
const jimengUrlMatch = responseStr.match(/https:\/\/v[0-9]+-[^"\\]*\.jimeng\.com\/[^"\s\\]+/);
if (jimengUrlMatch && jimengUrlMatch[0]) {
logger.info(`正则提取到jimeng视频URL: ${jimengUrlMatch[0]}`);
return jimengUrlMatch[0];
}
// 策略4: 匹配任何视频URL(兜底)
const anyVideoUrlMatch = responseStr.match(/https:\/\/v[0-9]+-[^"\\]*\.(vlabvod|jimeng)\.com\/[^"\s\\]+/);
if (anyVideoUrlMatch && anyVideoUrlMatch[0]) {
logger.info(`从get_local_item_list提取到视频URL: ${anyVideoUrlMatch[0]}`);
return anyVideoUrlMatch[0];
}
logger.warn(`未能从get_local_item_list响应中提取到视频URL`);
return null;
} catch (error) {
logger.warn(`获取高质量视频下载URL失败: ${error.message}`);
return null;
}
}
/**
* 生成视频
*
* @param _model 模型名称
* @param prompt 提示词
* @param options 选项
* @param refreshToken 刷新令牌
* @returns 视频URL
*/
export async function generateVideo(
_model: string,
prompt: string,
{
ratio = "1:1",
resolution = "720p",
duration = 5,
filePaths = [],
files = [],
}: {
ratio?: string;
resolution?: string;
duration?: number;
filePaths?: string[];
files?: any[];
},
refreshToken: string
) {
const model = getModel(_model);
// 解析分辨率参数获取实际的宽高
const { width, height } = resolveVideoResolution(resolution, ratio);
logger.info(`使用模型: ${_model} 映射模型: ${model} ${width}x${height} (${ratio}@${resolution}) 时长: ${duration}秒`);
// 检查积分
const { totalCredit } = await getCredit(refreshToken);
if (totalCredit <= 0)
await receiveCredit(refreshToken);
// 处理首帧和尾帧图片
let first_frame_image = undefined;
let end_frame_image = undefined;
// 处理上传的文件(multipart/form-data)
if (files && files.length > 0) {
let uploadIDs: string[] = [];
logger.info(`开始处理 ${files.length} 个上传文件用于视频生成`);
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file || !file.filepath) {
logger.warn(`第 ${i + 1} 个文件无效,跳过`);
continue;
}
try {
logger.info(`开始上传第 ${i + 1} 个文件: ${file.originalFilename || file.filepath}`);
// 读取文件内容并上传
const buffer = fs.readFileSync(file.filepath);
const imageUri = await uploadImageBufferForVideo(buffer, refreshToken);
if (imageUri) {
uploadIDs.push(imageUri);
logger.info(`第 ${i + 1} 个文件上传成功: ${imageUri}`);
} else {
logger.error(`第 ${i + 1} 个文件上传失败: 未获取到 image_uri`);
}
} catch (error) {
logger.error(`第 ${i + 1} 个文件上传失败: ${error.message}`);
if (i === 0) {
logger.error(`首帧文件上传失败,停止视频生成以避免浪费积分`);
throw new APIException(EX.API_REQUEST_FAILED, `首帧文件上传失败: ${error.message}`);
} else {
logger.warn(`第 ${i + 1} 个文件上传失败,将跳过此文件继续处理`);
}
}
}
logger.info(`文件上传完成,成功上传 ${uploadIDs.length} 个文件`);
if (uploadIDs.length === 0) {
logger.error(`所有文件上传失败,停止视频生成以避免浪费积分`);
throw new APIException(EX.API_REQUEST_FAILED, '所有文件上传失败,请检查文件是否有效');
}
// 构建首帧图片对象
if (uploadIDs[0]) {
first_frame_image = {
format: "",
height: height,
id: util.uuid(),
image_uri: uploadIDs[0],
name: "",
platform_type: 1,
source_from: "upload",
type: "image",
uri: uploadIDs[0],
width: width,
};
logger.info(`设置首帧图片: ${uploadIDs[0]}`);
}
// 构建尾帧图片对象
if (uploadIDs[1]) {
end_frame_image = {
format: "",
height: height,
id: util.uuid(),
image_uri: uploadIDs[1],
name: "",
platform_type: 1,
source_from: "upload",
type: "image",
uri: uploadIDs[1],
width: width,
};
logger.info(`设置尾帧图片: ${uploadIDs[1]}`);
}
} else if (filePaths && filePaths.length > 0) {
let uploadIDs: string[] = [];
logger.info(`开始上传 ${filePaths.length} 张图片用于视频生成`);
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
if (!filePath) {
logger.warn(`第 ${i + 1} 张图片路径为空,跳过`);
continue;
}
try {
logger.info(`开始上传第 ${i + 1} 张图片: ${filePath}`);
// 使用Amazon S3上传方式
const imageUri = await uploadImageForVideo(filePath, refreshToken);
if (imageUri) {
uploadIDs.push(imageUri);
logger.info(`第 ${i + 1} 张图片上传成功: ${imageUri}`);
} else {
logger.error(`第 ${i + 1} 张图片上传失败: 未获取到 image_uri`);
}
} catch (error) {
logger.error(`第 ${i + 1} 张图片上传失败: ${error.message}`);
// 图片上传失败时,停止视频生成避免浪费积分
if (i === 0) {
logger.error(`首帧图片上传失败,停止视频生成以避免浪费积分`);
throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
} else {
logger.warn(`第 ${i + 1} 张图片上传失败,将跳过此图片继续处理`);
}
}
}
logger.info(`图片上传完成,成功上传 ${uploadIDs.length} 张图片`);
// 如果没有成功上传任何图片,停止视频生成
if (uploadIDs.length === 0) {
logger.error(`所有图片上传失败,停止视频生成以避免浪费积分`);
throw new APIException(EX.API_REQUEST_FAILED, '所有图片上传失败,请检查图片URL是否有效');
}
// 构建首帧图片对象
if (uploadIDs[0]) {
first_frame_image = {
format: "",
height: height,
id: util.uuid(),
image_uri: uploadIDs[0],
name: "",
platform_type: 1,
source_from: "upload",
type: "image",
uri: uploadIDs[0],
width: width,
};
logger.info(`设置首帧图片: ${uploadIDs[0]}`);
}
// 构建尾帧图片对象
if (uploadIDs[1]) {
end_frame_image = {
format: "",
height: height,
id: util.uuid(),
image_uri: uploadIDs[1],
name: "",
platform_type: 1,
source_from: "upload",
type: "image",
uri: uploadIDs[1],
width: width,
};
logger.info(`设置尾帧图片: ${uploadIDs[1]}`);
} else if (filePaths.length > 1) {
logger.warn(`第二张图片上传失败或未提供,将仅使用首帧图片`);
}
} else {
logger.info(`未提供图片文件,将进行纯文本视频生成`);
}
const componentId = util.uuid();
const submitId = util.uuid();
const metricsExtra = provider.name === "dreamina-intl"
? JSON.stringify({
promptSource: "custom",
isDefaultSeed: 1,
originSubmitId: submitId,
isRegenerate: false,
enterFrom: "click",
position: "page_bottom_box",
functionMode: end_frame_image ? "first_last_frames" : "text_to_video",
sceneOptions: JSON.stringify([
{
type: "video",
scene: "BasicVideoGenerateButton",
resolution,
modelReqKey: model,
videoDuration: duration,
reportParams: {
enterSource: "generate",
vipSource: "generate",
extraVipFunctionKey: `${model}-${resolution}`,
useVipFunctionDetailsReporterHoc: true,
},
materialTypes: [],
},
]),
})
: JSON.stringify({
enterFrom: "click",
isDefaultSeed: 1,
promptSource: "custom",
isRegenerate: false,
originSubmitId: util.uuid(),
});
// 获取当前模型的 draft 版本
const draftVersion = MODEL_DRAFT_VERSIONS[_model] || DEFAULT_DRAFT_VERSION;
// 计算视频宽高比
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
const aspectRatio = `${width / divisor}:${height / divisor}`;
const requestBody = {
"extend": {
"root_model": end_frame_image ? MODEL_MAP['jimeng-video-3.0'] : model,
...(provider.name === "dreamina-intl"
? {
m_video_commerce_info: {
benefit_type: "basic_video_operation_vgfm_v_three",
resource_id: "generate_video",
resource_id_type: "str",
resource_sub_type: "aigc"
},
workspace_id: 0,
m_video_commerce_info_list: [{
benefit_type: "basic_video_operation_vgfm_v_three",
resource_id: "generate_video",
resource_id_type: "str",
resource_sub_type: "aigc"
}]
}
: {
m_video_commerce_info: {
benefit_type: "basic_video_operation_vgfm_v_three",
resource_id: "generate_video",
resource_id_type: "str",
resource_sub_type: "aigc"
},
m_video_commerce_info_list: [{
benefit_type: "basic_video_operation_vgfm_v_three",
resource_id: "generate_video",
resource_id_type: "str",
resource_sub_type: "aigc"
}]
}),
},
"submit_id": submitId,
"metrics_extra": metricsExtra,
"draft_content": JSON.stringify({
"type": "draft",
"id": util.uuid(),
"min_version": "3.0.5",
"is_from_tsn": true,
"version": draftVersion,
"main_component_id": componentId,
"component_list": [{
"type": "video_base_component",
"id": componentId,
"min_version": "1.0.0",
"metadata": {
"type": "",
"id": util.uuid(),
"created_platform": 3,
"created_platform_version": "",
"created_time_in_ms": Date.now(),
"created_did": ""
},
"generate_type": "gen_video",
"aigc_mode": "workbench",
"abilities": {
"type": "",
"id": util.uuid(),
"gen_video": {
"id": util.uuid(),
"type": "",
"text_to_video_params": {
"type": "",
"id": util.uuid(),
"model_req_key": model,
"priority": 0,
"seed": Math.floor(Math.random() * 100000000) + 2500000000,
"video_aspect_ratio": aspectRatio,
"video_gen_inputs": [{
duration_ms: duration * 1000,
first_frame_image: first_frame_image,
end_frame_image: end_frame_image,
fps: 24,
id: util.uuid(),
min_version: "3.0.5",
prompt: prompt,
resolution: resolution,
type: "",
video_mode: 2
}]
},
"video_task_extra": metricsExtra,
}
}
}],
}),
http_common_info: {
aid: DEFAULT_ASSISTANT_ID,
},
};
const deviceTime = util.unixTimestamp();
const generateResult = provider.name === "dreamina-intl"
? await browserService.fetch(
refreshToken,
`${provider.mwebApiBaseUrl}${provider.videoGeneratePath}?${new URLSearchParams({
aid: String(provider.assistantId),
device_platform: "web",
region: provider.region,
da_version: "3.3.12",
os: "windows",
web_component_open_flag: "1",
commerce_with_input_video: "1",
web_version: "7.5.0",
aigc_features: "app_lip_sync",
msToken: provider.extraCookies?.msToken || "",
}).toString()}`,
{
method: "POST",
headers: {
appid: String(provider.assistantId),
"app-sdk-version": "48.0.0",
appvr: "8.4.0",
"device-time": String(deviceTime),
sign: createRequestSign(provider.videoGeneratePath, deviceTime),
"sign-ver": "1",
loc: provider.loc,
lan: provider.lan,
pf: "7",
tdid: "",
did: "7623012616730936850",
"store-country-code": provider.storeRegionValue,
"store-country-code-src": provider.storeRegionSrcValue,
Accept: "application/json, text/plain, */*",
Referer: `${provider.pageBaseUrl}/`,
"accept-language": provider.acceptLanguage,
},
body: JSON.stringify(requestBody),
}
)
: await request(
"post",
provider.videoGeneratePath,
refreshToken,
{
params: {
aigc_features: "app_lip_sync",
web_version: "6.6.0",
da_version: draftVersion,
},
data: requestBody,
}
);
const aigc_data = generateResult?.aigc_data || generateResult?.data?.aigc_data || generateResult?.data || generateResult;
logger.info(`国际版视频生成返回: ${JSON.stringify(generateResult).slice(0, 1500)}`);
const historyId = aigc_data.history_record_id;
const pollId = submitId;
if (!historyId)
throw new APIException(EX.API_IMAGE_GENERATION_FAILED, `记录ID不存在,国际版返回: ${JSON.stringify(generateResult).slice(0, 800)}`);
// 轮询获取结果
let status = 20, failCode, item_list = [];
let retryCount = 0;
const maxRetries = 120; // 支持较长的视频生成时间(约20分钟以上)
// 首次查询前等待更长时间,让服务器有时间处理请求
await new Promise((resolve) => setTimeout(resolve, 5000));
logger.info(`开始轮询视频生成结果,历史ID: ${historyId},最大重试次数: ${maxRetries}`);
logger.info(`上游API地址: ${provider.baseUrl}/mweb/v1/get_history_by_ids`);
logger.info(`视频生成请求已发送,请同时在上游站点查看: ${provider.generateVideoReferer}`);
while (status === 20 && retryCount < maxRetries) {
try {
// 构建请求URL和参数
const requestUrl = "/mweb/v1/get_history_by_ids";
const requestData = provider.name === "dreamina-intl"
? { submit_ids: [pollId] }
: { history_ids: [historyId] };
// 尝试两种不同的API请求方式
let result;
let useAlternativeApi = retryCount > 10 && retryCount % 2 === 0; // 在重试10次后,每隔一次尝试备用API
if (useAlternativeApi) {
// 备用API请求方式
logger.info(`尝试备用API请求方式,URL: ${requestUrl}, 历史ID: ${historyId}, 重试次数: ${retryCount + 1}/${maxRetries}`);
const alternativeRequestData = {
history_record_ids: [historyId],
};
result = await request("post", "/mweb/v1/get_history_records", refreshToken, {
data: alternativeRequestData,
});
logger.info(`备用API响应摘要: ${JSON.stringify(result).substring(0, 500)}...`);
} else {
// 标准API请求方式
logger.info(`发送请求获取视频生成结果,URL: ${requestUrl}, 历史ID: ${historyId}, 重试次数: ${retryCount + 1}/${maxRetries}`);
result = await request("post", requestUrl, refreshToken, {
data: requestData,
});
const responseStr = JSON.stringify(result);
logger.info(`标准API响应摘要: ${responseStr.substring(0, 300)}...`);
}
// 检查结果是否有效
let historyData;
if (useAlternativeApi && result.history_records && result.history_records.length > 0) {
// 处理备用API返回的数据格式
historyData = result.history_records[0];
logger.info(`从备用API获取到历史记录`);
} else if (result.history_list && result.history_list.length > 0) {
// 处理标准API返回的数据格式
historyData = result.history_list[0];
logger.info(`从标准API获取到历史记录`);
} else if (result[provider.name === "dreamina-intl" ? pollId : historyId]) {
// get_history_by_ids 返回数据以 historyId 为键(如 result["8918159809292"])
historyData = result[provider.name === "dreamina-intl" ? pollId : historyId];
logger.info(`从historyId键获取到历史记录`);
} else {
// 所有API都没有返回有效数据
logger.warn(`历史记录不存在,重试中 (${retryCount + 1}/${maxRetries})... 历史ID: ${historyId}`);
logger.info(`请同时在上游站点检查视频是否已生成: ${provider.generateVideoReferer}`);
retryCount++;
// 增加重试间隔时间,但设置上限为30秒
const waitTime = Math.min(2000 * (retryCount + 1), 30000);
logger.info(`等待 ${waitTime}ms 后进行第 ${retryCount + 1} 次重试`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
continue;
}
// 记录获取到的结果详情
logger.info(`获取到历史记录结果: ${JSON.stringify(historyData)}`);
// 从历史数据中提取状态和结果
status = historyData.status;
failCode = historyData.fail_code;
item_list = historyData.item_list || [];
logger.info(`视频生成状态: ${status}, 失败代码: ${failCode || '无'}, 项目列表长度: ${item_list.length}`);
// 如果有视频URL,提前记录
let tempVideoUrl = item_list?.[0]?.video?.transcoded_video?.origin?.video_url;
if (!tempVideoUrl) {
// 尝试从其他可能的路径获取
tempVideoUrl = item_list?.[0]?.video?.play_url ||
item_list?.[0]?.video?.download_url ||
item_list?.[0]?.video?.url;
}
if (tempVideoUrl) {
logger.info(`检测到视频URL: ${tempVideoUrl}`);
}
if (status === 30) {
const error = failCode === 2038
? new APIException(EX.API_CONTENT_FILTERED, "内容被过滤")
: new APIException(EX.API_IMAGE_GENERATION_FAILED, `生成失败,错误码: ${failCode}`);
// 添加历史ID到错误对象,以便在chat.ts中显示
error.historyId = historyId;
throw error;
}
// 如果状态仍在处理中,等待后继续
if (status === 20) {
const waitTime = 2000 * (Math.min(retryCount + 1, 5)); // 随着重试次数增加等待时间,但最多10秒
logger.info(`视频生成中,状态码: ${status},等待 ${waitTime}ms 后继续查询`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
} catch (error) {
logger.error(`轮询视频生成结果出错: ${error.message}`);
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000 * (retryCount + 1)));
}
}
// 如果达到最大重试次数仍未成功
if (retryCount >= maxRetries && status === 20) {
logger.error(`视频生成超时,已尝试 ${retryCount} 次,总耗时约 ${Math.floor(retryCount * 2000 / 1000 / 60)} 分钟`);
const error = new APIException(EX.API_IMAGE_GENERATION_FAILED, "获取视频生成结果超时,请稍后在即梦官网查看您的视频");
// 添加历史ID到错误对象,以便在chat.ts中显示
error.historyId = historyId;
throw error;
}
// 尝试通过 get_local_item_list 获取高质量视频下载URL
const itemId = item_list?.[0]?.item_id
|| item_list?.[0]?.id
|| item_list?.[0]?.local_item_id
|| item_list?.[0]?.common_attr?.id;
if (itemId) {
try {
const hqVideoUrl = await fetchHighQualityVideoUrl(String(itemId), refreshToken);
if (hqVideoUrl) {
logger.info(`视频生成成功(高质量),URL: ${hqVideoUrl}`);
return hqVideoUrl;
}
} catch (error) {
logger.warn(`获取高质量视频URL失败,将使用预览URL作为回退: ${error.message}`);
}
} else {
logger.warn(`未能从item_list中提取item_id,将使用预览URL。item_list[0]键: ${item_list?.[0] ? Object.keys(item_list[0]).join(', ') : '无'}`);
}
// 回退:提取预览视频URL
let videoUrl = item_list?.[0]?.video?.transcoded_video?.origin?.video_url;
// 如果通过常规路径无法获取视频URL,尝试其他可能的路径
if (!videoUrl) {
// 尝试从item_list中的其他可能位置获取
if (item_list?.[0]?.video?.play_url) {
videoUrl = item_list[0].video.play_url;
logger.info(`从play_url获取到视频URL: ${videoUrl}`);
} else if (item_list?.[0]?.video?.download_url) {
videoUrl = item_list[0].video.download_url;
logger.info(`从download_url获取到视频URL: ${videoUrl}`);
} else if (item_list?.[0]?.video?.url) {
videoUrl = item_list[0].video.url;
logger.info(`从url获取到视频URL: ${videoUrl}`);
} else {
// 如果仍然找不到,记录错误并抛出异常
logger.error(`未能获取视频URL,item_list: ${JSON.stringify(item_list)}`);
const error = new APIException(EX.API_IMAGE_GENERATION_FAILED, "未能获取视频URL,请稍后在即梦官网查看");
// 添加历史ID到错误对象,以便在chat.ts中显示
error.historyId = historyId;
throw error;
}
}
logger.info(`视频生成成功,URL: ${videoUrl}`);
return videoUrl;
}
/**
* Seedance 2.0 多图智能视频生成
* 支持多张图片与文本混合生成视频
*
* @param _model 模型名称
* @param prompt 提示词(支持 @1 @2 等引用图片占位符)
* @param options 选项
* @param refreshToken 刷新令牌
* @returns 视频URL
*/
export async function generateSeedanceVideo(
_model: string,
prompt: string,
{
ratio = "4:3",
resolution = "720p",
duration = 4,
filePaths = [],
files = [],
}: {
ratio?: string;
resolution?: string;
duration?: number;
filePaths?: string[];
files?: any[];
},
refreshToken: string
) {
const model = getModel(_model);
const benefitType = SEEDANCE_BENEFIT_TYPE_MAP[_model] || "dreamina_video_seedance_20_pro";
// Seedance 2.0 默认时长为4秒
const actualDuration = duration || 4;
// 解析分辨率参数获取实际的宽高
const { width, height } = resolveVideoResolution(resolution, ratio);
logger.info(`Seedance 2.0 生成: 模型=${_model} 映射=${model} ${width}x${height} (${ratio}@${resolution}) 时长=${actualDuration}秒`);
// 检查积分
const { totalCredit } = await getCredit(refreshToken);
if (totalCredit <= 0)
await receiveCredit(refreshToken);
// 上传所有文件(支持图片/视频/音频)
let uploadedMaterials: UploadedMaterial[] = [];
// 处理上传的文件(multipart/form-data)
if (files && files.length > 0) {
logger.info(`Seedance: 开始处理 ${files.length} 个上传文件`);
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file || !file.filepath) {
logger.warn(`Seedance: 第 ${i + 1} 个文件无效,跳过`);
continue;
}
const materialType = detectMaterialType(file);
try {
logger.info(`Seedance: 开始上传第 ${i + 1} 个文件 (${materialType}): ${file.originalFilename || file.filepath}`);
const buffer = fs.readFileSync(file.filepath);
if (materialType === "image") {
const imageUri = await uploadImageBufferForVideo(buffer, refreshToken);
if (imageUri) {
uploadedMaterials.push({ type: "image", uri: imageUri, width, height });
logger.info(`Seedance: 第 ${i + 1} 个图片上传成功: ${imageUri}`);
}
} else {
// 视频或音频 → VOD 上传
const vodResult = await uploadMediaForVideo(buffer, materialType, refreshToken, file.originalFilename);
uploadedMaterials.push({
type: materialType,
vid: vodResult.vid,
width: vodResult.width,
height: vodResult.height,
duration: vodResult.duration,
fps: vodResult.fps,
name: file.originalFilename || "",
});
logger.info(`Seedance: 第 ${i + 1}${materialType === "video" ? "视频" : "音频"}上传成功: ${vodResult.vid}`);
}
} catch (error) {
logger.error(`Seedance: 第 ${i + 1} 个文件上传失败: ${error.message}`);
if (i === 0) {
throw new APIException(EX.API_REQUEST_FAILED, `首个文件上传失败: ${error.message}`);
}
}
}
} else if (filePaths && filePaths.length > 0) {
logger.info(`Seedance: 开始上传 ${filePaths.length} 个文件`);
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
if (!filePath) continue;
const materialType = detectMaterialTypeFromUrl(filePath);
try {
logger.info(`Seedance: 开始上传第 ${i + 1} 个文件 (${materialType}): ${filePath}`);
if (materialType === "image") {
const imageUri = await uploadImageForVideo(filePath, refreshToken);
if (imageUri) {
uploadedMaterials.push({ type: "image", uri: imageUri, width, height });
logger.info(`Seedance: 第 ${i + 1} 个图片上传成功: ${imageUri}`);
}
} else {
// 视频或音频 URL → 下载后 VOD 上传
const response = await fetch(filePath);
if (!response.ok) throw new Error(`下载文件失败: ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const vodResult = await uploadMediaForVideo(buffer, materialType, refreshToken);
uploadedMaterials.push({
type: materialType,
vid: vodResult.vid,
width: vodResult.width,
height: vodResult.height,
duration: vodResult.duration,
fps: vodResult.fps,
});
logger.info(`Seedance: 第 ${i + 1}${materialType === "video" ? "视频" : "音频"}上传成功: ${vodResult.vid}`);
}
} catch (error) {
logger.error(`Seedance: 第 ${i + 1} 个文件上传失败: ${error.message}`);
if (i === 0) {
throw new APIException(EX.API_REQUEST_FAILED, `首个文件上传失败: ${error.message}`);
}
}
}
}
if (uploadedMaterials.length === 0) {
throw new APIException(EX.API_REQUEST_FAILED, 'Seedance 2.0 需要至少一个文件(图片/视频/音频)');
}
logger.info(`Seedance: 成功上传 ${uploadedMaterials.length} 个文件`);
// 动态 benefit_type:包含视频素材时追加 _with_video 后缀
const hasVideoMaterial = uploadedMaterials.some(m => m.type === "video");
const finalBenefitType = hasVideoMaterial ? `${benefitType}_with_video` : benefitType;
// 构建 material_list(支持图片/视频/音频)
const materialList = uploadedMaterials.map((mat) => {
const base = { type: "", id: util.uuid() };
if (mat.type === "image") {
return {
...base,
material_type: "image",
image_info: {
type: "image",
id: util.uuid(),
source_from: "upload",
platform_type: 1,
name: "",
image_uri: mat.uri,
aigc_image: { type: "", id: util.uuid() },
width: mat.width,
height: mat.height,
format: "",
uri: mat.uri,
}
};
} else if (mat.type === "video") {
return {
...base,
material_type: "video",
video_info: {
type: "video",
id: util.uuid(),
source_from: "upload",
name: mat.name || "",
vid: mat.vid,
fps: mat.fps || 0,
width: mat.width || 0,
height: mat.height || 0,
duration: mat.duration || 0,
}
};
} else {
// audio
return {
...base,
material_type: "audio",
audio_info: {
type: "audio",
id: util.uuid(),
source_from: "upload",
vid: mat.vid,
duration: mat.duration || 0,
name: mat.name || "",
}
};
}
});
// 解析 prompt 中的素材占位符(@1, @2 等)并构建 meta_list
const metaList = buildMetaListFromPrompt(prompt, uploadedMaterials);
const componentId = util.uuid();
const submitId = util.uuid();
const draftVersion = MODEL_DRAFT_VERSIONS[_model] || "3.3.9";
// 计算视频宽高比
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
const aspectRatio = `${width / divisor}:${height / divisor}`;
const metricsExtra = JSON.stringify({
isDefaultSeed: 1,
originSubmitId: submitId,
isRegenerate: false,
enterFrom: "click",
position: "page_bottom_box",
functionMode: "omni_reference",
sceneOptions: JSON.stringify([{
type: "video",
scene: "BasicVideoGenerateButton",
modelReqKey: model,
videoDuration: actualDuration,
reportParams: {
enterSource: "generate",
vipSource: "generate",
extraVipFunctionKey: model,
useVipFunctionDetailsReporterHoc: true
},
materialTypes: [...new Set(uploadedMaterials.map(m => MATERIAL_TYPE_CODE[m.type]))]
}])
});
// 构建 Seedance 2.0 专用请求(通过浏览器代理,绕过 shark a_bogus 检测)
const token = await acquireToken(refreshToken);
const generateQueryParams = new URLSearchParams({
aid: String(CORE_ASSISTANT_ID),
device_platform: "web",
region: provider.region,
webId: String(WEB_ID),
da_version: draftVersion,
web_component_open_flag: "1",
web_version: "7.5.0",
aigc_features: "app_lip_sync",
});
const generateUrl = `${provider.webApiBaseUrl}${provider.videoGeneratePath}?${generateQueryParams.toString()}`;
const generateBody = {
extend: {
root_model: model,
m_video_commerce_info: {
benefit_type: finalBenefitType,
resource_id: "generate_video",
resource_id_type: "str",
resource_sub_type: "aigc"
},
m_video_commerce_info_list: [{
benefit_type: finalBenefitType,
resource_id: "generate_video",
resource_id_type: "str",
resource_sub_type: "aigc"
}]
},
submit_id: submitId,
metrics_extra: metricsExtra,
draft_content: JSON.stringify({
type: "draft",
id: util.uuid(),
min_version: draftVersion,
min_features: ["AIGC_Video_UnifiedEdit"],
is_from_tsn: true,
version: draftVersion,
main_component_id: componentId,
component_list: [{
type: "video_base_component",
id: componentId,
min_version: "1.0.0",
aigc_mode: "workbench",
metadata: {
type: "",
id: util.uuid(),
created_platform: 3,
created_platform_version: "",
created_time_in_ms: String(Date.now()),
created_did: ""
},
generate_type: "gen_video",
abilities: {
type: "",
id: util.uuid(),
gen_video: {
type: "",
id: util.uuid(),
text_to_video_params: {
type: "",
id: util.uuid(),
video_gen_inputs: [{
type: "",
id: util.uuid(),
min_version: draftVersion,
prompt: "", // Seedance 2.0 prompt 在 meta_list 中
video_mode: 2,
fps: 24,
duration_ms: actualDuration * 1000,
idip_meta_list: [],
unified_edit_input: {
type: "",
id: util.uuid(),
material_list: materialList,
meta_list: metaList
}
}],
video_aspect_ratio: aspectRatio,
seed: Math.floor(Math.random() * 1000000000),
model_req_key: model,
priority: 0
},
video_task_extra: metricsExtra
}
},
process_type: 1
}]
}),
http_common_info: {
aid: CORE_ASSISTANT_ID,
},
};
logger.info(`Seedance: 通过浏览器代理发送 generate 请求...`);
const generateResult = await browserService.fetch(
token,
generateUrl,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(generateBody),
}
);
// 检查浏览器代理返回的结果
const { ret, errmsg, data: generateData } = generateResult;
if (ret !== undefined && Number(ret) !== 0) {
if (Number(ret) === 5000) {
throw new APIException(EX.API_IMAGE_GENERATION_INSUFFICIENT_POINTS, `[无法生成视频]: 即梦积分可能不足,${errmsg}`);
}
throw new APIException(EX.API_REQUEST_FAILED, `[请求jimeng失败]: ${errmsg}`);
}
const aigc_data = generateData?.aigc_data || generateResult.aigc_data;
const historyId = aigc_data.history_record_id;
if (!historyId)
throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录ID不存在");
// 轮询获取结果(与普通视频相同的逻辑)
let status = 20, failCode, item_list = [];
let retryCount = 0;
const maxRetries = 120;
await new Promise((resolve) => setTimeout(resolve, 5000));
logger.info(`Seedance: 开始轮询视频生成结果,历史ID: ${historyId}`);
while (status === 20 && retryCount < maxRetries) {
try {
const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, {
data: { history_ids: [historyId] },
});
const responseStr = JSON.stringify(result);
logger.info(`Seedance: 轮询响应摘要: ${responseStr.substring(0, 300)}...`);
// get_history_by_ids 返回的数据可能以 historyId 为键(如 result["8918159809292"]),
// 也可能在 result.history_list 数组中
let historyData = result.history_list?.[0] || result[historyId];
if (!historyData) {
retryCount++;
const waitTime = Math.min(2000 * (retryCount + 1), 30000);
await new Promise((resolve) => setTimeout(resolve, waitTime));
continue;
}
status = historyData.status;
failCode = historyData.fail_code;
item_list = historyData.item_list || [];
logger.info(`Seedance: 状态=${status}, 失败码=${failCode || '无'}`);
if (status === 30) {
const error = failCode === 2038
? new APIException(EX.API_CONTENT_FILTERED, "内容被过滤")
: new APIException(EX.API_IMAGE_GENERATION_FAILED, `生成失败,错误码: ${failCode}`);
error.historyId = historyId;
throw error;
}
if (status === 20) {
const waitTime = 2000 * Math.min(retryCount + 1, 5);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
retryCount++;
} catch (error) {
if (error instanceof APIException) throw error;
logger.error(`Seedance: 轮询出错: ${error.message}`);
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000 * (retryCount + 1)));
}
}
if (retryCount >= maxRetries && status === 20) {
const error = new APIException(EX.API_IMAGE_GENERATION_FAILED, "视频生成超时");
error.historyId = historyId;
throw error;
}
// 尝试通过 get_local_item_list 获取高质量视频下载URL
const seedanceItemId = item_list?.[0]?.item_id
|| item_list?.[0]?.id
|| item_list?.[0]?.local_item_id
|| item_list?.[0]?.common_attr?.id;
if (seedanceItemId) {
try {
const hqVideoUrl = await fetchHighQualityVideoUrl(String(seedanceItemId), refreshToken);
if (hqVideoUrl) {
logger.info(`Seedance: 视频生成成功(高质量),URL: ${hqVideoUrl}`);
return hqVideoUrl;
}
} catch (error) {
logger.warn(`Seedance: 获取高质量视频URL失败,将使用预览URL作为回退: ${error.message}`);
}
} else {
logger.warn(`Seedance: 未能从item_list中提取item_id,将使用预览URL。item_list[0]键: ${item_list?.[0] ? Object.keys(item_list[0]).join(', ') : '无'}`);
}
// 回退:提取预览视频URL
let videoUrl = item_list?.[0]?.video?.transcoded_video?.origin?.video_url
|| item_list?.[0]?.video?.play_url
|| item_list?.[0]?.video?.download_url
|| item_list?.[0]?.video?.url;
if (!videoUrl) {
const error = new APIException(EX.API_IMAGE_GENERATION_FAILED, "未能获取视频URL");
error.historyId = historyId;
throw error;
}
logger.info(`Seedance: 视频生成成功,URL: ${videoUrl}`);
return videoUrl;
}
/**
* 解析 prompt 中的素材占位符并构建 meta_list
* 支持格式: "使用 @1 图片,@2 图片做动画" -> [text, material(0), text, material(1), text]
* meta_type 根据素材实际类型动态匹配(image/video/audio)
*/
function buildMetaListFromPrompt(prompt: string, materials: Array<{ type: SeedanceMaterialType }>): Array<{meta_type: string, text?: string, material_ref?: {material_idx: number}}> {
const metaList: Array<{meta_type: string, text?: string, material_ref?: {material_idx: number}}> = [];
const materialCount = materials.length;
// 匹配 @1, @2, @图1, @图2, @image1 等格式
const placeholderRegex = /@(?:图|image)?(\d+)/gi;
let lastIndex = 0;
let match;
while ((match = placeholderRegex.exec(prompt)) !== null) {
// 添加占位符前的文本
if (match.index > lastIndex) {
const textBefore = prompt.substring(lastIndex, match.index);
if (textBefore.trim()) {
metaList.push({ meta_type: "text", text: textBefore });
}
}
// 添加素材引用(使用对应素材的类型作为 meta_type)
const materialIndex = parseInt(match[1]) - 1; // @1 对应 index 0
if (materialIndex >= 0 && materialIndex < materialCount) {
metaList.push({
meta_type: materials[materialIndex].type,
text: "",
material_ref: { material_idx: materialIndex }
});
}
lastIndex = match.index + match[0].length;
}
// 添加剩余的文本
if (lastIndex < prompt.length) {
const remainingText = prompt.substring(lastIndex);
if (remainingText.trim()) {
metaList.push({ meta_type: "text", text: remainingText });
}
}
// 如果没有找到任何占位符,默认引用所有素材并附加整个prompt作为文本
if (metaList.length === 0) {
// 先添加所有素材引用
for (let i = 0; i < materialCount; i++) {
if (i === 0) {
metaList.push({ meta_type: "text", text: "使用" });
}
metaList.push({
meta_type: materials[i].type,
text: "",
material_ref: { material_idx: i }
});
if (i < materialCount - 1) {
metaList.push({ meta_type: "text", text: "和" });
}
}
// 添加描述文本
if (prompt && prompt.trim()) {
metaList.push({ meta_type: "text", text: `素材,${prompt}` });
} else {
metaList.push({ meta_type: "text", text: "素材生成视频" });
}
}
return metaList;
}
/**
* 独立的视频结果轮询函数
* 用于继续轮询已有的 historyId,适用于任务恢复场景
*
* @param historyId 即梦平台的 history_record_id
* @param refreshToken 刷新令牌
* @param maxRetries 最大重试次数(默认120次)
* @returns 视频URL
*/
async function pollVideoResult(
historyId: string,
refreshToken: string,
maxRetries: number = 120
): Promise<string> {
let status = 20, failCode, item_list = [];
let retryCount = 0;
logger.info(`轮询视频结果: historyId=${historyId}, maxRetries=${maxRetries}`);
while (status === 20 && retryCount < maxRetries) {
try {
const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, {
data: { history_ids: [historyId] },
});
const responseStr = JSON.stringify(result);
logger.info(`轮询响应摘要: ${responseStr.substring(0, 300)}...`);
let historyData = result.history_list?.[0] || result[historyId];
if (!historyData) {
retryCount++;
const waitTime = Math.min(2000 * (retryCount + 1), 30000);
logger.info(`历史记录未找到,等待 ${waitTime}ms 后重试 (${retryCount}/${maxRetries})`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
continue;
}
status = historyData.status;
failCode = historyData.fail_code;
item_list = historyData.item_list || [];
logger.info(`轮询状态: status=${status}, failCode=${failCode || '无'}, items=${item_list.length}`);
if (status === 30) {
const error = failCode === 2038
? new APIException(EX.API_CONTENT_FILTERED, "内容被过滤")
: new APIException(EX.API_IMAGE_GENERATION_FAILED, `生成失败,错误码: ${failCode}`);
error.historyId = historyId;
throw error;
}
if (status === 20) {
const waitTime = 2000 * Math.min(retryCount + 1, 5);
logger.info(`视频生成中,等待 ${waitTime}ms 后继续查询 (${retryCount + 1}/${maxRetries})`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
retryCount++;
} catch (error) {
if (error instanceof APIException) throw error;
logger.error(`轮询出错: ${error.message}`);
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000 * (retryCount + 1)));
}
}
if (retryCount >= maxRetries && status === 20) {
const error = new APIException(EX.API_IMAGE_GENERATION_FAILED, "视频生成超时");
error.historyId = historyId;
throw error;
}
// 尝试获取高质量视频URL
const itemId = item_list?.[0]?.item_id
|| item_list?.[0]?.id
|| item_list?.[0]?.local_item_id
|| item_list?.[0]?.common_attr?.id;
if (itemId) {
try {
const hqVideoUrl = await fetchHighQualityVideoUrl(String(itemId), refreshToken);
if (hqVideoUrl) {
logger.info(`视频生成成功(高质量),URL: ${hqVideoUrl}`);
return hqVideoUrl;
}
} catch (error) {
logger.warn(`获取高质量视频URL失败: ${error.message}`);
}
}
// 回退:提取预览视频URL
let videoUrl = item_list?.[0]?.video?.transcoded_video?.origin?.video_url
|| item_list?.[0]?.video?.play_url
|| item_list?.[0]?.video?.download_url
|| item_list?.[0]?.video?.url;
if (!videoUrl) {
const error = new APIException(EX.API_IMAGE_GENERATION_FAILED, "未能获取视频URL");
error.historyId = historyId;
throw error;
}
logger.info(`视频生成成功,URL: ${videoUrl}`);
return videoUrl;
}
/**
* 即时单次查询视频状态(不轮询)
* 用于 on-demand 查询:当用户查询一个 processing 任务时,去即梦平台查一次
* 如果视频已生成好,直接返回视频URL;否则返回 null
*
* @param historyId 即梦平台的 history_record_id
* @param refreshToken 刷新令牌
* @returns 视频URL(已完成)或 null(仍在处理中或失败)
*/
async function checkVideoStatusByHistoryId(
historyId: string,
refreshToken: string
): Promise<string | null> {
try {
const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, {
data: { history_ids: [historyId] },
});
let historyData = result.history_list?.[0] || result[historyId];
if (!historyData) {
logger.info(`即时查询: 未找到历史记录 historyId=${historyId}`);
return null;
}
const status = historyData.status;
const failCode = historyData.fail_code;
const item_list = historyData.item_list || [];
logger.info(`即时查询: historyId=${historyId}, status=${status}, failCode=${failCode || '无'}, items=${item_list.length}`);
// status=20 表示还在处理中
if (status === 20) {
return null;
}
// status=30 表示生成失败
if (status === 30) {
logger.warn(`即时查询: 视频生成失败, historyId=${historyId}, failCode=${failCode}`);
return null;
}
// status=10 或其他非20值,尝试提取视频URL
// 尝试获取高质量视频URL
const itemId = item_list?.[0]?.item_id
|| item_list?.[0]?.id
|| item_list?.[0]?.local_item_id
|| item_list?.[0]?.common_attr?.id;
if (itemId) {
try {
const hqVideoUrl = await fetchHighQualityVideoUrl(String(itemId), refreshToken);
if (hqVideoUrl) {
logger.info(`即时查询: 获取高质量视频URL成功, historyId=${historyId}`);
return hqVideoUrl;
}
} catch (error) {
logger.warn(`即时查询: 获取高质量视频URL失败: ${error.message}`);
}
}
// 回退:提取预览视频URL
const videoUrl = item_list?.[0]?.video?.transcoded_video?.origin?.video_url
|| item_list?.[0]?.video?.play_url
|| item_list?.[0]?.video?.download_url
|| item_list?.[0]?.video?.url;
if (videoUrl) {
logger.info(`即时查询: 获取预览视频URL成功, historyId=${historyId}`);
return videoUrl;
}
// item_list 非空但无法提取URL,可能还在处理中
if (item_list.length === 0) {
logger.info(`即时查询: item_list 为空,可能仍在处理, historyId=${historyId}`);
return null;
}
logger.warn(`即时查询: item_list 非空但无法提取视频URL, historyId=${historyId}`);
return null;
} catch (error) {
logger.error(`即时查询出错: historyId=${historyId}, ${error.message}`);
return null;
}
}
// ========== 异步视频生成任务管理 ==========
// 异步任务状态类型
type AsyncTaskStatus = "processing" | "succeeded" | "failed";
// 异步任务持久化接口(仅可序列化字段)
interface AsyncTaskData {
taskId: string;
status: AsyncTaskStatus;
model: string;
prompt: string;
refreshToken: string;
createdAt: number;
updatedAt: number;
historyId?: string; // 即梦平台的 history_record_id,用于重启后继续轮询
result?: {
url?: string;
b64_json?: string;
revised_prompt?: string;
};
error?: string;
}
// 运行时任务接口(含内存中的 Promise 控制器)
interface AsyncTask extends AsyncTaskData {
_resolve?: (value: void) => void;
_promise?: Promise<void>;
}
// 任务存储目录
const ASYNC_TASK_DIR = path.join(process.cwd(), "tmp", "async-tasks");
// 内存任务映射(从文件加载后使用)
const asyncTaskStore = new Map<string, AsyncTask>();
// 当前活跃异步任务数
let activeAsyncCount = 0;
// 最大并发数
const MAX_ASYNC_CONCURRENCY = 10;
// 任务过期时间(24小时,单位毫秒)
const TASK_EXPIRY_MS = 24 * 60 * 60 * 1000;
/**
* 获取任务文件路径
*/
function taskFilePath(taskId: string): string {
return path.join(ASYNC_TASK_DIR, `${taskId}.json`);
}
/**
* 将任务数据持久化到文件
*/
function saveTaskToFile(task: AsyncTask): void {
try {
const data: AsyncTaskData = {
taskId: task.taskId,
status: task.status,
model: task.model,
prompt: task.prompt,
refreshToken: task.refreshToken,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
historyId: task.historyId,
result: task.result,
error: task.error,
};
fs.writeFileSync(taskFilePath(task.taskId), JSON.stringify(data, null, 2), "utf-8");
} catch (err) {
logger.error(`保存任务文件失败: ${task.taskId}, ${err.message}`);
}
}
/**
* 从文件加载单个任务
*/
function loadTaskFromFile(filePath: string): AsyncTaskData | null {
try {
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as AsyncTaskData;
} catch (err) {
logger.error(`加载任务文件失败: ${filePath}, ${err.message}`);
return null;
}
}
/**
* 删除任务文件
*/
function deleteTaskFile(taskId: string): void {
try {
const fp = taskFilePath(taskId);
if (fs.existsSync(fp)) {
fs.unlinkSync(fp);
}
} catch (err) {
logger.error(`删除任务文件失败: ${taskId}, ${err.message}`);
}
}
/**
* 启动时从文件恢复所有未完成任务
* 恢复 processing 状态的任务并重新执行轮询
*/
function restoreTasksFromFiles(): void {
try {
if (!fs.existsSync(ASYNC_TASK_DIR)) {
fs.mkdirSync(ASYNC_TASK_DIR, { recursive: true });
return;
}
const files = fs.readdirSync(ASYNC_TASK_DIR).filter(f => f.endsWith(".json"));
if (files.length === 0) return;
logger.info(`发现 ${files.length} 个异步任务文件,开始恢复...`);
for (const file of files) {
const data = loadTaskFromFile(path.join(ASYNC_TASK_DIR, file));
if (!data) continue;
// 跳过已过期的任务
if (Date.now() - data.updatedAt > TASK_EXPIRY_MS) {
deleteTaskFile(data.taskId);
logger.info(`恢复时清理过期任务: ${data.taskId}`);
continue;
}
// 已成功/失败的任务直接加载到内存(不占用并发槽位)
if (data.status !== "processing") {
const task = data as AsyncTask;
asyncTaskStore.set(data.taskId, task);
logger.info(`恢复已完成任务: ${data.taskId}, 状态: ${data.status}`);
continue;
}
// processing 状态的任务:恢复并重启轮询
if (activeAsyncCount >= MAX_ASYNC_CONCURRENCY) {
logger.warn(`恢复任务 ${data.taskId} 跳过:并发已满 ${activeAsyncCount}/${MAX_ASYNC_CONCURRENCY}`);
// 仍加载到内存但不重启轮询,等有槽位时手动查询会触发
const task = data as AsyncTask;
asyncTaskStore.set(data.taskId, task);
continue;
}
const task: AsyncTask = {
...data,
_promise: undefined,
_resolve: undefined,
};
// 创建 Promise 并将 resolve 绑定到 task._resolve
task._promise = new Promise<void>((resolve) => {
task._resolve = resolve;
});
asyncTaskStore.set(data.taskId, task);
activeAsyncCount++;
logger.info(`恢复并重启 processing 任务: ${data.taskId}, 当前并发: ${activeAsyncCount}/${MAX_ASYNC_CONCURRENCY}`);
// 后台重新执行轮询
restartPollingForTask(task);
}
logger.info(`任务恢复完成,当前活跃并发: ${activeAsyncCount}/${MAX_ASYNC_CONCURRENCY}`);
} catch (err) {
logger.error(`恢复任务文件出错: ${err.message}`);
}
}
/**
* 为恢复的 processing 任务重启轮询
* 使用保存的 historyId 继续轮询,而不是重新提交生成请求
* 超时后保持 processing 状态,等用户查询时做 on-demand 查询
*/
function restartPollingForTask(task: AsyncTask): void {
(async () => {
try {
if (!task.historyId) {
// 没有 historyId,无法恢复轮询,标记为失败
task.status = "failed";
task.error = "任务缺少 historyId,无法恢复轮询";
task.updatedAt = Date.now();
saveTaskToFile(task);
logger.error(`恢复任务失败: ${task.taskId}, 缺少 historyId`);
return;
}
logger.info(`恢复任务轮询: ${task.taskId}, historyId=${task.historyId}`);
// 使用保存的 historyId 继续轮询
const videoUrl = await pollVideoResult(task.historyId, task.refreshToken);
task.status = "succeeded";
task.result = { url: videoUrl, revised_prompt: task.prompt };
task.updatedAt = Date.now();
saveTaskToFile(task);
logger.info(`恢复任务轮询成功: ${task.taskId}`);
} catch (error: any) {
// 超时错误:保持 processing 状态,不标记为 failed
// 用户查询时会通过 on-demand 查询检查即梦平台的最新状态
const errorMsg = error?.message || "";
if (errorMsg.includes("超时")) {
task.updatedAt = Date.now();
saveTaskToFile(task); // 保存 historyId,保持 processing
logger.warn(`恢复任务轮询超时,保持 processing 状态: ${task.taskId}, historyId=${task.historyId},等待用户查询时 on-demand 检查`);
} else {
task.status = "failed";
task.error = error instanceof APIException
? `[${error.code}] ${error.message}`
: errorMsg || "未知错误";
task.updatedAt = Date.now();
saveTaskToFile(task);
logger.error(`恢复任务轮询失败: ${task.taskId}, ${task.error}`);
}
} finally {
activeAsyncCount--;
if (task._resolve) task._resolve();
}
})();
}
// 定期清理过期任务文件(每30分钟)
setInterval(() => {
const now = Date.now();
for (const [taskId, task] of asyncTaskStore) {
if (now - task.updatedAt > TASK_EXPIRY_MS) {
asyncTaskStore.delete(taskId);
deleteTaskFile(taskId);
logger.info(`异步任务已过期清理: ${taskId}`);
}
}
}, 30 * 60 * 1000);
// 启动时恢复任务
restoreTasksFromFiles();
/**
* 普通视频生成(内部版,返回 historyId)
* 将生成请求和轮询拆分,在获取到 historyId 后立即保存,以便重启恢复
*/
async function _generateVideoWithHistoryId(
_model: string,
prompt: string,
options: {
ratio?: string;
resolution?: string;
duration?: number;
filePaths?: string[];
files?: any[];
},
refreshToken: string,
onHistoryId?: (historyId: string) => void
): Promise<{ url: string; historyId: string }> {
const model = getModel(_model);
const { ratio = "1:1", resolution = "720p", duration = 5, filePaths = [], files = [] } = options;
const { width, height } = resolveVideoResolution(resolution, ratio);
logger.info(`异步任务-普通视频: 模型=${_model} 映射=${model} ${width}x${height} (${ratio}@${resolution}) 时长=${duration}秒`);
// 检查积分
const { totalCredit } = await getCredit(refreshToken);
if (totalCredit <= 0) await receiveCredit(refreshToken);
// 处理首帧和尾帧图片
let first_frame_image = undefined;
let end_frame_image = undefined;
if (files && files.length > 0) {
let uploadIDs: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file || !file.filepath) continue;
try {
const buffer = fs.readFileSync(file.filepath);
const imageUri = await uploadImageBufferForVideo(buffer, refreshToken);
if (imageUri) uploadIDs.push(imageUri);
} catch (error) {
if (i === 0) throw new APIException(EX.API_REQUEST_FAILED, `首帧文件上传失败: ${error.message}`);
}
}
if (uploadIDs.length === 0) throw new APIException(EX.API_REQUEST_FAILED, '所有文件上传失败');
if (uploadIDs[0]) {
first_frame_image = { format: "", height, id: util.uuid(), image_uri: uploadIDs[0], name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[0], width };
}
if (uploadIDs[1]) {
end_frame_image = { format: "", height, id: util.uuid(), image_uri: uploadIDs[1], name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[1], width };
}
} else if (filePaths && filePaths.length > 0) {
let uploadIDs: string[] = [];
for (let i = 0; i < filePaths.length; i++) {
if (!filePaths[i]) continue;
try {
const imageUri = await uploadImageForVideo(filePaths[i], refreshToken);
if (imageUri) uploadIDs.push(imageUri);
} catch (error) {
if (i === 0) throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
}
}
if (uploadIDs.length === 0) throw new APIException(EX.API_REQUEST_FAILED, '所有图片上传失败');
if (uploadIDs[0]) {
first_frame_image = { format: "", height, id: util.uuid(), image_uri: uploadIDs[0], name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[0], width };
}
if (uploadIDs[1]) {
end_frame_image = { format: "", height, id: util.uuid(), image_uri: uploadIDs[1], name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[1], width };
}
}
const componentId = util.uuid();
const metricsExtra = JSON.stringify({
"enterFrom": "click", "isDefaultSeed": 1, "promptSource": "custom",
"isRegenerate": false, "originSubmitId": util.uuid(),
});
const draftVersion = MODEL_DRAFT_VERSIONS[_model] || DEFAULT_DRAFT_VERSION;
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
const aspectRatio = `${width / divisor}:${height / divisor}`;
// 提交生成请求
const { aigc_data } = await request("post", provider.videoGeneratePath, refreshToken, {
params: {
aigc_features: "app_lip_sync", web_version: "6.6.0", da_version: draftVersion,
},
data: {
"extend": {
"root_model": end_frame_image ? MODEL_MAP['jimeng-video-3.0'] : model,
"m_video_commerce_info": { benefit_type: "basic_video_operation_vgfm_v_three", resource_id: "generate_video", resource_id_type: "str", resource_sub_type: "aigc" },
"m_video_commerce_info_list": [{ benefit_type: "basic_video_operation_vgfm_v_three", resource_id: "generate_video", resource_id_type: "str", resource_sub_type: "aigc" }]
},
"submit_id": util.uuid(),
"metrics_extra": metricsExtra,
"draft_content": JSON.stringify({
"type": "draft", "id": util.uuid(), "min_version": "3.0.5", "is_from_tsn": true,
"version": draftVersion, "main_component_id": componentId,
"component_list": [{
"type": "video_base_component", "id": componentId, "min_version": "1.0.0",
"metadata": { "type": "", "id": util.uuid(), "created_platform": 3, "created_platform_version": "", "created_time_in_ms": Date.now(), "created_did": "" },
"generate_type": "gen_video", "aigc_mode": "workbench",
"abilities": {
"type": "", "id": util.uuid(),
"gen_video": {
"id": util.uuid(), "type": "",
"text_to_video_params": {
"type": "", "id": util.uuid(), "model_req_key": model, "priority": 0,
"seed": Math.floor(Math.random() * 100000000) + 2500000000,
"video_aspect_ratio": aspectRatio,
"video_gen_inputs": [{
duration_ms: duration * 1000, first_frame_image, end_frame_image,
fps: 24, id: util.uuid(), min_version: "3.0.5", prompt, resolution, type: "", video_mode: 2
}]
},
"video_task_extra": metricsExtra,
}
}
}],
}),
http_common_info: { aid: DEFAULT_ASSISTANT_ID },
}
});
const historyId = aigc_data.history_record_id;
if (!historyId) throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录ID不存在");
logger.info(`异步任务-普通视频: 生成请求已提交, historyId=${historyId}`);
// 立即通知外部 historyId,确保即使后续轮询超时也能保存
if (onHistoryId) onHistoryId(historyId);
// 轮询获取结果(使用独立的轮询函数)
const videoUrl = await pollVideoResult(historyId, refreshToken);
return { url: videoUrl, historyId };
}
/**
* Seedance 2.0 视频生成(内部版,返回 historyId)
* 将生成请求和轮询拆分,在获取到 historyId 后立即保存,以便重启恢复
*/
async function _generateSeedanceVideoWithHistoryId(
_model: string,
prompt: string,
options: {
ratio?: string;
resolution?: string;
duration?: number;
filePaths?: string[];
files?: any[];
},
refreshToken: string,
onHistoryId?: (historyId: string) => void
): Promise<{ url: string; historyId: string }> {
const model = getModel(_model);
const benefitType = SEEDANCE_BENEFIT_TYPE_MAP[_model] || "dreamina_video_seedance_20_pro";
const { ratio = "4:3", resolution = "720p", duration = 4, filePaths = [], files = [] } = options;
const actualDuration = duration || 4;
const { width, height } = resolveVideoResolution(resolution, ratio);
logger.info(`异步任务-Seedance: 模型=${_model} 映射=${model} ${width}x${height} (${ratio}@${resolution}) 时长=${actualDuration}秒`);
// 检查积分
const { totalCredit } = await getCredit(refreshToken);
if (totalCredit <= 0) await receiveCredit(refreshToken);
// 上传素材(复用 generateSeedanceVideo 中的逻辑)
let uploadedMaterials: UploadedMaterial[] = [];
if (files && files.length > 0) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file || !file.filepath) continue;
const materialType = detectMaterialType(file);
try {
const buffer = fs.readFileSync(file.filepath);
if (materialType === "image") {
const imageUri = await uploadImageBufferForVideo(buffer, refreshToken);
if (imageUri) uploadedMaterials.push({ type: "image", uri: imageUri, width, height });
} else {
const vodResult = await uploadMediaForVideo(buffer, materialType, refreshToken, file.originalFilename);
uploadedMaterials.push({ type: materialType, vid: vodResult.vid, width: vodResult.width, height: vodResult.height, duration: vodResult.duration, fps: vodResult.fps, name: file.originalFilename || "" });
}
} catch (error) {
if (i === 0) throw new APIException(EX.API_REQUEST_FAILED, `首个文件上传失败: ${error.message}`);
}
}
} else if (filePaths && filePaths.length > 0) {
for (let i = 0; i < filePaths.length; i++) {
if (!filePaths[i]) continue;
const materialType = detectMaterialTypeFromUrl(filePaths[i]);
try {
if (materialType === "image") {
const imageUri = await uploadImageForVideo(filePaths[i], refreshToken);
if (imageUri) uploadedMaterials.push({ type: "image", uri: imageUri, width, height });
} else {
const response = await fetch(filePaths[i]);
if (!response.ok) throw new Error(`下载文件失败: ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
const vodResult = await uploadMediaForVideo(buffer, materialType, refreshToken);
uploadedMaterials.push({ type: materialType, vid: vodResult.vid, width: vodResult.width, height: vodResult.height, duration: vodResult.duration, fps: vodResult.fps });
}
} catch (error) {
if (i === 0) throw new APIException(EX.API_REQUEST_FAILED, `首个文件上传失败: ${error.message}`);
}
}
}
if (uploadedMaterials.length === 0) {
throw new APIException(EX.API_REQUEST_FAILED, 'Seedance 2.0 需要至少一个文件');
}
// 构建请求参数(与 generateSeedanceVideo 相同)
const hasVideoMaterial = uploadedMaterials.some(m => m.type === "video");
const finalBenefitType = hasVideoMaterial ? `${benefitType}_with_video` : benefitType;
const materialList = uploadedMaterials.map((mat) => {
const base = { type: "", id: util.uuid() };
if (mat.type === "image") {
return { ...base, material_type: "image", image_info: { type: "image", id: util.uuid(), source_from: "upload", platform_type: 1, name: "", image_uri: mat.uri, aigc_image: { type: "", id: util.uuid() }, width: mat.width, height: mat.height, format: "", uri: mat.uri } };
} else if (mat.type === "video") {
return { ...base, material_type: "video", video_info: { type: "video", id: util.uuid(), source_from: "upload", name: mat.name || "", vid: mat.vid, fps: mat.fps || 0, width: mat.width || 0, height: mat.height || 0, duration: mat.duration || 0 } };
} else {
return { ...base, material_type: "audio", audio_info: { type: "audio", id: util.uuid(), source_from: "upload", vid: mat.vid, duration: mat.duration || 0, name: mat.name || "" } };
}
});
const metaList = buildMetaListFromPrompt(prompt, uploadedMaterials);
const componentId = util.uuid();
const submitId = util.uuid();
const draftVersion = MODEL_DRAFT_VERSIONS[_model] || "3.3.9";
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
const aspectRatio = `${width / divisor}:${height / divisor}`;
const metricsExtra = JSON.stringify({
isDefaultSeed: 1, originSubmitId: submitId, isRegenerate: false, enterFrom: "click",
position: "page_bottom_box", functionMode: "omni_reference",
sceneOptions: JSON.stringify([{ type: "video", scene: "BasicVideoGenerateButton", modelReqKey: model, videoDuration: actualDuration, reportParams: { enterSource: "generate", vipSource: "generate", extraVipFunctionKey: model, useVipFunctionDetailsReporterHoc: true }, materialTypes: [...new Set(uploadedMaterials.map(m => MATERIAL_TYPE_CODE[m.type]))] }])
});
const token = await acquireToken(refreshToken);
const generateQueryParams = new URLSearchParams({
aid: String(CORE_ASSISTANT_ID), device_platform: "web", region: provider.region,
webId: String(WEB_ID), da_version: draftVersion, web_component_open_flag: "1",
web_version: "7.5.0", aigc_features: "app_lip_sync",
});
const generateUrl = `${provider.webApiBaseUrl}${provider.videoGeneratePath}?${generateQueryParams.toString()}`;
const generateBody = {
extend: {
root_model: model,
m_video_commerce_info: { benefit_type: finalBenefitType, resource_id: "generate_video", resource_id_type: "str", resource_sub_type: "aigc" },
m_video_commerce_info_list: [{ benefit_type: finalBenefitType, resource_id: "generate_video", resource_id_type: "str", resource_sub_type: "aigc" }]
},
submit_id: submitId, metrics_extra: metricsExtra,
draft_content: JSON.stringify({
type: "draft", id: util.uuid(), min_version: draftVersion, min_features: ["AIGC_Video_UnifiedEdit"],
is_from_tsn: true, version: draftVersion, main_component_id: componentId,
component_list: [{
type: "video_base_component", id: componentId, min_version: "1.0.0", aigc_mode: "workbench",
metadata: { type: "", id: util.uuid(), created_platform: 3, created_platform_version: "", created_time_in_ms: String(Date.now()), created_did: "" },
generate_type: "gen_video",
abilities: {
type: "", id: util.uuid(),
gen_video: {
type: "", id: util.uuid(),
text_to_video_params: {
type: "", id: util.uuid(),
video_gen_inputs: [{
type: "", id: util.uuid(), min_version: draftVersion, prompt: "", video_mode: 2, fps: 24,
duration_ms: actualDuration * 1000, idip_meta_list: [],
unified_edit_input: { type: "", id: util.uuid(), material_list: materialList, meta_list: metaList }
}],
video_aspect_ratio: aspectRatio, seed: Math.floor(Math.random() * 1000000000), model_req_key: model, priority: 0
},
video_task_extra: metricsExtra
}
},
process_type: 1
}]
}),
http_common_info: { aid: CORE_ASSISTANT_ID },
};
logger.info(`异步任务-Seedance: 通过浏览器代理发送 generate 请求...`);
const generateResult = await browserService.fetch(token, generateUrl, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(generateBody),
});
const { ret, errmsg, data: generateData } = generateResult;
if (ret !== undefined && Number(ret) !== 0) {
if (Number(ret) === 5000) {
throw new APIException(EX.API_IMAGE_GENERATION_INSUFFICIENT_POINTS, `[无法生成视频]: 即梦积分可能不足,${errmsg}`);
}
throw new APIException(EX.API_REQUEST_FAILED, `[请求jimeng失败]: ${errmsg}`);
}
const aigc_data = generateData?.aigc_data || generateResult.aigc_data;
const historyId = aigc_data.history_record_id;
if (!historyId) throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录ID不存在");
logger.info(`异步任务-Seedance: 生成请求已提交, historyId=${historyId}`);
// 立即通知外部 historyId,确保即使后续轮询超时也能保存
if (onHistoryId) onHistoryId(historyId);
// 轮询获取结果(使用独立的轮询函数)
const videoUrl = await pollVideoResult(historyId, refreshToken);
return { url: videoUrl, historyId };
}
/**
* 提交异步视频生成任务
* 调用生成接口后立即返回 taskId,后台执行轮询等待视频生成完成
*/
export function submitAsyncVideoTask(
model: string,
prompt: string,
options: {
ratio?: string;
resolution?: string;
duration?: number;
filePaths?: string[];
files?: any[];
},
refreshToken: string
): string {
if (activeAsyncCount >= MAX_ASYNC_CONCURRENCY) {
throw new APIException(
EX.API_REQUEST_FAILED,
`当前异步任务并发数已达上限 (${MAX_ASYNC_CONCURRENCY}),请稍后重试`
);
}
// 确保任务目录存在
if (!fs.existsSync(ASYNC_TASK_DIR)) {
fs.mkdirSync(ASYNC_TASK_DIR, { recursive: true });
}
const taskId = util.uuid();
const task: AsyncTask = {
taskId,
status: "processing",
model,
prompt,
refreshToken,
createdAt: Date.now(),
updatedAt: Date.now(),
};
// 创建用于查询接口阻塞等待的 Promise
task._promise = new Promise<void>((resolve) => {
task._resolve = resolve;
});
asyncTaskStore.set(taskId, task);
saveTaskToFile(task);
activeAsyncCount++;
logger.info(
`异步任务已创建: ${taskId}, 模型: ${model}, 当前并发: ${activeAsyncCount}/${MAX_ASYNC_CONCURRENCY}`
);
// 后台执行视频生成(含轮询)
(async () => {
try {
let videoUrl: string;
if (isSeedanceModel(model)) {
const seedanceDuration =
options.duration === 5 ? 4 : options.duration;
const seedanceRatio =
options.ratio === "1:1" ? "4:3" : options.ratio;
// 使用 Seedance 生成:先生成获取 historyId,通过回调立即保存,然后再轮询
const { url } = await _generateSeedanceVideoWithHistoryId(
model, prompt, {
ratio: seedanceRatio,
resolution: options.resolution,
duration: seedanceDuration,
filePaths: options.filePaths,
files: options.files,
}, refreshToken,
// onHistoryId 回调:在获取到 historyId 后立即保存到 task 文件
(historyId) => {
task.historyId = historyId;
saveTaskToFile(task);
logger.info(`异步任务-Seedance: historyId 已保存, ${taskId} -> ${historyId}`);
}
);
videoUrl = url;
} else {
// 普通视频生成:先生成获取 historyId,通过回调立即保存,然后再轮询
const { url } = await _generateVideoWithHistoryId(
model, prompt, {
ratio: options.ratio,
resolution: options.resolution,
duration: options.duration,
filePaths: options.filePaths,
files: options.files,
}, refreshToken,
// onHistoryId 回调:在获取到 historyId 后立即保存到 task 文件
(historyId) => {
task.historyId = historyId;
saveTaskToFile(task);
logger.info(`异步任务-普通视频: historyId 已保存, ${taskId} -> ${historyId}`);
}
);
videoUrl = url;
}
task.status = "succeeded";
task.result = {
url: videoUrl,
revised_prompt: prompt,
};
task.updatedAt = Date.now();
saveTaskToFile(task);
logger.info(`异步任务成功: ${taskId}, 视频URL: ${videoUrl}`);
} catch (error: any) {
const errorMsg = error?.message || "";
// 超时错误:保持 processing 状态,保存 historyId
// 用户查询时会通过 on-demand 查询检查即梦平台最新状态
if (errorMsg.includes("超时")) {
task.updatedAt = Date.now();
saveTaskToFile(task); // 保存 historyId,保持 processing
logger.warn(`异步任务后台轮询超时,保持 processing 状态: ${taskId}, historyId=${task.historyId},等待用户查询时 on-demand 检查`);
} else {
task.status = "failed";
task.error = error instanceof APIException
? `[${error.code}] ${error.message}`
: errorMsg || "未知错误";
task.updatedAt = Date.now();
saveTaskToFile(task);
logger.error(`异步任务失败: ${taskId}, 错误: ${task.error}`);
}
} finally {
activeAsyncCount--;
// 通知查询接口任务已完成(succeeded/failed),或后台轮询已停止(超时保持processing)
if (task._resolve) {
task._resolve();
}
}
})();
return taskId;
}
/**
* 查询异步视频生成任务结果
* - 如果后台轮询仍在进行中(有 _promise),阻塞等待完成
* - 如果后台轮询已停止但任务仍为 processing(超时场景),做 on-demand 即时查询
*/
export async function queryAsyncVideoTask(
taskId: string
): Promise<AsyncTask> {
// 先从内存查找
let task = asyncTaskStore.get(taskId);
// 内存中没有,尝试从文件加载
if (!task) {
const fp = taskFilePath(taskId);
if (!fs.existsSync(fp)) {
throw new APIException(
EX.API_REQUEST_PARAMS_INVALID,
`任务ID不存在或已过期: ${taskId}`
);
}
const data = loadTaskFromFile(fp);
if (!data) {
throw new APIException(
EX.API_REQUEST_PARAMS_INVALID,
`任务数据损坏: ${taskId}`
);
}
task = data as AsyncTask;
asyncTaskStore.set(taskId, task);
logger.info(`从文件加载任务: ${taskId}, 状态: ${task.status}`);
}
// 已终态的任务直接返回
if (task.status === "succeeded" || task.status === "failed") {
return task;
}
// processing 状态的任务
if (task.status === "processing") {
// 如果后台轮询仍在进行中(有活跃的 Promise),阻塞等待
if (task._promise) {
logger.info(`查询接口等待后台轮询完成: ${taskId}`);
await task._promise;
return task;
}
// 后台轮询已停止(超时或重启后的 processing 任务),做 on-demand 即时查询
if (task.historyId) {
logger.info(`on-demand 即时查询: ${taskId}, historyId=${task.historyId}`);
const videoUrl = await checkVideoStatusByHistoryId(task.historyId, task.refreshToken);
if (videoUrl) {
// 视频已生成好!更新任务状态
task.status = "succeeded";
task.result = { url: videoUrl, revised_prompt: task.prompt };
task.updatedAt = Date.now();
saveTaskToFile(task);
logger.info(`on-demand 查询发现视频已完成: ${taskId}, URL: ${videoUrl}`);
} else {
// 仍在处理中,保持 processing
logger.info(`on-demand 查询: 视频仍在处理中, ${taskId}`);
}
} else {
logger.warn(`processing 任务缺少 historyId,无法 on-demand 查询: ${taskId}`);
}
}
return task;
}