| 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 { |
| |
| if (model.includes("veo3.1")) { |
| return "generate_video_veo3.1"; |
| } |
| |
| if (model.includes("veo3")) { |
| return "generate_video_veo3"; |
| } |
| |
| 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<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; |
| } |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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) }); |
| } |
|
|
| |
| if (meta_list.length === 0) { |
| meta_list.push({ meta_type: "text", text: prompt }); |
| } |
|
|
| return meta_list; |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"); |
| |
| const supportsResolution = (model.includes("vgfm_3.0") || model.includes("vgfm_3.0_fast")) && !model.includes("_pro"); |
|
|
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| 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"; |
|
|
| |
| 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) { |
| |
| logger.info(`进入 omni_reference 全能模式`); |
|
|
| |
| 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; |
|
|
| |
| 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}`); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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); |
| } |
| } |
| |
| 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')) { |
| |
| 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}`); |
| } |
| } |
|
|
| |
| 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')) { |
| |
| 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`); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| const meta_list = parseOmniPrompt(prompt, materialRegistry); |
|
|
| logger.info(`[omni] material_list: ${material_list.length} 项, meta_list: ${meta_list.length} 项, materialTypes: [${materialTypes}]`); |
|
|
| |
| 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]), |
| }); |
|
|
| |
| 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 { |
| |
| 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)); |
|
|
| |
| const maxPollCount = 900; |
| let pollAttempts = 0; |
|
|
| const poller = new SmartPoller({ |
| maxPollCount, |
| pollInterval: 20000, |
| expectedItemCount: 1, |
| type: 'video', |
| timeoutSeconds: 3600 |
| }); |
|
|
| const { result: pollingResult, data: finalHistoryData } = await poller.poll(async () => { |
| pollAttempts++; |
|
|
| |
| const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, { |
| data: { |
| history_ids: [historyId], |
| }, |
| }); |
|
|
| |
| |
| if (!result[historyId]) { |
| logger.warn(`API未返回历史记录 (轮询第${pollAttempts}次),historyId: ${historyId},继续等待...`); |
| return { |
| status: { |
| status: 20, |
| 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 || []; |
|
|
| |
| 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(', ') : '无'}`); |
| } |
|
|
| |
| let fallbackVideoUrl = item_list?.[0] ? extractVideoUrl(item_list[0]) : null; |
|
|
| |
| 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; |
| } |
|
|