cang-ying/engines/video-composer.js

174 lines
5.8 KiB
JavaScript
Raw Permalink Normal View History

/**
* 光湖视频AI系统 · 视频合成引擎
* D135 · 铸渊 ICE-GL-ZY001
*
* 用途: 将多个镜头的 mp4 文件按时间线拼接为成品视频
* 支持硬切溶解过渡截取加速静音轨
* 底层调用 FFmpeg零额外成本
*
* 使用方式:
* const { compose } = require('./video-composer');
* await compose({
* shots: [{ file, trim: 2.5 }, ...],
* transition: 'fade', // 'cut' | 'fade'
* fadeDuration: 0.3,
* output: '/Volumes/JZAO/.../output.mp4',
* });
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// ==================== 默认输出 ====================
const DEFAULT_OUTPUT_DIR = (() => {
const jzao = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频';
return fs.existsSync(jzao) ? jzao : path.resolve(__dirname, '../outputs');
})();
// ==================== 核心 ====================
/**
* 合成视频
* @param {object[]} opts.shots - 镜头列表 [{ file, trim: 秒数 }, ...]
* @param {string} [opts.transition] - 'cut' 硬切(默认) | 'fade' 溶解
* @param {number} [opts.fadeDuration] - 溶解时长秒数 (默认0.3)
* @param {string} [opts.output] - 输出路径
* @param {number} [opts.fps] - 帧率 (默认与首镜相同)
* @returns {Promise<{outputPath: string, duration: number, size: string}>}
*/
async function compose({ shots, transition = 'fade', fadeDuration = 0.3, output, fps }) {
if (!shots || shots.length === 0) throw new Error('镜头列表不能为空');
if (shots.length === 1) {
const src = shots[0].file;
const dest = output || path.join(DEFAULT_OUTPUT_DIR, `composed-${Date.now()}.mp4`);
fs.copyFileSync(src, dest);
const dur = probeSec(dest);
return { outputPath: dest, duration: dur, size: fmtSize(dest) };
}
const tmpDir = path.join(DEFAULT_OUTPUT_DIR, '.composer-tmp');
fs.mkdirSync(tmpDir, { recursive: true });
// Step1: 截取每镜到目标时长,统一帧率和编码
const trimmed = [];
for (let i = 0; i < shots.length; i++) {
const s = shots[i];
const tFile = path.join(tmpDir, `trim_${i}_${Date.now()}.mp4`);
const trimDur = s.trim || probeSec(s.file);
const fpsFlag = fps ? `-r ${fps}` : '';
execSync(
`ffmpeg -y -i "${s.file}" -t ${trimDur} ${fpsFlag} -c:v libx264 -preset ultrafast -crf 18 -an "${tFile}" 2>/dev/null`,
{ timeout: 30000 }
);
if (!fs.existsSync(tFile)) throw new Error(`截取失败: 镜${i + 1}`);
trimmed.push({ file: tFile, dur: trimDur });
}
// Step2: 拼接
const finalPath = output || path.join(DEFAULT_OUTPUT_DIR, `composed-${Date.now()}.mp4`);
if (transition === 'fade' && trimmed.length >= 2) {
await composeXFade(trimmed, finalPath, fadeDuration);
} else {
await composeCut(trimmed, finalPath);
}
// Step3: faststart播放器优化失败不影响
try {
const tmp = finalPath + '.tmp';
execSync(`ffmpeg -y -i "${finalPath}" -c copy -movflags +faststart "${tmp}" 2>/dev/null`, { timeout: 15000 });
if (fs.existsSync(tmp)) { fs.unlinkSync(finalPath); fs.renameSync(tmp, finalPath); }
} catch (_) { /* faststart is optional */ }
// Step4: 清理临时文件
trimmed.forEach(t => { try { fs.unlinkSync(t.file); } catch (_) {} });
const dur = probeSec(finalPath);
console.log(`[Composer] ✅ ${finalPath} ${dur.toFixed(1)}s ${fmtSize(finalPath)}`);
return { outputPath: finalPath, duration: dur, size: fmtSize(finalPath) };
}
/**
* 硬切拼接无过渡
*/
function composeCut(trimmed, output) {
const listFile = path.join(path.dirname(output), '.concat-list.txt');
const list = trimmed.map(t => `file '${t.file}'`).join('\n');
fs.writeFileSync(listFile, list);
execSync(
`ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${output}" 2>/dev/null`,
{ timeout: 30000 }
);
try { fs.unlinkSync(listFile); } catch (_) {}
}
/**
* 溶解过渡拼接
*/
function composeXFade(trimmed, output, fadeDur) {
if (trimmed.length === 2) {
// 两镜:直接 xfade
execSync(
`ffmpeg -y -i "${trimmed[0].file}" -i "${trimmed[1].file}" ` +
`-filter_complex "xfade=transition=fade:duration=${fadeDur}:offset=${(trimmed[0].dur - fadeDur).toFixed(1)}" ` +
`-c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
{ timeout: 60000 }
);
return;
}
// 多镜:链式 xfade
let filterLines = '';
let prevLabel = '0:v';
let totalDur = 0;
const offsets = [];
for (let i = 0; i < trimmed.length; i++) {
if (i > 0) {
const offset = (totalDur - fadeDur).toFixed(1);
const newLabel = i < trimmed.length - 1 ? `vt${i}` : 'vout';
filterLines += `[${prevLabel}][${i}:v]xfade=transition=fade:duration=${fadeDur}:offset=${offset}[${newLabel}];\n`;
prevLabel = newLabel;
totalDur += trimmed[i].dur - fadeDur;
} else {
totalDur = trimmed[i].dur;
}
}
const inputFlags = trimmed.map((_, i) => `-i "${trimmed[i].file}"`).join(' ');
const fullFilter = filterLines.trim();
const mapOut = trimmed.length > 2 ? 'vout' : '1:v';
execSync(
`ffmpeg -y ${inputFlags} -filter_complex "${fullFilter}" ` +
`-map "[${prevLabel}]" -c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
{ timeout: 120000 }
);
}
// ==================== 工具 ====================
function probeSec(file) {
try {
const out = execSync(
`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${file}"`,
{ timeout: 5000, encoding: 'utf8' }
);
return parseFloat(out.trim());
} catch (_) {
return 4.0; // fallback
}
}
function fmtSize(file) {
try {
const stat = fs.statSync(file);
return stat.size < 1048576
? `${(stat.size / 1024).toFixed(0)}KB`
: `${(stat.size / 1048576).toFixed(1)}MB`;
} catch (_) { return '?'; }
}
module.exports = { compose, probeSec };