import _ from "lodash"; import fs from "fs-extra"; import axios from "axios"; import APIException from "@/lib/exceptions/APIException.ts"; import EX from "@/api/consts/exceptions.ts"; import util from "@/lib/util.ts"; import { getCredit, receiveCredit, request, parseRegionFromToken, getAssistantId, checkImageContent, RegionInfo } from "./core.ts"; import logger from "@/lib/logger.ts"; import { SmartPoller, PollingStatus } from "@/lib/smart-poller.ts"; import { DEFAULT_ASSISTANT_ID_CN, DEFAULT_ASSISTANT_ID_US, DEFAULT_ASSISTANT_ID_HK, DEFAULT_ASSISTANT_ID_JP, DEFAULT_ASSISTANT_ID_SG, DEFAULT_VIDEO_MODEL, DRAFT_VERSION, DRAFT_VERSION_OMNI, OMNI_BENEFIT_TYPE, OMNI_BENEFIT_TYPE_FAST, VIDEO_MODEL_MAP, VIDEO_MODEL_MAP_US, VIDEO_MODEL_MAP_ASIA } from "@/api/consts/common.ts"; import { uploadImageBuffer, ImageUploadResult } from "@/lib/image-uploader.ts"; import { uploadVideoBuffer, VideoUploadResult } from "@/lib/video-uploader.ts"; import { extractVideoUrl, fetchHighQualityVideoUrl } from "@/lib/image-utils.ts"; import { uploadVideoFromUrl } from "@/lib/video-uploader.ts"; export const DEFAULT_MODEL = DEFAULT_VIDEO_MODEL; export function getModel(model: string, regionInfo: RegionInfo) { // 根据站点选择不同的模型映射 let modelMap: Record; if (regionInfo.isUS) { modelMap = VIDEO_MODEL_MAP_US; } else if (regionInfo.isHK || regionInfo.isJP || regionInfo.isSG) { modelMap = VIDEO_MODEL_MAP_ASIA; } else { modelMap = VIDEO_MODEL_MAP; } return modelMap[model] || modelMap[DEFAULT_MODEL] || VIDEO_MODEL_MAP[DEFAULT_MODEL]; } function getVideoBenefitType(model: string): string { // veo3.1 模型 (需先于 veo3 检查) if (model.includes("veo3.1")) { return "generate_video_veo3.1"; } // veo3 模型 if (model.includes("veo3")) { return "generate_video_veo3"; } // sora2 模型 if (model.includes("sora2")) { return "generate_video_sora2"; } if (model.includes("40_pro")) { return "dreamina_video_seedance_20_pro"; } if (model.includes("40")) { return "dreamina_video_seedance_20_fast"; } if (model.includes("3.5_pro")) { return "dreamina_video_seedance_15_pro"; } if (model.includes("3.5")) { return "dreamina_video_seedance_15"; } return "basic_video_operation_vgfm_v_three"; } // 处理本地上传的文件 async function uploadImageFromFile(file: any, refreshToken: string, regionInfo: RegionInfo): Promise { try { logger.info(`开始从本地文件上传视频图片: ${file.originalFilename} (路径: ${file.filepath})`); const imageBuffer = await fs.readFile(file.filepath); return await uploadImageBuffer(imageBuffer, refreshToken, regionInfo); } catch (error: any) { logger.error(`从本地文件上传视频图片失败: ${error.message}`); throw error; } } // 处理来自URL的图片 async function uploadImageFromUrl(imageUrl: string, refreshToken: string, regionInfo: RegionInfo): Promise { try { logger.info(`开始从URL下载并上传视频图片: ${imageUrl}`); const imageResponse = await axios.get(imageUrl, { responseType: 'arraybuffer', proxy: false, }); if (imageResponse.status < 200 || imageResponse.status >= 300) { throw new Error(`下载图片失败: ${imageResponse.status}`); } const imageBuffer = imageResponse.data; return await uploadImageBuffer(imageBuffer, refreshToken, regionInfo); } catch (error: any) { logger.error(`从URL上传视频图片失败: ${error.message}`); throw error; } } /** * 解析 omni_reference 模式的 prompt,将 @引用 拆解为 meta_list * 输入: "@image_file_1作为首帧,@image_file_2作为尾帧,运动动作模仿@video_file" * 输出: 交替的 text + material_ref 段 */ function parseOmniPrompt(prompt: string, materialRegistry: Map): any[] { // 收集所有可识别的引用名(字段名 + 原始文件名),转义正则特殊字符 const refNames = [...materialRegistry.keys()] .sort((a, b) => b.length - a.length) // 长名优先匹配 .map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); if (refNames.length === 0) { return [{ meta_type: "text", text: prompt }]; } const pattern = new RegExp(`@(${refNames.join('|')})`, 'g'); const meta_list: any[] = []; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(prompt)) !== null) { // 文本段 if (match.index > lastIndex) { const textSegment = prompt.slice(lastIndex, match.index); if (textSegment) { meta_list.push({ meta_type: "text", text: textSegment }); } } // 引用段 const refName = match[1]; const entry = materialRegistry.get(refName); if (entry) { meta_list.push({ meta_type: entry.type, text: "", material_ref: { material_idx: entry.idx }, }); } lastIndex = pattern.lastIndex; } // 尾部文本 if (lastIndex < prompt.length) { meta_list.push({ meta_type: "text", text: prompt.slice(lastIndex) }); } // 如果没有任何 @ 引用,把整个 prompt 作为文本段 if (meta_list.length === 0) { meta_list.push({ meta_type: "text", text: prompt }); } return meta_list; } /** * 生成视频 * * @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 = {}, httpRequest, functionMode = "first_last_frames", }: { ratio?: string; resolution?: string; duration?: number; filePaths?: string[]; files?: any; httpRequest?: any; functionMode?: string; }, refreshToken: string ) { // 检测区域 const regionInfo = parseRegionFromToken(refreshToken); const { isInternational } = regionInfo; logger.info(`视频生成区域检测: isInternational=${isInternational}`); const model = getModel(_model, regionInfo); const isVeo3 = model.includes("veo3"); const isSora2 = model.includes("sora2"); const is35Pro = model.includes("3.5_pro"); const is40Pro = model.includes("40_pro"); const is40 = model.includes("40") && !model.includes("40_pro"); // 只有 video-3.0 和 video-3.0-fast 支持 resolution 参数(3.0-pro 和 3.5-pro 不支持) const supportsResolution = (model.includes("vgfm_3.0") || model.includes("vgfm_3.0_fast")) && !model.includes("_pro"); // 将秒转换为毫秒 // veo3 模型固定 8 秒 // sora2 模型支持 4秒、8秒、12秒,默认4秒 // 3.5-pro 模型支持 5秒、10秒、12秒,默认5秒 // 4.0-pro (seedance 2.0) 和 4.0 (seedance 2.0-fast) 模型支持 4~15秒,默认5秒 // 其他模型支持 5秒、10秒,默认5秒 let durationMs: number; let actualDuration: number; if (isVeo3) { durationMs = 8000; actualDuration = 8; } else if (isSora2) { if (duration === 12) { durationMs = 12000; actualDuration = 12; } else if (duration === 8) { durationMs = 8000; actualDuration = 8; } else { durationMs = 4000; actualDuration = 4; } } else if (is40Pro || is40) { // seedance 2.0 和 2.0-fast: 支持 4~15 秒,clamp 到有效范围,默认 5 秒 actualDuration = Math.max(4, Math.min(15, duration)); durationMs = actualDuration * 1000; } else if (is35Pro) { if (duration === 12) { durationMs = 12000; actualDuration = 12; } else if (duration === 10) { durationMs = 10000; actualDuration = 10; } else { durationMs = 5000; actualDuration = 5; } } else { durationMs = duration === 10 ? 10000 : 5000; actualDuration = duration === 10 ? 10 : 5; } logger.info(`使用模型: ${_model} 映射模型: ${model} 比例: ${ratio} 分辨率: ${supportsResolution ? resolution : '不支持'} 时长: ${actualDuration}s`); // 检查积分 const { totalCredit } = await getCredit(refreshToken); if (totalCredit <= 0) { logger.info("积分为 0,尝试收取今日积分..."); try { await receiveCredit(refreshToken); } catch (receiveError) { logger.warn(`收取积分失败: ${receiveError.message}. 这可能是因为: 1) 今日已收取过积分, 2) 账户受到风控限制, 3) 需要在官网手动收取首次积分`); throw new APIException(EX.API_VIDEO_GENERATION_FAILED, `积分不足且无法自动收取。请访问即梦官网手动收取首次积分,或检查账户状态。`); } } const isOmniMode = functionMode === "omni_reference"; // omni_reference 仅支持 seedance 2.0 (40_pro) 和 2.0-fast (40) 模型 if (isOmniMode && !is40Pro && !is40) { throw new APIException(EX.API_REQUEST_FAILED, `omni_reference 模式仅支持 jimeng-video-seedance-2.0 和 jimeng-video-seedance-2.0-fast 模型`); } let requestData: any; if (isOmniMode) { // ========== omni_reference 分支 ========== logger.info(`进入 omni_reference 全能模式`); // 素材注册表: fieldName → { idx, type, uploadResult } interface MaterialEntry { idx: number; type: "image" | "video"; fieldName: string; originalFilename: string; imageUri?: string; imageWidth?: number; imageHeight?: number; imageFormat?: string; videoResult?: VideoUploadResult; } const materialRegistry: Map = new Map(); let materialIdx = 0; // canonical key 集合,防止 originalFilename 覆盖 const canonicalKeys = new Set(); canonicalKeys.add('image_file'); canonicalKeys.add('video_file'); for (let i = 1; i <= 9; i++) canonicalKeys.add(`image_file_${i}`); for (let i = 1; i <= 3; i++) canonicalKeys.add(`video_file_${i}`); // 安全注册别名:originalFilename 不与 canonical key 冲突时才注册 function registerAlias(filename: string, entry: MaterialEntry) { if (!canonicalKeys.has(filename) && !materialRegistry.has(filename)) { materialRegistry.set(filename, entry); } } // 收集所有需要处理的图片和视频字段 const imageFields: string[] = []; const videoFields: string[] = []; // 检测上传的文件 if (files) { for (const fieldName of Object.keys(files)) { if (fieldName === 'image_file' || fieldName.startsWith('image_file_')) imageFields.push(fieldName); else if (fieldName === 'video_file' || fieldName.startsWith('video_file_')) videoFields.push(fieldName); } } // 检测URL字段 for (let i = 1; i <= 9; i++) { const fieldName = `image_file_${i}`; if (typeof httpRequest?.body?.[fieldName] === 'string' && httpRequest.body[fieldName].startsWith('http')) { if (!imageFields.includes(fieldName)) imageFields.push(fieldName); } } for (let i = 1; i <= 3; i++) { const fieldName = `video_file_${i}`; if (typeof httpRequest?.body?.[fieldName] === 'string' && httpRequest.body[fieldName].startsWith('http')) { if (!videoFields.includes(fieldName)) videoFields.push(fieldName); } } // 检测不带数字后缀的裸名 URL 字段 if (typeof httpRequest?.body?.image_file === 'string' && httpRequest.body.image_file.startsWith('http')) { if (!imageFields.includes('image_file')) imageFields.push('image_file'); } if (typeof httpRequest?.body?.video_file === 'string' && httpRequest.body.video_file.startsWith('http')) { if (!videoFields.includes('video_file')) videoFields.push('video_file'); } // 检查是否有素材 const hasFilePaths = filePaths && filePaths.length > 0; if (imageFields.length === 0 && videoFields.length === 0 && !hasFilePaths) { throw new APIException(EX.API_REQUEST_FAILED, `omni_reference 模式需要至少上传一个素材文件 (image_file_*, video_file_*) 或提供素材URL`); } let totalVideoDuration = 0; // 累计视频时长 // 串行上传图片素材 for (const fieldName of imageFields) { const imageFile = files?.[fieldName]; const imageUrlField = httpRequest?.body?.[fieldName]; try { logger.info(`[omni] 上传 ${fieldName}`); let imgResult: ImageUploadResult; if (imageFile) { // 本地文件上传 const buf = await fs.readFile(imageFile.filepath); imgResult = await uploadImageBuffer(buf, refreshToken, regionInfo); await checkImageContent(imgResult.uri, refreshToken, regionInfo); const entry: MaterialEntry = { idx: materialIdx++, type: "image", fieldName, originalFilename: imageFile.originalFilename, imageUri: imgResult.uri, imageWidth: imgResult.width, imageHeight: imgResult.height, imageFormat: imgResult.format, }; materialRegistry.set(fieldName, entry); registerAlias(imageFile.originalFilename, entry); logger.info(`[omni] ${fieldName} 上传成功: ${imgResult.uri} (${imgResult.width}x${imgResult.height})`); } else if (imageUrlField && typeof imageUrlField === 'string' && imageUrlField.startsWith('http')) { // URL上传 imgResult = await uploadImageFromUrl(imageUrlField, refreshToken, regionInfo); await checkImageContent(imgResult.uri, refreshToken, regionInfo); const entry: MaterialEntry = { idx: materialIdx++, type: "image", fieldName, originalFilename: imageUrlField, imageUri: imgResult.uri, imageWidth: imgResult.width, imageHeight: imgResult.height, imageFormat: imgResult.format, }; materialRegistry.set(fieldName, entry); logger.info(`[omni] ${fieldName} URL上传成功: ${imgResult.uri} (${imgResult.width}x${imgResult.height})`); } } catch (error: any) { throw new APIException(EX.API_REQUEST_FAILED, `${fieldName} 处理失败: ${error.message}`); } } // 通过 filePaths 数组补充未被占用的图片槽位 if (filePaths && filePaths.length > 0) { let slotIndex = 1; for (const url of filePaths) { // 找到第一个未被占用的槽位 while (slotIndex <= 9 && materialRegistry.has(`image_file_${slotIndex}`)) { slotIndex++; } if (slotIndex > 9) break; // 已达到最大数量 const fieldName = `image_file_${slotIndex}`; try { logger.info(`[omni] 从URL上传 ${fieldName}: ${url}`); const imgResult = await uploadImageFromUrl(url, refreshToken, regionInfo); await checkImageContent(imgResult.uri, refreshToken, regionInfo); const entry: MaterialEntry = { idx: materialIdx++, type: "image", fieldName, originalFilename: url, imageUri: imgResult.uri, imageWidth: imgResult.width, imageHeight: imgResult.height, imageFormat: imgResult.format, }; materialRegistry.set(fieldName, entry); logger.info(`[omni] ${fieldName} URL上传成功: ${imgResult.uri} (${imgResult.width}x${imgResult.height})`); } catch (error: any) { throw new APIException(EX.API_REQUEST_FAILED, `${fieldName} URL图片处理失败: ${error.message}`); } slotIndex++; } } // 串行上传视频素材 for (const fieldName of videoFields) { const videoFile = files?.[fieldName]; const videoUrlField = httpRequest?.body?.[fieldName]; try { logger.info(`[omni] 上传 ${fieldName}`); let vResult: VideoUploadResult; if (videoFile) { // 本地文件上传 const buf = await fs.readFile(videoFile.filepath); vResult = await uploadVideoBuffer(buf, refreshToken, regionInfo); totalVideoDuration += vResult.videoMeta.duration; const entry: MaterialEntry = { idx: materialIdx++, type: "video", fieldName, originalFilename: videoFile.originalFilename, videoResult: vResult }; materialRegistry.set(fieldName, entry); registerAlias(videoFile.originalFilename, entry); logger.info(`[omni] ${fieldName} 上传成功: vid=${vResult.vid}, ${vResult.videoMeta.width}x${vResult.videoMeta.height}, ${vResult.videoMeta.duration}s`); } else if (videoUrlField && typeof videoUrlField === 'string' && videoUrlField.startsWith('http')) { // URL上传 vResult = await uploadVideoFromUrl(videoUrlField, refreshToken, regionInfo); totalVideoDuration += vResult.videoMeta.duration; const entry: MaterialEntry = { idx: materialIdx++, type: "video", fieldName, originalFilename: videoUrlField, videoResult: vResult }; materialRegistry.set(fieldName, entry); logger.info(`[omni] ${fieldName} URL上传成功: vid=${vResult.vid}, ${vResult.videoMeta.width}x${vResult.videoMeta.height}, ${vResult.videoMeta.duration}s`); } } catch (error: any) { throw new APIException(EX.API_REQUEST_FAILED, `${fieldName} 处理失败: ${error.message}`); } } // 验证视频总时长 const MAX_TOTAL_VIDEO_DURATION = 15; if (!Number.isFinite(totalVideoDuration)) { throw new APIException(EX.API_REQUEST_FAILED, `视频时长数据异常,请检查视频文件`); } if (totalVideoDuration > MAX_TOTAL_VIDEO_DURATION) { throw new APIException(EX.API_REQUEST_FAILED, `视频总时长 ${totalVideoDuration.toFixed(2)}s 超过限制 (最大 ${MAX_TOTAL_VIDEO_DURATION}s)`); } logger.info(`[omni] 视频总时长: ${totalVideoDuration.toFixed(2)}s`); // 构建 material_list(按注册顺序) const orderedEntries = [...new Map([...materialRegistry].filter(([k, v]) => k === v.fieldName)).values()] .sort((a, b) => a.idx - b.idx); const material_list: any[] = []; const materialTypes: number[] = []; for (const entry of orderedEntries) { if (entry.type === "image") { material_list.push({ type: "", id: util.uuid(), material_type: "image", image_info: { type: "image", id: util.uuid(), source_from: "upload", platform_type: 1, name: "", image_uri: entry.imageUri, width: entry.imageWidth || 0, height: entry.imageHeight || 0, format: entry.imageFormat || "", uri: entry.imageUri, }, }); materialTypes.push(1); } else { const vm = entry.videoResult!; material_list.push({ type: "", id: util.uuid(), material_type: "video", video_info: { type: "video", id: util.uuid(), source_from: "upload", name: "", vid: vm.vid, fps: 0, width: vm.videoMeta.width, height: vm.videoMeta.height, duration: Math.round(vm.videoMeta.duration * 1000), }, }); materialTypes.push(2); } } // 解析 prompt → meta_list const meta_list = parseOmniPrompt(prompt, materialRegistry); logger.info(`[omni] material_list: ${material_list.length} 项, meta_list: ${meta_list.length} 项, materialTypes: [${materialTypes}]`); // 构建 omni payload const componentId = util.uuid(); const submitId = util.uuid(); const sceneOption = { type: "video", scene: "BasicVideoGenerateButton", modelReqKey: model, videoDuration: actualDuration, materialTypes, reportParams: { enterSource: "generate", vipSource: "generate", extraVipFunctionKey: model, useVipFunctionDetailsReporterHoc: true, }, }; const metricsExtra = JSON.stringify({ position: "page_bottom_box", isDefaultSeed: 1, originSubmitId: submitId, isRegenerate: false, enterFrom: "click", functionMode: "omni_reference", sceneOptions: JSON.stringify([sceneOption]), }); // 根据模型选择 benefit_type const omniBenefitType = is40 ? OMNI_BENEFIT_TYPE_FAST : OMNI_BENEFIT_TYPE; requestData = { params: { aigc_features: "app_lip_sync", web_version: "7.5.0", da_version: DRAFT_VERSION_OMNI, }, data: { extend: { root_model: model, m_video_commerce_info: { benefit_type: omniBenefitType, resource_id: "generate_video", resource_id_type: "str", resource_sub_type: "aigc", }, m_video_commerce_info_list: [{ benefit_type: omniBenefitType, 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: DRAFT_VERSION_OMNI, min_features: ["AIGC_Video_UnifiedEdit"], is_from_tsn: true, version: DRAFT_VERSION_OMNI, 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: Date.now().toString(), created_did: "", }, generate_type: "gen_video", abilities: { type: "", id: util.uuid(), gen_video: { id: util.uuid(), type: "", text_to_video_params: { type: "", id: util.uuid(), video_gen_inputs: [{ type: "", id: util.uuid(), min_version: DRAFT_VERSION_OMNI, prompt: "", video_mode: 2, fps: 24, duration_ms: durationMs, unified_edit_input: { type: "", id: util.uuid(), material_list, meta_list, }, idip_meta_list: [], }], video_aspect_ratio: ratio, seed: Math.floor(Math.random() * 4294967296), model_req_key: model, priority: 0, }, video_task_extra: metricsExtra, }, }, process_type: 1, }], }), http_common_info: { aid: getAssistantId(regionInfo), }, }, }; } else { // ========== first_last_frames 分支(原有逻辑) ========== let first_frame_image = undefined; let end_frame_image = undefined; let uploadIDs: string[] = []; // 优先处理本地上传的文件 const uploadedFiles = _.values(files); if (uploadedFiles && uploadedFiles.length > 0) { logger.info(`检测到 ${uploadedFiles.length} 个本地上传文件,优先处理`); for (let i = 0; i < uploadedFiles.length; i++) { const file = uploadedFiles[i]; if (!file) continue; try { logger.info(`开始上传第 ${i + 1} 张本地图片: ${file.originalFilename}`); const imgResult = await uploadImageFromFile(file, refreshToken, regionInfo); if (imgResult) { await checkImageContent(imgResult.uri, refreshToken, regionInfo); uploadIDs.push(imgResult.uri); logger.info(`第 ${i + 1} 张本地图片上传成功: ${imgResult.uri}`); } else { logger.error(`第 ${i + 1} 张本地图片上传失败: 未获取到 image_uri`); } } catch (error: any) { logger.error(`第 ${i + 1} 张本地图片上传失败: ${error.message}`); if (i === 0) { throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`); } } } } else if (filePaths && filePaths.length > 0) { logger.info(`未检测到本地上传文件,处理 ${filePaths.length} 个图片URL`); for (let i = 0; i < filePaths.length; i++) { const filePath = filePaths[i]; if (!filePath) { logger.warn(`第 ${i + 1} 个图片URL为空,跳过`); continue; } try { logger.info(`开始上传第 ${i + 1} 个URL图片: ${filePath}`); const imgResult = await uploadImageFromUrl(filePath, refreshToken, regionInfo); if (imgResult) { await checkImageContent(imgResult.uri, refreshToken, regionInfo); uploadIDs.push(imgResult.uri); logger.info(`第 ${i + 1} 个URL图片上传成功: ${imgResult.uri}`); } else { logger.error(`第 ${i + 1} 个URL图片上传失败: 未获取到 image_uri`); } } catch (error: any) { logger.error(`第 ${i + 1} 个URL图片上传失败: ${error.message}`); if (i === 0) { throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`); } } } } else { logger.info(`未提供图片文件或URL,将进行纯文本视频生成`); } if (uploadIDs.length > 0) { logger.info(`图片上传完成,共成功 ${uploadIDs.length} 张`); if (uploadIDs[0]) { first_frame_image = { format: "", height: 0, id: util.uuid(), image_uri: uploadIDs[0], name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[0], width: 0, }; logger.info(`设置首帧图片: ${uploadIDs[0]}`); } if (uploadIDs[1]) { end_frame_image = { format: "", height: 0, id: util.uuid(), image_uri: uploadIDs[1], name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[1], width: 0, }; logger.info(`设置尾帧图片: ${uploadIDs[1]}`); } } const componentId = util.uuid(); const originSubmitId = util.uuid(); const flFunctionMode = "first_last_frames"; const sceneOption = { type: "video", scene: "BasicVideoGenerateButton", ...(supportsResolution ? { resolution } : {}), modelReqKey: model, videoDuration: actualDuration, reportParams: { enterSource: "generate", vipSource: "generate", extraVipFunctionKey: supportsResolution ? `${model}-${resolution}` : model, useVipFunctionDetailsReporterHoc: true, }, }; const metricsExtra = JSON.stringify({ promptSource: "custom", isDefaultSeed: 1, originSubmitId, isRegenerate: false, enterFrom: "click", functionMode: flFunctionMode, sceneOptions: JSON.stringify([sceneOption]), }); const hasImageInput = uploadIDs.length > 0; if (hasImageInput && ratio !== "1:1") { logger.warn(`图生视频模式下,ratio参数将被忽略(由输入图片的实际比例决定),但resolution参数仍然有效`); } logger.info(`视频生成模式: ${uploadIDs.length}张图片 (首帧: ${!!first_frame_image}, 尾帧: ${!!end_frame_image}), resolution: ${resolution}`); requestData = { params: { aigc_features: "app_lip_sync", web_version: "7.5.0", da_version: DRAFT_VERSION, }, data: { extend: { root_model: model, m_video_commerce_info: { benefit_type: getVideoBenefitType(model), resource_id: "generate_video", resource_id_type: "str", resource_sub_type: "aigc", }, m_video_commerce_info_list: [{ benefit_type: getVideoBenefitType(model), 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", min_features: [], is_from_tsn: true, version: DRAFT_VERSION, 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: Date.now().toString(), created_did: "", }, generate_type: "gen_video", abilities: { type: "", id: util.uuid(), gen_video: { id: util.uuid(), type: "", text_to_video_params: { type: "", id: util.uuid(), video_gen_inputs: [{ type: "", id: util.uuid(), min_version: "3.0.5", prompt, video_mode: 2, fps: 24, duration_ms: durationMs, ...(supportsResolution ? { resolution } : {}), first_frame_image, end_frame_image, idip_meta_list: [], }], video_aspect_ratio: ratio, seed: Math.floor(Math.random() * 4294967296), model_req_key: model, priority: 0, }, video_task_extra: metricsExtra, }, }, process_type: 1, }], }), http_common_info: { aid: getAssistantId(regionInfo), }, }, }; } // 发送请求 const videoReferer = regionInfo.isCN ? "https://jimeng.jianying.com/ai-tool/generate?type=video" : "https://dreamina.capcut.com/ai-tool/generate?type=video"; const { aigc_data } = await request( "post", "/mweb/v1/aigc_draft/generate", refreshToken, { ...requestData, headers: { Referer: videoReferer }, } ); const historyId = aigc_data.history_record_id; if (!historyId) throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录ID不存在"); logger.info(`视频生成任务已提交,history_id: ${historyId},等待生成完成...`); // 首次查询前等待,让服务器有时间处理请求 await new Promise((resolve) => setTimeout(resolve, 5000)); // 使用 SmartPoller 进行智能轮询 const maxPollCount = 900; // 增加轮询次数,支持更长的生成时间 let pollAttempts = 0; const poller = new SmartPoller({ maxPollCount, pollInterval: 20000, // 20秒基础间隔 expectedItemCount: 1, type: 'video', timeoutSeconds: 3600 // 60分钟超时 }); const { result: pollingResult, data: finalHistoryData } = await poller.poll(async () => { pollAttempts++; // 使用标准API请求方式 const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, { data: { history_ids: [historyId], }, }); // 检查响应中是否有该 history_id 的数据 // 由于 API 存在最终一致性,早期轮询可能暂时获取不到记录,返回处理中状态继续轮询 if (!result[historyId]) { logger.warn(`API未返回历史记录 (轮询第${pollAttempts}次),historyId: ${historyId},继续等待...`); return { status: { status: 20, // PROCESSING itemCount: 0, historyId } as PollingStatus, data: { status: 20, item_list: [] } }; } const historyData = result[historyId]; const currentStatus = historyData.status; const currentFailCode = historyData.fail_code; const currentItemList = historyData.item_list || []; const finishTime = historyData.task?.finish_time || 0; // 记录详细信息 if (currentItemList.length > 0) { const tempVideoUrl = currentItemList[0]?.video?.transcoded_video?.origin?.video_url || currentItemList[0]?.video?.play_url || currentItemList[0]?.video?.download_url || currentItemList[0]?.video?.url; if (tempVideoUrl) { logger.info(`检测到视频URL: ${tempVideoUrl}`); } } return { status: { status: currentStatus, failCode: currentFailCode, itemCount: currentItemList.length, finishTime, historyId } as PollingStatus, data: historyData }; }, historyId); const item_list = finalHistoryData.item_list || []; // 尝试通过 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},总耗时: ${pollingResult.elapsedTime}秒`); 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 fallbackVideoUrl = item_list?.[0] ? extractVideoUrl(item_list[0]) : null; // 如果无法获取视频URL,抛出异常 if (!fallbackVideoUrl) { logger.error(`未能获取视频URL,item_list: ${JSON.stringify(item_list)}`); throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "未能获取视频URL,请稍后查看"); } logger.info(`视频生成成功,URL: ${fallbackVideoUrl},总耗时: ${pollingResult.elapsedTime}秒`); return fallbackVideoUrl; }