cang-ying/engines/video-api-adapter.js

665 lines
25 KiB
JavaScript
Raw Normal View History

/**
* 光湖视频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,
};