m19921414377's picture
Upload folder using huggingface_hub
c8c6dbf verified
import _ from "lodash";
import crypto from "crypto";
import fs from "fs";
import APIException from "@/lib/exceptions/APIException.ts";
import EX from "@/api/consts/exceptions.ts";
import util from "@/lib/util.ts";
import { getCredit, receiveCredit, request } from "./core.ts";
import logger from "@/lib/logger.ts";
const DEFAULT_ASSISTANT_ID = 513695;
export const DEFAULT_MODEL = "jimeng-video-3.0";
const DRAFT_VERSION = "3.2.8";
const MODEL_MAP = {
"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"
};
// 视频支持的分辨率和比例配置
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 = ''
) {
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 = 'cn-north-1';
const service = 'imagex';
// 规范化查询参数
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': 'zh-CN,zh;q=0.9',
'authorization': authorization,
'origin': 'https://jimeng.jianying.com',
'referer': 'https://jimeng.jianying.com/ai-tool/video/generate',
'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': 'zh-CN,zh;q=0.9',
'Authorization': auth,
'Connection': 'keep-alive',
'Content-CRC32': crc32,
'Content-Disposition': 'attachment; filename="undefined"',
'Content-Type': 'application/octet-stream',
'Origin': 'https://jimeng.jianying.com',
'Referer': 'https://jimeng.jianying.com/ai-tool/video/generate',
'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': 'zh-CN,zh;q=0.9',
'authorization': commitAuthorization,
'content-type': 'application/json',
'origin': 'https://jimeng.jianying.com',
'referer': 'https://jimeng.jianying.com/ai-tool/video/generate',
'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': 'zh-CN,zh;q=0.9',
'authorization': authorization,
'origin': 'https://jimeng.jianying.com',
'referer': 'https://jimeng.jianying.com/ai-tool/video/generate',
'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': 'https://jimeng.jianying.com',
'Referer': 'https://jimeng.jianying.com/ai-tool/video/generate',
'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': 'https://jimeng.jianying.com',
'referer': 'https://jimeng.jianying.com/ai-tool/video/generate',
'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;
}
}
/**
* 生成视频
*
* @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 metricsExtra = JSON.stringify({
"enterFrom": "click",
"isDefaultSeed": 1,
"promptSource": "custom",
"isRegenerate": false,
"originSubmitId": util.uuid(),
});
// 计算视频宽高比
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",
"/mweb/v1/aigc_draft/generate",
refreshToken,
{
params: {
aigc_features: "app_lip_sync",
web_version: "6.6.0",
da_version: DRAFT_VERSION,
},
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": DRAFT_VERSION,
"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 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 = 60; // 增加重试次数,支持约20分钟的总重试时间
// 首次查询前等待更长时间,让服务器有时间处理请求
await new Promise((resolve) => setTimeout(resolve, 5000));
logger.info(`开始轮询视频生成结果,历史ID: ${historyId},最大重试次数: ${maxRetries}`);
logger.info(`即梦官网API地址: https://jimeng.jianying.com/mweb/v1/get_history_by_ids`);
logger.info(`视频生成请求已发送,请同时在即梦官网查看: https://jimeng.jianying.com/ai-tool/video/generate`);
while (status === 20 && retryCount < maxRetries) {
try {
// 构建请求URL和参数
const requestUrl = "/mweb/v1/get_history_by_ids";
const requestData = {
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)}`);
// 尝试直接从响应中提取视频URL
const responseStr = JSON.stringify(result);
const videoUrlMatch = responseStr.match(/https:\/\/v[0-9]+-artist\.vlabvod\.com\/[^"\s]+/);
if (videoUrlMatch && videoUrlMatch[0]) {
logger.info(`从备用API响应中直接提取到视频URL: ${videoUrlMatch[0]}`);
// 提前返回找到的URL
return videoUrlMatch[0];
}
} 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)}...`);
// 尝试直接从响应中提取视频URL
const videoUrlMatch = responseStr.match(/https:\/\/v[0-9]+-artist\.vlabvod\.com\/[^"\s]+/);
if (videoUrlMatch && videoUrlMatch[0]) {
logger.info(`从标准API响应中直接提取到视频URL: ${videoUrlMatch[0]}`);
// 提前返回找到的URL
return videoUrlMatch[0];
}
}
// 检查结果是否有效
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 {
// 两种API都没有返回有效数据
logger.warn(`历史记录不存在,重试中 (${retryCount + 1}/${maxRetries})... 历史ID: ${historyId}`);
logger.info(`请同时在即梦官网检查视频是否已生成: https://jimeng.jianying.com/ai-tool/video/generate`);
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;
}
// 提取视频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;
}