cang-ying/engines/video-api-adapter.js
Guanghu Domestic Migration a2fa7d57d8 chore: import sanitized domestic snapshot for REPO-005
Source snapshot: 23037fa3a2e9b0597162735755e92fc763bf4d94
2026-07-17 15:55:48 +08:00

665 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光湖视频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 参数,已忽略。风格请通过提示词描述控制。`);
// 不传入 correctedstyle 将被丢弃
}
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,
};