cang-ying/engines/video-composer.js
Guanghu Domestic Migration a2fa7d57d8 chore: import sanitized domestic snapshot for REPO-005
Source snapshot: 23037fa3a2e9b0597162735755e92fc763bf4d94
2026-07-17 15:55:48 +08:00

174 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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