Spaces:
Sleeping
Sleeping
| 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<string, string>; | |
| 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_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<ImageUploadResult> { | |
| 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<ImageUploadResult> { | |
| 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<string, any>): 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<string, MaterialEntry> = new Map(); | |
| let materialIdx = 0; | |
| // canonical key 集合,防止 originalFilename 覆盖 | |
| const canonicalKeys = new Set<string>(); | |
| 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, | |
| materialTypes: [] as number[], | |
| reportParams: { | |
| enterSource: "generate", | |
| vipSource: "generate", | |
| extraVipFunctionKey: supportsResolution ? `${model}-${resolution}` : model, | |
| useVipFunctionDetailsReporterHoc: true, | |
| }, | |
| }; | |
| const metricsExtra = JSON.stringify({ | |
| promptSource: "custom", | |
| isDefaultSeed: 1, | |
| originSubmitId, | |
| isRegenerate: false, | |
| enterFrom: "use_bgimage_prompt", | |
| position: "page_bottom_box", | |
| 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]?.common_attr?.transcoded_video?.origin?.video_url || | |
| 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; | |
| } | |