/** * 光湖视频AI系统 · 视频API适配层 * D135 → D144 · 铸渊 ICE-GL-ZY001 * * 基于 火山方舟 Seedance API 对接 * * 【D144】三模型分工: * - doubao-seedance-2-0-260128 2.0 旗舰 · 复杂镜头(打斗/法术/参考视频) * - doubao-seedance-2-0-mini-260615 Mini · 简单镜头主力(特写/静物/基础运镜) * - doubao-seedance-1-5-pro-251215 1.5 Pro · 音画同步口型(generate_audio:true) * * 使用方式: * const { generateVideo, validateAndGenerate } = require('./video-api-adapter'); * * // 简单镜头用 Mini * const result = await validateAndGenerate({ * prompt: '...', duration: 5, * model: 'doubao-seedance-2-0-mini-260615' * }); * * // 口型镜头用 1.5 Pro * const result = await validateAndGenerate({ * prompt: '苏白对着镜头大声说:天道宗开门收徒啦!', * model: 'doubao-seedance-1-5-pro-251215', * generateAudio: true * }); * * 输出路径优先级: * 1. opts.outputPath(显式指定) * 2. 外接硬盘 JZAO /Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频/ * 3. 本地 fallback video-ai-system/outputs/ * * 环境变量(由 LOCAL-SECRETS-PATH.hdlp 的苍耳本机路径加载): * JIMENG_API_KEY=xxx 火山方舟 API Key * JIMENG_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 * JIMENG_MODEL=doubao-seedance-2-0-260128 * VIDEO_OUTPUT_ROOT=/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频 (可选·覆盖默认) */ const fs = require('fs'); const path = require('path'); const https = require('https'); const http = require('http'); const { loadVideoAiEnv } = require('./env-loader'); loadVideoAiEnv(path.resolve(__dirname, '../.env')); const API_KEY = process.env.JIMENG_API_KEY || ''; const BASE_URL = process.env.JIMENG_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3'; const MODEL = process.env.JIMENG_MODEL || 'doubao-seedance-2-0-260128'; const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS, 10) || 5000; const MAX_POLL_ATTEMPTS = parseInt(process.env.MAX_POLL_ATTEMPTS, 10) || 120; // 最多轮询10分钟 // 【D135】输出路径:外接硬盘优先 const JZAO_VIDEO_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频'; const LOCAL_OUTPUT_ROOT = path.resolve(__dirname, '../outputs'); const VIDEO_OUTPUT_ROOT = (() => { const env = process.env.VIDEO_OUTPUT_ROOT; if (env) return env; if (fs.existsSync(JZAO_VIDEO_ROOT)) return JZAO_VIDEO_ROOT; return LOCAL_OUTPUT_ROOT; })(); const VIDEO_REGISTRY_PATH = path.resolve(__dirname, '../outputs/video-registry.json'); console.log(`[VideoAPI] 输出路径: ${VIDEO_OUTPUT_ROOT}`); /** * 【D135】解析输出路径 — 外接硬盘JZAO优先,本地fallback * @param {string} projectKey - 项目标识,如 "zai-fu-fei-xiu-xian/ep01" * @param {string} filename - 文件名 * @returns {string} 完整输出路径 */ function resolveOutputPath(projectKey, filename) { const dir = path.join(VIDEO_OUTPUT_ROOT, projectKey); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return path.join(dir, filename); } /** * 【D135】视频注册 — 镜编号→硬盘路径的双向索引 * 存到仓库里,人不用翻文件夹,系统毫秒级定位 */ function registerVideo({ projectKey, shotId, taskId, filePath, duration, resolution, prompt }) { let registry = { _meta: { updated: new Date().toISOString(), by: '铸渊 ICE-GL-ZY001' }, shots: {} }; try { if (fs.existsSync(VIDEO_REGISTRY_PATH)) { registry = JSON.parse(fs.readFileSync(VIDEO_REGISTRY_PATH, 'utf-8')); } } catch (e) { // 文件损坏,重建 } const key = shotId || taskId; registry.shots[key] = { shotId: key, taskId, projectKey, filePath, duration, resolution, promptPreview: (prompt || '').substring(0, 80), generatedAt: new Date().toISOString(), dNumber: 'D135', }; registry._meta.updated = new Date().toISOString(); registry._meta.totalShots = Object.keys(registry.shots).length; const dir = path.dirname(VIDEO_REGISTRY_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(VIDEO_REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf-8'); console.log(`[VideoAPI·注册] ${key} → ${filePath}`); } /** * 【D135】按镜编号查找视频 — 毫秒级定位 * @param {string} shotId * @returns {{ found: boolean, filePath?: string, info?: object }} */ function findVideo(shotId) { try { if (!fs.existsSync(VIDEO_REGISTRY_PATH)) return { found: false }; const registry = JSON.parse(fs.readFileSync(VIDEO_REGISTRY_PATH, 'utf-8')); const entry = registry.shots[shotId]; if (!entry) return { found: false }; const onDisk = fs.existsSync(entry.filePath); return { found: true, filePath: entry.filePath, onDisk, info: entry }; } catch (e) { return { found: false, error: e.message }; } } /** * HTTP POST 请求封装(Node.js 原生,无依赖) */ async function httpPost(url, body, apiKey) { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const transport = isHttps ? https : http; const payload = JSON.stringify(body); return new Promise((resolve, reject) => { const req = transport.request(url, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), }, timeout: 30000, }, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const json = JSON.parse(data); if (res.statusCode >= 400) { const errMsg = json.error?.message || json.message || `HTTP ${res.statusCode}`; reject(new Error(`API错误(${res.statusCode}): ${errMsg}`)); return; } resolve(json); } catch (e) { reject(new Error(`JSON解析失败: ${data.substring(0, 200)}`)); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); }); req.write(payload); req.end(); }); } /** * HTTP GET 请求封装 */ async function httpGet(url, apiKey) { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const transport = isHttps ? https : http; return new Promise((resolve, reject) => { const req = transport.request(url, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, timeout: 10000, }, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const json = JSON.parse(data); resolve(json); } catch (e) { reject(new Error(`JSON解析失败: ${data.substring(0, 200)}`)); } }); }); req.on('error', reject); req.end(); }); } /** * 下载视频到本地,避免外网URL过期 */ async function downloadVideo(videoUrl, outputPath) { const urlObj = new URL(videoUrl); const isHttps = urlObj.protocol === 'https:'; const transport = isHttps ? https : http; return new Promise((resolve, reject) => { const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const file = fs.createWriteStream(outputPath); transport.get(videoUrl, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { // 重定向 const redirectUrl = res.headers.location.startsWith('http') ? res.headers.location : `${urlObj.protocol}//${urlObj.host}${res.headers.location}`; downloadVideo(redirectUrl, outputPath).then(resolve).catch(reject); return; } res.pipe(file); file.on('finish', () => { file.close(); resolve(outputPath); }); file.on('error', (err) => { fs.unlinkSync(outputPath); reject(err); }); }).on('error', reject); }); } // ==================== API 规范常量 ==================== // 【D144】三模型分工 — 共用端点,区分场景 const SUPPORTED_MODELS = { 'doubao-seedance-2-0-260128': { name: 'Seedance 2.0 旗舰', role: 'complex', maxResolution: '720p', supportsAudio: false, supportsReferenceVideo: true }, 'doubao-seedance-2-0-mini-260615': { name: 'Seedance 2.0 Mini', role: 'simple', maxResolution: '720p', supportsAudio: false, supportsReferenceVideo: false }, 'doubao-seedance-1-5-pro-251215': { name: 'Seedance 1.5 Pro', role: 'lipsync', maxResolution: '1080p', supportsAudio: true, supportsReferenceVideo: false }, }; const API_SPEC = { duration: { key: 'duration', type: 'integer', range: [4, 15], default: 5, special: -1, note: '-1=自动' }, resolution: { key: 'resolution', type: 'string', values: ['480p', '720p', '1080p'], default: '720p' }, model: { key: 'model', type: 'string', values: Object.keys(SUPPORTED_MODELS), default: 'doubao-seedance-2-0-260128' }, promptMaxLen: { chinese: 500, english: 1000 }, }; /** * 【新增 · D135】预校验 · 提交前参数合规性检查 * 零成本——不调用API,只在本地检查参数是否对齐Seedance 2.0规范 * @param {object} opts * @returns {{ valid: boolean, warnings: string[], errors: string[], corrected: object }} */ function preflightCheck({ prompt, duration, resolution, style }) { const warnings = []; const errors = []; const corrected = {}; // 1. duration 校验 const dur = parseInt(duration, 10); if (duration !== undefined && duration !== null) { if (isNaN(dur)) { errors.push(`duration 类型错误: 收到 "${duration}" (${typeof duration}),应为 integer`); } else if (dur !== -1 && (dur < 4 || dur > 15)) { errors.push(`duration 超出范围: ${dur},Seedance 2.0 支持 4~15 秒(或 -1 自动)`); } else { corrected.duration = dur; // 确保是整数 } } else { corrected.duration = API_SPEC.duration.default; } // 2. resolution 校验 if (resolution !== undefined && resolution !== null) { if (!API_SPEC.resolution.values.includes(String(resolution))) { warnings.push(`resolution "${resolution}" 不在 Seedance 2.0 支持列表中(${API_SPEC.resolution.values.join(', ')}),已修正为 ${API_SPEC.resolution.default}`); corrected.resolution = API_SPEC.resolution.default; } else { corrected.resolution = resolution; } } else { corrected.resolution = API_SPEC.resolution.default; } // 3. prompt 长度校验 if (prompt && prompt.trim()) { const chineseChars = (prompt.match(/[\u4e00-\u9fff]/g) || []).length; const englishWords = prompt.split(/\s+/).filter(w => /[a-zA-Z]/.test(w)).length; if (chineseChars > API_SPEC.promptMaxLen.chinese) { warnings.push(`提示词中文字数 ${chineseChars},超过建议上限 ${API_SPEC.promptMaxLen.chinese} 字`); } if (englishWords > API_SPEC.promptMaxLen.english) { warnings.push(`提示词英文词数 ${englishWords},超过建议上限 ${API_SPEC.promptMaxLen.english} 词`); } } else { errors.push('提示词不能为空'); } // 4. style 参数(Seedance 2.0 官方API不直接支持style参数,通过提示词控制) if (style) { warnings.push(`style="${style}" 不是 Seedance 2.0 官方 API 参数,已忽略。风格请通过提示词描述控制。`); // 不传入 corrected,style 将被丢弃 } return { valid: errors.length === 0, warnings, errors, corrected, }; } /** * 提交视频生成任务 * @param {object} opts * @param {string} opts.prompt - 视频描述提示词(中文 ≤500字,英文 ≤1000词) * @param {number} [opts.duration] - 时长 4-15秒,默认 5,-1=自动 * @param {string} [opts.resolution] - 分辨率 '480p' | '720p',默认 720p * @param {string} [opts.style] - [已废弃] Seedance 2.0 标准API不直接支持,请通过提示词控制风格 * @param {string} [opts.referenceImage] - 参考图文件路径(用于锁脸),自动转 base64 * @param {string} [opts.model] - 模型ID覆盖(默认 .env 中的 JIMENG_MODEL) * @param {boolean} [opts.generateAudio] - 【D144】是否生成音画同步音频(仅 1.5 Pro 支持) * @returns {Promise<{taskId: string, preflight: object}>} */ async function submitTask({ prompt, duration, resolution, style, referenceImage, model, generateAudio }) { // 【D135】预校验 const preflight = preflightCheck({ prompt, duration, resolution, style }); if (!preflight.valid) { console.error(`[VideoAPI·预校验] ❌ 参数错误,拒绝提交:`); preflight.errors.forEach(e => console.error(` ✗ ${e}`)); throw new Error(`预校验失败: ${preflight.errors.join('; ')}`); } if (preflight.warnings.length > 0) { console.warn(`[VideoAPI·预校验] ⚠️ ${preflight.warnings.length} 条警告:`); preflight.warnings.forEach(w => console.warn(` ⚠ ${w}`)); } // 使用修正后的参数 const finalDuration = preflight.corrected.duration; const finalResolution = preflight.corrected.resolution; console.log(`[VideoAPI] 提交任务: ${prompt.substring(0, 60)}...`); // 【D144】模型选择 — 支持显式覆盖 .env 默认值 const activeModel = model || MODEL; const modelInfo = SUPPORTED_MODELS[activeModel] || { name: activeModel, supportsAudio: false, maxResolution: '720p' }; // 【D144】generate_audio 检查 — 非 1.5 模型传了这个参数会被 API 忽略 const shouldGenerateAudio = generateAudio === true && modelInfo.supportsAudio; // 【D135关键修复】参数必须在顶层,不能嵌套在 parameters 对象中 // 官方文档: https://www.volcengine.com/docs/82379/1520757 const content = [ { type: 'text', text: prompt } ]; // 【D135】参考图支持 — 锁脸用 if (referenceImage) { if (!fs.existsSync(referenceImage)) { console.warn(`[VideoAPI] ⚠️ 参考图不存在: ${referenceImage}`); } else { const imgBuffer = fs.readFileSync(referenceImage); const ext = path.extname(referenceImage).toLowerCase(); const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }; const mime = mimeMap[ext] || 'image/png'; const b64 = imgBuffer.toString('base64'); content.push({ type: 'image_url', role: 'reference_image', image_url: { url: `data:${mime};base64,${b64}` }, }); console.log(`[VideoAPI] 参考图已挂载: ${path.basename(referenceImage)} (${(imgBuffer.length/1024).toFixed(0)}KB)`); } } const payload = { model: activeModel, content, duration: finalDuration, // ← 顶层 integer,不是 parameters.video_length String resolution: finalResolution, // ← 顶层 string,仅支持 480p/720p }; // 【D144】音画同步 — 仅 1.5 Pro 支持 if (shouldGenerateAudio) { payload.generate_audio = true; } const data = await httpPost(`${BASE_URL}/contents/generations/tasks`, payload, API_KEY); const taskId = data.id || data.task_id || data.data?.task_id || data.data?.id; if (!taskId) { throw new Error(`即梦API未返回任务ID: ${JSON.stringify(data).substring(0, 200)}`); } console.log(`[VideoAPI] 任务已提交: ${taskId} 模型: ${MODEL} 时长: ${finalDuration}s 分辨率: ${finalResolution}`); return { taskId, preflight }; } /** * 查询任务状态 * @param {string} taskId * @returns {Promise<{status: 'generating'|'completed'|'failed', videoUrl?: string, videoMeta?: object, rawResponse?: object, error?: string}>} */ async function queryTask(taskId) { const data = await httpGet(`${BASE_URL}/contents/generations/tasks/${taskId}`, API_KEY); const rawStatus = (data.status || data.data?.status || '').toLowerCase(); if (['succeeded', 'completed', 'success', 'done'].includes(rawStatus)) { const videoUrl = data.content?.video_url || data.output?.video_url || data.output?.url || data.data?.output?.video_url || data.data?.output?.url || data.result?.video_url || data.content?.[0]?.url || data.data?.content?.[0]?.url; if (!videoUrl) { return { status: 'failed', error: '任务完成但未返回视频地址' }; } // 【D135】提取响应中包含的元数据(可能有 duration/width/height 等) const videoMeta = {}; const rawOutput = data.output || data.data?.output || data.content || data.data?.content || {}; if (rawOutput.duration !== undefined) videoMeta.duration = rawOutput.duration; if (rawOutput.video_duration !== undefined) videoMeta.video_duration = rawOutput.video_duration; if (rawOutput.width !== undefined) videoMeta.width = rawOutput.width; if (rawOutput.height !== undefined) videoMeta.height = rawOutput.height; if (rawOutput.resolution !== undefined) videoMeta.resolution = rawOutput.resolution; if (rawOutput.frame_count !== undefined) videoMeta.frame_count = rawOutput.frame_count; if (rawOutput.fps !== undefined) videoMeta.fps = rawOutput.fps; return { status: 'completed', videoUrl, videoMeta, rawResponse: data }; } if (['failed', 'error', 'cancelled'].includes(rawStatus)) { const errMsg = data.error?.message || data.data?.error?.message || data.message || '生成失败'; return { status: 'failed', error: errMsg, rawResponse: data }; } return { status: 'generating' }; } /** * 【D135 新增】通过 ffprobe 从视频URL提取实际时长(秒) * 在下载完整视频之前就能知道实际时长,避免瞎子式验证 * @param {string} videoUrl - 视频URL * @returns {Promise<{duration: number|null, meta: object, error: string|null}>} */ async function probeVideoDuration(videoUrl) { const { execSync } = require('child_process'); try { // ffprobe 只下载文件头解析元数据,不发完整请求 const stdout = execSync( `ffprobe -v quiet -print_format json -show_format -show_streams "${videoUrl}"`, { timeout: 15000, encoding: 'utf8', maxBuffer: 1024 * 1024 } ); const meta = JSON.parse(stdout); // 从 format 层取时长 const formatDuration = parseFloat(meta.format?.duration); // 从流层取第一视频流时长 const videoStream = (meta.streams || []).find(s => s.codec_type === 'video'); const streamDuration = videoStream ? parseFloat(videoStream.duration) : null; const duration = formatDuration || streamDuration || null; if (duration !== null) { console.log(`[VideoAPI·探针] 视频实际时长: ${duration.toFixed(1)}s (${videoStream?.width || '?'}×${videoStream?.height || '?'})`); } return { duration, meta: { width: videoStream?.width || null, height: videoStream?.height || null, codec: videoStream?.codec_name || null, fps: videoStream?.r_frame_rate || null, }, error: null, }; } catch (e) { return { duration: null, meta: {}, error: `ffprobe 不可用或提取失败: ${e.message}`, }; } } /** * 生成视频(提交 + 自动轮询 + 下载 + 注册索引) * @param {object} opts * @param {string} opts.prompt - 视频提示词 * @param {number} [opts.duration] - 时长 4-15秒 * @param {string} [opts.resolution] - 分辨率 480p/720p * @param {string} [opts.style] - [已废弃] * @param {string} [opts.outputPath] - 输出路径(可选,优先于默认JZAO路径) * @param {string} [opts.shotId] - 镜编号,用于注册索引(如 'ep01-shot01') * @param {string} [opts.projectKey] - 项目标识(如 'zai-fu-fei-xiu-xian/ep01') * @param {string} [opts.referenceImage] - 参考图路径 * @param {string} [opts.model] - 【D144】模型ID覆盖 * @param {boolean} [opts.generateAudio] - 【D144】音画同步(仅1.5 Pro) * @returns {Promise<{videoPath: string, taskId: string, duration: number, preflight: object}>} */ async function generateVideo({ prompt, duration, resolution, style, outputPath, shotId, projectKey, referenceImage, model, generateAudio }) { if (!API_KEY) { throw new Error('未配置 JIMENG_API_KEY。请按 LOCAL-SECRETS-PATH.hdlp 在苍耳本机仓库外配置。'); } // 1. 提交任务(含预校验) const { taskId, preflight } = await submitTask({ prompt, duration, resolution, style, referenceImage, model, generateAudio }); const finalDuration = preflight.corrected.duration; const finalResolution = preflight.corrected.resolution; // 2. 轮询等待 let attempts = 0; while (attempts < MAX_POLL_ATTEMPTS) { attempts++; await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); const result = await queryTask(taskId); if (result.status === 'completed') { // 【D135】API响应中如有元数据,先报告 if (Object.keys(result.videoMeta).length > 0) { console.log(`[VideoAPI] API返回的元数据:`, JSON.stringify(result.videoMeta)); } // 3. 【D135】解析输出路径 — 外接硬盘JZAO优先 let finalPath; if (outputPath) { finalPath = outputPath; } else if (shotId && projectKey) { // 有编号 → 走 JZAO 编号文件夹 finalPath = resolveOutputPath(projectKey, `${shotId}.mp4`); } else { // 兜底: JZAO根目录用taskId finalPath = path.join(VIDEO_OUTPUT_ROOT, `${taskId}.mp4`); } console.log(`[VideoAPI] 生成完成!正在下载到: ${finalPath}`); await downloadVideo(result.videoUrl, finalPath); console.log(`[VideoAPI] 视频已保存: ${finalPath}`); // 4. 【D135】注册视频索引 — 仓库↔硬盘双向映射 if (shotId || taskId) { registerVideo({ projectKey: projectKey || 'unknown', shotId: shotId || taskId, taskId, filePath: finalPath, duration: finalDuration, resolution: finalResolution, prompt, }); } return { videoPath: finalPath, taskId, duration: finalDuration, resolution: finalResolution, preflight }; } if (result.status === 'failed') { throw new Error(`视频生成失败: ${result.error}`); } console.log(`[VideoAPI] 生成中... (${attempts}/${MAX_POLL_ATTEMPTS})`); } throw new Error(`轮询超时(${MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS / 1000}秒)`); } /** * 【D135 新增】智能生成 — 提交前校验 + 下载前探针验证 + 自动重试 * * 流程: * 预校验(免费) → 提交 → 轮询 → API响应元数据检查 → * → ffprobe 探针(不下载)检查实际时长 * → ✅ 匹配 → 下载 * → ❌ 不匹配 → 报告差异,询问是否仍下载 * * @param {object} opts * @param {string} opts.prompt - 提示词 * @param {number} [opts.duration] - 期望时长 4-15秒 * @param {string} [opts.resolution] - 分辨率 * @param {string} [opts.outputPath] - 输出路径 * @param {boolean} [opts.forceDownload] - 跳过探针验证直接下载 * @param {string} [opts.model] - 【D144】模型ID覆盖 * @param {boolean} [opts.generateAudio] - 【D144】音画同步 * @returns {Promise<{videoPath: string, taskId: string, actualDuration: number, matched: boolean, preflight: object}>} */ async function validateAndGenerate({ prompt, duration, resolution, outputPath, shotId, projectKey, referenceImage, forceDownload, model, generateAudio }) { // 提交 + 轮询 + 下载 const result = await generateVideo({ prompt, duration, resolution, outputPath, shotId, projectKey, referenceImage, model, generateAudio }); // 【D135】探针验证实际时长 let probeResult = null; if (!forceDownload && result.videoPath) { console.log(`[VideoAPI·验证] 正在探测下载后视频的实际时长...`); probeResult = await probeVideoDuration(result.videoPath); if (probeResult.duration !== null) { const actual = probeResult.duration; const expected = result.duration; const diff = Math.abs(actual - expected); if (diff > 1.0) { // 差异超过1秒 → 问题 console.warn(`[VideoAPI·验证] ⚠️ 时长不匹配!`); console.warn(` 请求: ${expected}s → 实际: ${actual.toFixed(1)}s (差 ${diff.toFixed(1)}s)`); return { ...result, actualDuration: actual, matched: false, probeMeta: probeResult.meta, }; } else { console.log(`[VideoAPI·验证] ✅ 时长匹配: 请求${expected}s = 实际${actual.toFixed(1)}s`); return { ...result, actualDuration: actual, matched: true, probeMeta: probeResult.meta, }; } } else { console.warn(`[VideoAPI·验证] ⚠️ ffprobe 不可用,跳过时长验证 (${probeResult.error})`); } } return { ...result, actualDuration: probeResult?.duration || null, matched: null, // 无法验证 probeMeta: probeResult?.meta || {}, }; } // ==================== 导出 ==================== module.exports = { submitTask, queryTask, generateVideo, validateAndGenerate, preflightCheck, probeVideoDuration, downloadVideo, resolveOutputPath, registerVideo, findVideo, MODEL, SUPPORTED_MODELS, BASE_URL, API_SPEC, VIDEO_OUTPUT_ROOT, VIDEO_REGISTRY_PATH, };