remote-rdr / utils /CaptionRender.js
shiveshnavin's picture
Add options
bdfc7ce
// FULL FIXED VERSION — eliminates concat=n=1 and all copy filters
import { spawn } from 'child_process';
import os from 'os';
import path from 'path';
import fs from 'fs';
const ffmpegLocation = os.platform() === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
export class CaptionRenderer {
validateCaption(originalManuscript) {
let captionFiles = originalManuscript.transcript.map(item => item.audioCaptionFile);
// make sure the caption files are in ass format and exist
for (let captionFile of captionFiles) {
if (!captionFile || !captionFile.endsWith('.ass')) {
throw new Error('Invalid caption file format. Expected .ass files for item ' + (captionFiles.indexOf(captionFile) + 1) + '. Did you forget to use `caption` plugin?');
}
}
return true;
}
async doRender(jobId, originalManuscript, onLog, npmScript, options, controller) {
const outDir = path.join(process.cwd(), 'out');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const segmentFiles = [];
// Helper to clean up intermediate files
function cleanupFiles(files) {
for (const f of files) {
try { fs.unlinkSync(f); } catch (e) { /* ignore */ }
}
}
// Render each transcript section separately
for (const [i, section] of originalManuscript.transcript.entries()) {
const segmentFile = path.join(outDir, `${jobId}_segment_${i}.mp4`);
const inputs = [];
const filter = [];
let inputIndex = 0;
const media = section.mediaAbsPaths || [];
if (!media.length) continue;
media.forEach(m => {
inputs.push('-i', m.path);
inputIndex++;
});
inputs.push('-i', section.audioFullPath);
inputIndex++;
let clips = [];
let elapsed = 0;
media.forEach((m, j) => {
let d = m.prompt?.durationSec || m.durationSec;
d = Math.min(d, section.durationInSeconds - elapsed);
if (d <= 0) return;
const label = `v${i}_${j}`;
filter.push(`[${inputIndex - media.length - 1 + j}:v]trim=duration=${d},setpts=PTS-STARTPTS[${label}]`);
clips.push(`[${label}]`);
elapsed += d;
});
const vcat = `vc${i}`;
filter.push(`${clips.join('')}concat=n=${clips.length}:v=1:a=0[${vcat}]`);
const sub = `vs${i}`;
const esc = section.audioCaptionFile.replace(/\\/g, '/').replace(/:/g, '\\:');
filter.push(`[${vcat}]subtitles='${esc}'[${sub}]`);
const vout = `vout${i}`;
const aout = `aout${i}`;
filter.push(`[${sub}]null[${vout}]`);
filter.push(`[${inputIndex - 1}:a]anull[${aout}]`);
const args = [
...inputs,
'-filter_complex', filter.join(';'),
'-map', `[${vout}]`,
'-map', `[${aout}]`,
'-c:v', 'libx264',
'-c:a', 'aac',
'-y', segmentFile
];
await this.runFFmpeg(args, controller, onLog);
segmentFiles.push(segmentFile);
}
// Concatenate all segments together
const resultFile = path.join(outDir, `${jobId}_final.mp4`);
const concatFile = path.join(outDir, `${jobId}_combined_final.mp4`);
const concatList = path.join(outDir, `${jobId}_concat_list.txt`);
fs.writeFileSync(concatList, segmentFiles.map(f => `file '${f}'`).join('\n'));
const concatArgs = [
'-f', 'concat',
'-safe', '0',
'-i', concatList,
'-c', 'copy',
'-y', concatFile
];
await this.runFFmpeg(concatArgs, controller, onLog);
// Add background music if present
const { bgMusic, bgMusicVolume = 0.25 } = originalManuscript;
if (bgMusic) {
const finalWithMusic = resultFile; // Overwrite the _final.mp4 file
const getDuration = (filePath) => {
try {
const ffprobeSync = require('child_process').spawnSync;
const result = ffprobeSync(ffmpegLocation.replace('ffmpeg', 'ffprobe'), [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
filePath
]);
return parseFloat(result.stdout.toString().trim());
} catch (e) {
return null;
}
};
const videoDuration = getDuration(concatFile);
const atrimArg = (videoDuration && !isNaN(videoDuration)) ? `,atrim=end=${videoDuration}` : '';
const voiceVolume = options?.voiceVolume || 1.5;
const musicArgs = [
'-i', concatFile,
'-i', bgMusic,
'-filter_complex', `[0:a]volume=${voiceVolume}[voice];[1:a]volume=${bgMusicVolume}${atrimArg}[bgm];[voice][bgm]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixeda]`,
'-map', '0:v',
'-map', '[mixeda]',
'-c:v', 'copy',
'-c:a', 'aac',
'-y', finalWithMusic
];
await this.runFFmpeg(musicArgs, controller, onLog);
segmentFiles.push(concatFile);
}
else {
fs.renameSync(concatFile, resultFile);
}
cleanupFiles([...segmentFiles, concatList]);
return resultFile;
}
runFFmpeg(args, controller, onLog) {
console.log('FFMPEG cmd:', args.join(' '));
return new Promise((resolve, reject) => {
const p = spawn(ffmpegLocation, args, { detached: true });
if (controller) controller.stop = () => p.kill('SIGKILL');
p.stderr.on('data', d => onLog && onLog(d.toString()));
p.stdout.on('data', d => onLog && onLog(d.toString()));
p.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)));
p.on('error', reject);
});
}
}