D131: video-composer.js · FFmpeg拼接+SRT字幕烧录引擎
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled

This commit is contained in:
冰朔 2026-06-12 23:50:36 +08:00
parent 19cff4aab8
commit e85fd512e8

View File

@ -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();