remote-rdr / utils /AvatarRender.js
shiveshnavin's picture
Bubble feature done and dusted
9db2ad9
import { spawn } from 'child_process';
import os from 'os';
import path from 'path';
import fs from 'fs';
import Bubble from './bubble/Bubble.js';
const ffmpegLocation = os.platform() === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
export class AvatarRenderer {
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 tempFiles = []; // track intermediate files for cleanup
const sectionCaptionedVideos = [];
// Step 1: For each section, concat its media files and burn its captions
for (let i = 0; i < originalManuscript.transcript.length; i++) {
const section = originalManuscript.transcript[i];
const sectionVideos = section.mediaAbsPaths?.map(m => m.path) || [];
if (!sectionVideos.length) {
onLog && onLog(`Warning: Section ${i} has no mediaAbsPaths, skipping.\n`);
continue;
}
let sectionVideo;
// Concat all media files within this section
if (sectionVideos.length === 1) {
sectionVideo = sectionVideos[0];
} else {
const baseDirOfVideos = path.dirname(sectionVideos[0]);
const concatList = path.join(baseDirOfVideos, `${jobId}_section${i}_concat_list.txt`);
fs.writeFileSync(concatList, sectionVideos.map(f => `file '${path.basename(f)}'`).join('\n'));
tempFiles.push(concatList);
sectionVideo = path.join(outDir, `${jobId}_section${i}_concat.mp4`);
const concatArgs = [
'-f', 'concat',
'-safe', '0',
'-i', concatList,
'-c', 'copy',
'-y', sectionVideo
];
onLog && onLog(`Concatenating ${sectionVideos.length} media files for section ${i}...\n`);
await this.runFFmpeg(concatArgs, controller, onLog);
tempFiles.push(sectionVideo);
}
// First apply bubbles (so they are below caption layer)
let sourceAfterBubbles = sectionVideo;
if (section.bubbles && Array.isArray(section.bubbles) && section.bubbles.length) {
const bubbledFile = path.join(outDir, `${jobId}_section${i}_bubbled.mp4`);
try {
onLog && onLog(`Applying ${section.bubbles.length} bubble(s) to section ${i}...\n`);
await Bubble.makeBubble(sectionVideo, section.bubbles, bubbledFile, onLog);
tempFiles.push(bubbledFile);
sourceAfterBubbles = bubbledFile;
} catch (e) {
onLog && onLog(`Bubble application failed for section ${i}, using original: ${e}\n`);
sourceAfterBubbles = sectionVideo;
}
}
// Then burn captions on top of bubbled video (so captions stay visible)
const captionFile = section.audioCaptionFile;
if (captionFile && captionFile.endsWith('.ass')) {
const subbedFile = path.join(outDir, `${jobId}_section${i}_subbed.mp4`);
const esc = captionFile.replace(/\\/g, '/').replace(/:/g, '\\:');
const subArgs = [
'-i', sourceAfterBubbles,
'-vf', `subtitles='${esc}'`,
'-c:a', 'copy',
'-y', subbedFile
];
onLog && onLog(`Burning captions for section ${i} after bubbles...\n`);
await this.runFFmpeg(subArgs, controller, onLog);
tempFiles.push(subbedFile);
sectionCaptionedVideos.push(subbedFile);
} else {
sectionCaptionedVideos.push(sourceAfterBubbles);
}
}
if (!sectionCaptionedVideos.length) throw new Error('No mediaAbsPaths found in any transcript section.');
// Step 2: Concat all captioned section videos together
let videoWithSubs;
if (sectionCaptionedVideos.length === 1) {
videoWithSubs = sectionCaptionedVideos[0];
} else {
const finalConcatList = path.join(outDir, `${jobId}_final_concat_list.txt`);
fs.writeFileSync(finalConcatList, sectionCaptionedVideos.map(f => `file '${f}'`).join('\n'));
tempFiles.push(finalConcatList);
videoWithSubs = path.join(outDir, `${jobId}_all_sections.mp4`);
const finalConcatArgs = [
'-f', 'concat',
'-safe', '0',
'-i', finalConcatList,
'-c', 'copy',
'-y', videoWithSubs
];
onLog && onLog(`Concatenating ${sectionCaptionedVideos.length} captioned sections...\n`);
await this.runFFmpeg(finalConcatArgs, controller, onLog);
tempFiles.push(videoWithSubs);
}
// Step 3: Add background music if present
const { bgMusic, bgMusicVolume = 0.25 } = originalManuscript;
const resultFile = path.join(outDir, `${jobId}_final.mp4`);
if (bgMusic) {
const voiceVolume = options?.voiceVolume || 1.5;
const musicArgs = [
'-i', videoWithSubs,
'-i', bgMusic,
'-filter_complex', `[0:a]volume=${voiceVolume}[voice];[1:a]volume=${bgMusicVolume}[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', resultFile
];
await this.runFFmpeg(musicArgs, controller, onLog);
} else {
// If the final video is one of the temp files, copy it; otherwise rename
if (tempFiles.includes(videoWithSubs)) {
fs.copyFileSync(videoWithSubs, resultFile);
} else {
fs.renameSync(videoWithSubs, resultFile);
}
}
// Cleanup intermediate files — keep only the final resultFile and the manuscript JSON
const manuscriptPath = path.join(process.cwd(), 'public', 'original_manuscript.json');
for (const f of tempFiles) {
if (!f) continue;
try {
const abs = path.resolve(f);
// never delete the final result file or the manuscript file
if (abs === path.resolve(resultFile)) continue;
if (abs === path.resolve(manuscriptPath)) continue;
if (fs.existsSync(abs)) {
try { fs.unlinkSync(abs); onLog && onLog(`Deleted temp file: ${abs}\n`); } catch (e) { onLog && onLog(`Failed to delete temp file ${abs}: ${e}\n`); }
}
} catch (e) {
onLog && onLog(`Error while cleaning temp file ${f}: ${e}\n`);
}
}
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);
});
}
}