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