/** * 光湖视频AI · video-composer · 视频拼接+字幕引擎 * D131 · 铸渊 ICE-GL-ZY001 * * 功能: * 1. 将8个分镜视频按顺序拼接 * 2. 从剧本提取台词生成SRT字幕 * 3. 调用FFmpeg合成最终视频 * * 用法: * node video-composer.js --project qinshan --ep 1 * * 环境要求: * - FFmpeg (服务器上) * - 8个分镜视频文件在 outputs/shots/ 下 * - 字幕数据从 project_subs 配置读取 */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // ==================== 项目字幕配置 ==================== // 每镜: { duration, text }。镜面无台词则 text 为空不烧字幕。 const project_subs = { qinshan: { title: '秦山号', ep: { 1: [ { shot: 1, duration: 5, text: '' }, { shot: 2, duration: 5, text: '' }, { shot: 3, duration: 5, text: '[警报声] 滴滴滴滴...' }, { shot: 4, duration: 5, text: '' }, { shot: 5, duration: 5, text: '' }, { shot: 6, duration: 5, text: '林昊:不能坐以待毙!' }, { shot: 7, duration: 5, text: '林昊:果然...' }, { shot: 8, duration: 10, text: '[无声翕动] 救我...杀了我...' }, ], }, }, }; // ==================== SRT 生成 ==================== function generateSRT(subs) { const lines = []; let time = 0; // 累计秒数 let idx = 1; for (const sub of subs) { if (!sub.text) { time += sub.duration; continue; } const start = formatTime(time); const end = formatTime(time + sub.duration); lines.push(String(idx)); lines.push(`${start} --> ${end}`); lines.push(sub.text); lines.push(''); time += sub.duration; idx++; } return lines.join('\n'); } function formatTime(totalSec) { const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = Math.floor(totalSec % 60); const ms = Math.round((totalSec - Math.floor(totalSec)) * 1000); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`; } // ==================== FFmpeg 拼接 ==================== function buildConcatFile(shotsDir, prefix, count) { const files = []; for (let i = 1; i <= count; i++) { const p = path.resolve(shotsDir, `${prefix}${i}.mp4`); if (fs.existsSync(p)) { files.push(`file '${p}'`); } else { console.error(`⚠️ 缺失: ${p}`); } } return files.join('\n'); } function concatAndBurn(shotsDir, prefix, count, srtPath, outputPath) { const concatPath = path.join(path.dirname(outputPath), 'concat.txt'); const concatContent = buildConcatFile(shotsDir, prefix, count); fs.writeFileSync(concatPath, concatContent); console.log(`拼接文件: ${concatPath}`); console.log(`字幕文件: ${srtPath}`); if (fs.existsSync(srtPath) && fs.statSync(srtPath).size > 0) { // 有字幕:拼接+烧字幕 const cmd = `ffmpeg -y -f concat -safe 0 -i "${concatPath}" -vf "subtitles=${srtPath}:force_style='FontName=Noto Sans SC,FontSize=20,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=1,Shadow=1'" -c:v libx264 -preset medium -crf 23 -c:a aac "${outputPath}"`; console.log(`执行: ${cmd.substring(0, 100)}...`); execSync(cmd, { stdio: 'inherit' }); } else { // 无字幕:纯拼接 const cmd = `ffmpeg -y -f concat -safe 0 -i "${concatPath}" -c copy "${outputPath}"`; console.log(`执行: ${cmd}`); execSync(cmd, { stdio: 'inherit' }); } console.log(`✅ 成品: ${outputPath}`); } // ==================== 主入口 ==================== function main() { const args = process.argv.slice(2); const project = args.includes('--project') ? args[args.indexOf('--project') + 1] : 'qinshan'; const epNum = args.includes('--ep') ? parseInt(args[args.indexOf('--ep') + 1], 10) : 1; const epSubs = project_subs[project]?.ep?.[epNum]; if (!epSubs) { console.error(`❌ 未找到项目 ${project} 第${epNum}集的字幕配置`); process.exit(1); } // 查找视频文件 const shotsDir = path.resolve(__dirname, '../outputs/shots'); const prefix = `${project}-ep${String(epNum).padStart(2, '0')}-shot`; // 生成SRT const outputDir = path.resolve(__dirname, '../outputs/final'); if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); const srtPath = path.join(outputDir, `${project}-ep${String(epNum).padStart(2, '0')}.srt`); const srtContent = generateSRT(epSubs); if (srtContent) { fs.writeFileSync(srtPath, srtContent); console.log(`📝 字幕: ${srtPath}`); } // 拼接+烧字幕 const outputPath = path.join(outputDir, `${project}-ep${String(epNum).padStart(2, '0')}.mp4`); concatAndBurn(shotsDir, prefix, epSubs.length, srtContent ? srtPath : null, outputPath); console.log(`\n🎬 ${project_subs[project].title} 第${epNum}集完成!`); console.log(` 成品: ${outputPath}`); } main();