Spaces:
Running
Running
File size: 7,276 Bytes
f3b5020 23a704f f3b5020 2a06a04 9db2ad9 2a06a04 9db2ad9 2a06a04 9db2ad9 2a06a04 9db2ad9 23a704f 9db2ad9 2a06a04 f3b5020 2a06a04 f3b5020 2a06a04 f3b5020 2a06a04 f3b5020 23a704f 2a06a04 23a704f 2a06a04 f3b5020 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | 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);
});
}
}
|