174 lines
5.8 KiB
JavaScript
174 lines
5.8 KiB
JavaScript
/**
|
||
* 光湖视频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 };
|