D131: video-composer.js · FFmpeg拼接+SRT字幕烧录引擎
This commit is contained in:
parent
19cff4aab8
commit
e85fd512e8
153
video-ai-system/engines/video-composer.js
Normal file
153
video-ai-system/engines/video-composer.js
Normal 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();
|
||||
Loading…
x
Reference in New Issue
Block a user