From e85fd512e887d91786ba15bbf9255b3c16f746d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Fri, 12 Jun 2026 23:50:36 +0800 Subject: [PATCH] =?UTF-8?q?D131:=20video-composer.js=20=C2=B7=20FFmpeg?= =?UTF-8?q?=E6=8B=BC=E6=8E=A5+SRT=E5=AD=97=E5=B9=95=E7=83=A7=E5=BD=95?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-ai-system/engines/video-composer.js | 153 ++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 video-ai-system/engines/video-composer.js diff --git a/video-ai-system/engines/video-composer.js b/video-ai-system/engines/video-composer.js new file mode 100644 index 0000000..b035567 --- /dev/null +++ b/video-ai-system/engines/video-composer.js @@ -0,0 +1,153 @@ +/** + * 光湖视频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();