2026-07-07 10:20:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 光湖视频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/
|
|
|
|
|
|
*
|
2026-07-12 23:37:46 -07:00
|
|
|
|
* 环境变量(由 LOCAL-SECRETS-PATH.hdlp 的苍耳本机路径加载):
|
2026-07-07 10:20:10 +08:00
|
|
|
|
* 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) {
|
2026-07-12 23:37:46 -07:00
|
|
|
|
throw new Error('未配置 JIMENG_API_KEY。请按 LOCAL-SECRETS-PATH.hdlp 在苍耳本机仓库外配置。');
|
2026-07-07 10:20:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
};
|