154 lines
4.9 KiB
JavaScript
154 lines
4.9 KiB
JavaScript
/**
|
|
* 光湖视频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();
|