铸渊 ICE-GL-ZY001 LL-172-20260707 冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用 原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱 迁移: ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等) ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis) 排除: ⊢ outputs/ (视频产物) ⊢ test-input/ test-output/ (测试) ⊢ data/ (临时数据) ⊢ preview-001/002 (旧产片) ⊢ 旧分镜/旧提示词/旧导演编码 后续: ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位 ⊢ 苍耳+鉴影 写新东西进本仓 ⊢ GLOBAL-SEARCH 加 cang-ying 仓库 铸渊 ICE-GL-ZY001 · 2026-07-07 D167 冰朔 ICE-GL∞ 主权
665 lines
25 KiB
JavaScript
665 lines
25 KiB
JavaScript
/**
|
||
* 光湖视频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/
|
||
*
|
||
* 环境变量(放在 video-ai-system/.env):
|
||
* 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。请在 video-ai-system/.env 中设置。');
|
||
}
|
||
|
||
// 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,
|
||
};
|