Spaces:
Runtime error
Runtime error
Commit ·
7ad7585
1
Parent(s): 9b7faf0
Change to work with single line
Browse files- server-plugins/split-render.js +3 -0
- utils/CaptionRender.js +60 -121
server-plugins/split-render.js
CHANGED
|
@@ -7,6 +7,9 @@ import { PerformanceRecorder } from 'common-utils';
|
|
| 7 |
|
| 8 |
/**
|
| 9 |
* SplitRenderPlugin must be used with transparent-background
|
|
|
|
|
|
|
|
|
|
| 10 |
*/
|
| 11 |
export class SplitRenderPlugin extends Plugin {
|
| 12 |
constructor(name, options) {
|
|
|
|
| 7 |
|
| 8 |
/**
|
| 9 |
* SplitRenderPlugin must be used with transparent-background
|
| 10 |
+
* when the Remotion renders the captions on a black background.
|
| 11 |
+
* This plugin will then composite the transparent video over
|
| 12 |
+
* the actual media files thus combining them into the final output.
|
| 13 |
*/
|
| 14 |
export class SplitRenderPlugin extends Plugin {
|
| 15 |
constructor(name, options) {
|
utils/CaptionRender.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
|
|
|
|
|
| 1 |
import { spawn } from 'child_process';
|
| 2 |
import os from 'os';
|
| 3 |
import path from 'path';
|
| 4 |
import fs from 'fs';
|
| 5 |
|
| 6 |
-
|
| 7 |
|
| 8 |
export class CaptionRenderer {
|
| 9 |
validateCaption(originalManuscript) {
|
|
@@ -19,159 +21,96 @@ export class CaptionRenderer {
|
|
| 19 |
|
| 20 |
async doRender(jobId, originalManuscript, onLog, npmScript, options, controller) {
|
| 21 |
const outDir = path.join(process.cwd(), 'out');
|
| 22 |
-
if (!fs.existsSync(outDir)) {
|
| 23 |
-
fs.mkdirSync(outDir, { recursive: true });
|
| 24 |
-
}
|
| 25 |
|
| 26 |
const outFile = path.join(outDir, `${jobId}_final.mp4`);
|
| 27 |
|
| 28 |
-
// Build a single complex filter chain for all sections
|
| 29 |
-
let filterComplex = '';
|
| 30 |
let inputs = [];
|
| 31 |
-
let
|
| 32 |
let inputIndex = 0;
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
const audioDuration = section.durationInSeconds;
|
| 38 |
-
const audioPath = section.audioFullPath;
|
| 39 |
-
const captionFile = section.audioCaptionFile;
|
| 40 |
-
const mediaFiles = section.mediaAbsPaths || [];
|
| 41 |
-
|
| 42 |
-
if (mediaFiles.length === 0) {
|
| 43 |
-
console.log(`Skipping section ${i}: no media files`);
|
| 44 |
-
continue;
|
| 45 |
-
}
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
inputs.push('-i',
|
| 51 |
inputIndex++;
|
| 52 |
-
}
|
| 53 |
|
| 54 |
-
|
| 55 |
-
inputs.push('-i', audioPath);
|
| 56 |
-
const audioIndex = inputIndex;
|
| 57 |
inputIndex++;
|
| 58 |
|
| 59 |
-
|
| 60 |
-
let
|
| 61 |
-
let totalVideoDuration = 0;
|
| 62 |
|
| 63 |
-
|
| 64 |
-
let
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
}
|
| 68 |
-
const clipDuration = Math.min(mediaFileDuration, audioDuration - totalVideoDuration);
|
| 69 |
-
if (clipDuration <= 0) break;
|
| 70 |
|
| 71 |
-
const
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
}
|
| 77 |
|
| 78 |
-
|
| 79 |
-
const
|
| 80 |
-
|
| 81 |
-
filterComplex += videoFilters.join(';');
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
} else {
|
| 86 |
-
filterComplex += `;[v${i}_0]copy[vconcat${i}]`;
|
| 87 |
-
}
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
filterComplex += `;[vconcat${i}]subtitles='${escapedCaptionFile}'[vsub${i}]`;
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
|
|
|
| 95 |
|
| 96 |
-
|
| 97 |
-
}
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
filterComplex += `;[vout0]copy[finalv];[aout0]copy[finala]`;
|
| 104 |
}
|
| 105 |
|
| 106 |
-
|
| 107 |
-
const { bgMusic, bgMusicVolume = 0.25, bgMusicDuration = 3 * 60 } = originalManuscript;
|
| 108 |
-
let bgMusicInputIndex = null;
|
| 109 |
-
if (bgMusic) {
|
| 110 |
-
inputs.push('-i', bgMusic);
|
| 111 |
-
bgMusicInputIndex = inputIndex;
|
| 112 |
-
inputIndex++;
|
| 113 |
-
// Mix bgMusic with finala
|
| 114 |
-
filterComplex += `;[${bgMusicInputIndex}:a]volume=${bgMusicVolume}[bgmvol];[finala][bgmvol]amix=inputs=2:duration=first:dropout_transition=2[mixeda]`;
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
await this.runFFmpeg([
|
| 118 |
...inputs,
|
| 119 |
-
'-filter_complex',
|
| 120 |
'-map', '[finalv]',
|
| 121 |
-
'-map', bgMusic ? '[mixeda]' : '[finala]',
|
| 122 |
'-c:v', 'libx264',
|
| 123 |
'-c:a', 'aac',
|
| 124 |
-
'-y',
|
| 125 |
-
|
| 126 |
-
], controller, onLog);
|
| 127 |
|
| 128 |
-
|
| 129 |
-
return outFile;
|
| 130 |
}
|
| 131 |
|
| 132 |
-
|
| 133 |
-
console.log('FFMPEG cmd:
|
| 134 |
-
return new Promise((resolve, reject) => {
|
| 135 |
-
const ffmpegProcess = spawn(ffmpegLocation, [...args], {
|
| 136 |
-
detached: true,
|
| 137 |
-
cwd: process.cwd()
|
| 138 |
-
});
|
| 139 |
-
|
| 140 |
-
if (controller) {
|
| 141 |
-
controller.stop = () => {
|
| 142 |
-
console.log('Stopping ffmpeg process');
|
| 143 |
-
try {
|
| 144 |
-
process.kill(-ffmpegProcess.pid, 'SIGKILL');
|
| 145 |
-
} catch (e) {
|
| 146 |
-
console.error(`Failed to kill process group ${-ffmpegProcess.pid}`, e);
|
| 147 |
-
ffmpegProcess.kill('SIGKILL');
|
| 148 |
-
}
|
| 149 |
-
};
|
| 150 |
-
}
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
console.log(msg);
|
| 155 |
-
if (onLog) onLog(msg);
|
| 156 |
-
});
|
| 157 |
|
| 158 |
-
|
| 159 |
-
const msg = data.toString();
|
| 160 |
-
console.error(msg);
|
| 161 |
-
if (onLog) onLog(msg);
|
| 162 |
-
});
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
resolve();
|
| 167 |
-
} else {
|
| 168 |
-
reject(new Error(`FFmpeg process exited with code ${code}`));
|
| 169 |
-
}
|
| 170 |
-
});
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
});
|
| 175 |
});
|
| 176 |
}
|
| 177 |
}
|
|
|
|
| 1 |
+
// FULL FIXED VERSION — eliminates concat=n=1 and all copy filters
|
| 2 |
+
|
| 3 |
import { spawn } from 'child_process';
|
| 4 |
import os from 'os';
|
| 5 |
import path from 'path';
|
| 6 |
import fs from 'fs';
|
| 7 |
|
| 8 |
+
const ffmpegLocation = os.platform() === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
|
| 9 |
|
| 10 |
export class CaptionRenderer {
|
| 11 |
validateCaption(originalManuscript) {
|
|
|
|
| 21 |
|
| 22 |
async doRender(jobId, originalManuscript, onLog, npmScript, options, controller) {
|
| 23 |
const outDir = path.join(process.cwd(), 'out');
|
| 24 |
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
|
|
|
| 25 |
|
| 26 |
const outFile = path.join(outDir, `${jobId}_final.mp4`);
|
| 27 |
|
|
|
|
|
|
|
| 28 |
let inputs = [];
|
| 29 |
+
let filter = [];
|
| 30 |
let inputIndex = 0;
|
| 31 |
+
let videoLabels = [];
|
| 32 |
+
let audioLabels = [];
|
| 33 |
|
| 34 |
+
originalManuscript.transcript.forEach((section, i) => {
|
| 35 |
+
const media = section.mediaAbsPaths || [];
|
| 36 |
+
if (!media.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
const audioIndex = inputIndex + media.length;
|
| 39 |
+
|
| 40 |
+
media.forEach(m => {
|
| 41 |
+
inputs.push('-i', m.path);
|
| 42 |
inputIndex++;
|
| 43 |
+
});
|
| 44 |
|
| 45 |
+
inputs.push('-i', section.audioFullPath);
|
|
|
|
|
|
|
| 46 |
inputIndex++;
|
| 47 |
|
| 48 |
+
let clips = [];
|
| 49 |
+
let elapsed = 0;
|
|
|
|
| 50 |
|
| 51 |
+
media.forEach((m, j) => {
|
| 52 |
+
let d = m.prompt?.durationSec || m.durationSec;
|
| 53 |
+
d = Math.min(d, section.durationInSeconds - elapsed);
|
| 54 |
+
if (d <= 0) return;
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
const label = `v${i}_${j}`;
|
| 57 |
+
filter.push(`[${inputIndex - media.length - 1 + j}:v]trim=duration=${d},setpts=PTS-STARTPTS[${label}]`);
|
| 58 |
+
clips.push(`[${label}]`);
|
| 59 |
+
elapsed += d;
|
| 60 |
+
});
|
| 61 |
|
| 62 |
+
const vcat = `vc${i}`;
|
| 63 |
+
filter.push(`${clips.join('')}concat=n=${clips.length}:v=1:a=0[${vcat}]`);
|
| 64 |
|
| 65 |
+
const sub = `vs${i}`;
|
| 66 |
+
const esc = section.audioCaptionFile.replace(/\\/g, '/').replace(/:/g, '\\:');
|
| 67 |
+
filter.push(`[${vcat}]subtitles='${esc}'[${sub}]`);
|
|
|
|
| 68 |
|
| 69 |
+
const vout = `vout${i}`;
|
| 70 |
+
const aout = `aout${i}`;
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
filter.push(`[${sub}]null[${vout}]`);
|
| 73 |
+
filter.push(`[${audioIndex}:a]anull[${aout}]`);
|
|
|
|
| 74 |
|
| 75 |
+
videoLabels.push(`[${vout}]`);
|
| 76 |
+
audioLabels.push(`[${aout}]`);
|
| 77 |
+
});
|
| 78 |
|
| 79 |
+
filter.push(`${videoLabels.join('')}concat=n=${videoLabels.length}:v=1:a=0[finalv]`);
|
| 80 |
+
filter.push(`${audioLabels.join('')}concat=n=${audioLabels.length}:v=0:a=1[finala]`);
|
| 81 |
|
| 82 |
+
if (originalManuscript.bgMusic) {
|
| 83 |
+
inputs.push('-i', originalManuscript.bgMusic);
|
| 84 |
+
filter.push(`[${inputIndex}:a]volume=${originalManuscript.bgMusicVolume || 0.25}[bgm]`);
|
| 85 |
+
filter.push(`[finala][bgm]amix=inputs=2:duration=first:dropout_transition=2[mixeda]`);
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
+
const args = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
...inputs,
|
| 90 |
+
'-filter_complex', filter.join(';'),
|
| 91 |
'-map', '[finalv]',
|
| 92 |
+
'-map', originalManuscript.bgMusic ? '[mixeda]' : '[finala]',
|
| 93 |
'-c:v', 'libx264',
|
| 94 |
'-c:a', 'aac',
|
| 95 |
+
'-y', outFile
|
| 96 |
+
];
|
|
|
|
| 97 |
|
| 98 |
+
return this.runFFmpeg(args, controller, onLog);
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
+
runFFmpeg(args, controller, onLog) {
|
| 102 |
+
console.log('FFMPEG cmd:', args.join(' '));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
return new Promise((resolve, reject) => {
|
| 105 |
+
const p = spawn(ffmpegLocation, args, { detached: true });
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
if (controller) controller.stop = () => p.kill('SIGKILL');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
p.stderr.on('data', d => onLog && onLog(d.toString()));
|
| 110 |
+
p.stdout.on('data', d => onLog && onLog(d.toString()));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
p.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)));
|
| 113 |
+
p.on('error', reject);
|
|
|
|
| 114 |
});
|
| 115 |
}
|
| 116 |
}
|