Spaces:
Running
Running
Commit ·
8dd5bba
1
Parent(s): 8ba367b
WIP renderer
Browse files- app.js +5 -633
- routes.js +15 -8
- server-plugins/apply.js +5 -1
- server-plugins/flatten-paths.js +28 -0
- server-plugins/generate-captions.js +160 -172
- server-plugins/plugin.js +4 -0
- utils/CaptionRender.js +179 -0
app.js
CHANGED
|
@@ -1,647 +1,19 @@
|
|
| 1 |
import express from 'express';
|
| 2 |
-
import
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
writeFileSync,
|
| 6 |
-
copyFileSync,
|
| 7 |
-
readdirSync,
|
| 8 |
-
mkdirSync,
|
| 9 |
-
createReadStream,
|
| 10 |
-
createWriteStream,
|
| 11 |
-
} from 'fs';
|
| 12 |
-
import path, {resolve as _resolve, join} from 'path';
|
| 13 |
-
import {exec} from 'child_process';
|
| 14 |
-
import kill from 'tree-kill';
|
| 15 |
-
import {fileURLToPath} from 'url';
|
| 16 |
-
import {dirname} from 'path';
|
| 17 |
import RenderRouter from './routes.js';
|
| 18 |
|
| 19 |
-
import pkg from 'common-utils';
|
| 20 |
-
const {
|
| 21 |
-
UnzipFiles,
|
| 22 |
-
ZipFiles,
|
| 23 |
-
Utils,
|
| 24 |
-
FileUploader,
|
| 25 |
-
PerformanceRecorder,
|
| 26 |
-
Auditlog,
|
| 27 |
-
Vault,
|
| 28 |
-
} = pkg;
|
| 29 |
-
const OracleStorage = Utils.readFileToObject('storage.json');
|
| 30 |
const __filename = fileURLToPath(import.meta.url);
|
| 31 |
const __dirname = dirname(__filename);
|
| 32 |
-
const axios = Utils.getAxios();
|
| 33 |
-
|
| 34 |
-
if (process.env.is_pm2) {
|
| 35 |
-
console.log('Disabling render logs to stdout as env is pm2');
|
| 36 |
-
}
|
| 37 |
-
function modifyFiles(originalManuscript, originalManuscriptFile) {
|
| 38 |
-
let fname = join(__dirname, './src/youtube/SequentialScene.orig.tsx');
|
| 39 |
-
let fnameTarget = join(__dirname, './src/youtube/SequentialScene.tsx');
|
| 40 |
-
let SequentialSceneText = readFileSync(fname).toString();
|
| 41 |
-
let seqScene = '';
|
| 42 |
-
let script = originalManuscript.transcript;
|
| 43 |
-
for (let index = 0; index < script.length; index++) {
|
| 44 |
-
if (index > 0)
|
| 45 |
-
seqScene = seqScene + `\n{getTransitionScene(contents[${index}])}\n`;
|
| 46 |
-
seqScene = seqScene + `\n{getScene(contents[${index}])}\n`;
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
generateSubtitles(originalManuscript);
|
| 50 |
-
writeFileSync(
|
| 51 |
-
originalManuscriptFile,
|
| 52 |
-
JSON.stringify(originalManuscript, null, 2)
|
| 53 |
-
);
|
| 54 |
-
|
| 55 |
-
writeFileSync(
|
| 56 |
-
fnameTarget,
|
| 57 |
-
SequentialSceneText.replace('{getScene(contents[0])}', seqScene)
|
| 58 |
-
);
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
function generateSubtitles(originalManuscript) {
|
| 62 |
-
let transcript = originalManuscript.transcript;
|
| 63 |
-
for (let index = 0; index < transcript.length; index++) {
|
| 64 |
-
const section = transcript[index];
|
| 65 |
-
section.total_syllables = getDurationForSentenceByPhenone(
|
| 66 |
-
section.text.trim()
|
| 67 |
-
);
|
| 68 |
-
let speakerSyllablesPs =
|
| 69 |
-
section.total_syllables / section.durationInSeconds;
|
| 70 |
-
let calc = 0;
|
| 71 |
-
section.subtitles = section.text.split('.').map((t) => {
|
| 72 |
-
let c = getDurationForSentenceByPhenone(t.trim()) / speakerSyllablesPs;
|
| 73 |
-
calc += c;
|
| 74 |
-
return {
|
| 75 |
-
text: t.trim(),
|
| 76 |
-
expectedDurationSec: c,
|
| 77 |
-
};
|
| 78 |
-
});
|
| 79 |
-
section.calculate_duration_sec = calc;
|
| 80 |
-
}
|
| 81 |
-
return originalManuscript;
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
function getDurationForSentenceByPhenone(text) {
|
| 85 |
-
return text.length; //syllableCount(text)
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
function checkUnzipExists() {
|
| 89 |
-
return new Promise((resolve, reject) => {
|
| 90 |
-
const checkCommand = 'command -v unzip || which unzip';
|
| 91 |
-
exec(checkCommand, (error, stdout, stderr) => {
|
| 92 |
-
if (error || stderr || !stdout.trim()) {
|
| 93 |
-
resolve(false); // Return false if unzip was not found
|
| 94 |
-
} else {
|
| 95 |
-
resolve(true); // Return true if unzip exists in the PATH
|
| 96 |
-
}
|
| 97 |
-
});
|
| 98 |
-
});
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
async function UnzipFilesLocal(zipFilePath, destinationDirectory) {
|
| 102 |
-
try {
|
| 103 |
-
if (await checkUnzipExists())
|
| 104 |
-
return await new Promise((resolve, reject) => {
|
| 105 |
-
const fileName = path.basename(zipFilePath);
|
| 106 |
-
const unzipCommand = `unzip -o ${fileName} -d ${destinationDirectory}`;
|
| 107 |
-
|
| 108 |
-
exec(
|
| 109 |
-
unzipCommand,
|
| 110 |
-
{cwd: path.dirname(zipFilePath)},
|
| 111 |
-
(error, stdout, stderr) => {
|
| 112 |
-
if (error) {
|
| 113 |
-
console.error(`Error executing unzip command: ${error.message}`);
|
| 114 |
-
reject(error);
|
| 115 |
-
return;
|
| 116 |
-
}
|
| 117 |
-
if (stderr) {
|
| 118 |
-
console.error(`Unzip command stderr: ${stderr}`);
|
| 119 |
-
}
|
| 120 |
-
console.log(`Unzip command stdout: ${stdout}`);
|
| 121 |
-
resolve();
|
| 122 |
-
}
|
| 123 |
-
);
|
| 124 |
-
});
|
| 125 |
-
else return await UnzipFiles(zipFilePath, destinationDirectory);
|
| 126 |
-
} catch (error_1) {
|
| 127 |
-
console.error('Unzip check failed:', error_1.message);
|
| 128 |
-
throw error_1;
|
| 129 |
-
}
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
async function extract(filePath) {
|
| 133 |
-
await UnzipFilesLocal(filePath, path.join(__dirname, 'public'));
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
async function generateOutputBundle(jobId) {
|
| 137 |
-
let outDir = join(__dirname, 'out');
|
| 138 |
-
let outFile = join(__dirname, 'out', `${jobId}.zip`);
|
| 139 |
-
let manuFile = join(__dirname, `public/original_manuscript.json`);
|
| 140 |
-
copyFileSync(manuFile, join(__dirname, 'out', `original_manuscript.json`));
|
| 141 |
-
let outputFiles = readdirSync(outDir).map((fname) => {
|
| 142 |
-
const filePath = join(outDir, fname);
|
| 143 |
-
return filePath;
|
| 144 |
-
});
|
| 145 |
-
await ZipFiles(outputFiles, outFile);
|
| 146 |
-
return outFile;
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
async function notify(
|
| 150 |
-
jobId,
|
| 151 |
-
origManu,
|
| 152 |
-
outputUrl,
|
| 153 |
-
bundleUrl,
|
| 154 |
-
status,
|
| 155 |
-
time_taken
|
| 156 |
-
) {
|
| 157 |
-
origManu.meta.render = {
|
| 158 |
-
output_url: outputUrl,
|
| 159 |
-
status: status,
|
| 160 |
-
time_taken: time_taken,
|
| 161 |
-
id: jobId,
|
| 162 |
-
};
|
| 163 |
-
origManu.bundleUrl = bundleUrl;
|
| 164 |
-
if (origManu.meta.callback_url) {
|
| 165 |
-
try {
|
| 166 |
-
let cbRes = await axios.post(origManu.meta.callback_url, origManu.meta);
|
| 167 |
-
console.log('On Render Completed Callback', cbRes.data);
|
| 168 |
-
clear();
|
| 169 |
-
} catch (e) {
|
| 170 |
-
console.log('Error in render callback', e.message);
|
| 171 |
-
}
|
| 172 |
-
} else {
|
| 173 |
-
console.log('No callback url provided, skipping on-render webhook');
|
| 174 |
-
}
|
| 175 |
-
}
|
| 176 |
|
| 177 |
const app = express();
|
| 178 |
-
function clear() {
|
| 179 |
-
try {
|
| 180 |
-
const foldersToPreserve = ['assets', 'mp3'];
|
| 181 |
-
Utils.clearFolder(join(__dirname, './public'), foldersToPreserve);
|
| 182 |
-
// Utils.clearFolder(join(__dirname, "./out"))
|
| 183 |
-
Utils.clearFolder(join(__dirname, './uploads'));
|
| 184 |
-
} catch (e) {}
|
| 185 |
-
}
|
| 186 |
app.use(RenderRouter);
|
| 187 |
-
app.all('/clear', async (req, res) => {
|
| 188 |
-
clear();
|
| 189 |
-
res.send('Cache Cleared');
|
| 190 |
-
});
|
| 191 |
-
|
| 192 |
-
app.all('/config', (req, res) => {
|
| 193 |
-
res.send({
|
| 194 |
-
transitions: JSON.parse(
|
| 195 |
-
readFileSync('public/assets/transitions.json').toString()
|
| 196 |
-
),
|
| 197 |
-
});
|
| 198 |
-
});
|
| 199 |
-
|
| 200 |
-
var currentRenderJobId = undefined;
|
| 201 |
-
let waiter = new Promise((resolve) => {
|
| 202 |
-
resolve();
|
| 203 |
-
});
|
| 204 |
-
app.all('/busy', async (req, res) => {
|
| 205 |
-
res.send({
|
| 206 |
-
job_id: currentRenderJobId,
|
| 207 |
-
busy: currentRenderJobId != undefined,
|
| 208 |
-
});
|
| 209 |
-
});
|
| 210 |
-
|
| 211 |
-
let acreds = process.env.audit_log_creds || {
|
| 212 |
-
"type": "service_account",
|
| 213 |
-
"project_id": "xx",
|
| 214 |
-
"private_key_id": "xx",
|
| 215 |
-
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEPe\n-----END PRIVATE KEY-----\n",
|
| 216 |
-
"client_email": "xx@semibit-admin.iam.gserviceaccount.com",
|
| 217 |
-
"client_id": "XX",
|
| 218 |
-
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 219 |
-
"token_uri": "https://oauth2.googleapis.com/token",
|
| 220 |
-
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 221 |
-
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/semibit-admin%40semibit-admin.iam.gserviceaccount.com",
|
| 222 |
-
"universe_domain": "googleapis.com"
|
| 223 |
-
}
|
| 224 |
-
if (acreds && !fs.existsSync('audit_log_creds.json')) {
|
| 225 |
-
console.log('Wrote audit_log_creds from env');
|
| 226 |
-
fs.writeFileSync('audit_log_creds.json', acreds)
|
| 227 |
-
}
|
| 228 |
-
let audit = Auditlog.get();
|
| 229 |
-
const uploader = new FileUploader('oracle', OracleStorage.semibit_media);
|
| 230 |
-
|
| 231 |
-
app.all('/render', async (req, res) => {
|
| 232 |
-
let event = audit.event('render');
|
| 233 |
-
console.log('waiting to process render request', req.originalUrl);
|
| 234 |
-
await waiter;
|
| 235 |
-
console.log('initializing render', req.originalUrl);
|
| 236 |
-
let resolver = () => {
|
| 237 |
-
currentRenderJobId = undefined;
|
| 238 |
-
};
|
| 239 |
-
let timeout = setTimeout(() => {
|
| 240 |
-
Utils.log(req, 'Renderer freed after timeout');
|
| 241 |
-
resolver();
|
| 242 |
-
}, 1 * 60 * 60 * 1000);
|
| 243 |
-
waiter = new Promise((resolve) => {
|
| 244 |
-
resolver = () => {
|
| 245 |
-
clearTimeout(timeout);
|
| 246 |
-
currentRenderJobId = undefined;
|
| 247 |
-
resolve();
|
| 248 |
-
};
|
| 249 |
-
});
|
| 250 |
-
try {
|
| 251 |
-
clear();
|
| 252 |
-
const perf = new PerformanceRecorder();
|
| 253 |
-
perf.reset();
|
| 254 |
-
let bundleUrl = req.query.fileUrl;
|
| 255 |
-
let skipRender = req.query.skipRender;
|
| 256 |
-
let fileName = decodeURIComponent(Utils.getFileName(bundleUrl));
|
| 257 |
-
let filePath = req.query.filePath || join(__dirname, `public/${fileName}`);
|
| 258 |
-
if (bundleUrl) {
|
| 259 |
-
console.log('Downloading remote asset bundle from', bundleUrl);
|
| 260 |
-
try {
|
| 261 |
-
await Utils.downloadFile(bundleUrl, filePath, true);
|
| 262 |
-
} catch (e) {
|
| 263 |
-
resolver();
|
| 264 |
-
return res.status(500).send({
|
| 265 |
-
status: 'FAILED',
|
| 266 |
-
message: 'Unable to download bundle.' + e.message,
|
| 267 |
-
});
|
| 268 |
-
}
|
| 269 |
-
console.log('Downloaded remote asset bundle', 'to', filePath);
|
| 270 |
-
}
|
| 271 |
-
if (existsSync(filePath)) {
|
| 272 |
-
await extract(filePath);
|
| 273 |
-
let manuFile = join(__dirname, `public/original_manuscript.json`);
|
| 274 |
-
if (!existsSync) {
|
| 275 |
-
resolver();
|
| 276 |
-
res.status(400);
|
| 277 |
-
return res.send({
|
| 278 |
-
message:
|
| 279 |
-
'The asset bundle dosent contain a original_manuscript.json at root',
|
| 280 |
-
});
|
| 281 |
-
}
|
| 282 |
-
let manuObj;
|
| 283 |
-
let manuStr;
|
| 284 |
-
try {
|
| 285 |
-
const manuData = await new Promise((resolve, reject) => {
|
| 286 |
-
fs.readFile(manuFile, 'utf-8', function (err, data) {
|
| 287 |
-
if (err) return reject(err);
|
| 288 |
-
resolve(data);
|
| 289 |
-
});
|
| 290 |
-
});
|
| 291 |
-
|
| 292 |
-
manuStr = manuData.toString();
|
| 293 |
-
manuObj = JSON.parse(manuStr);
|
| 294 |
-
} catch (e) {
|
| 295 |
-
resolver();
|
| 296 |
-
console.log('error reading bundle');
|
| 297 |
-
console.log(e);
|
| 298 |
-
return res.status(500).send(manuStr);
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
currentRenderJobId = manuObj.id;
|
| 302 |
-
let jobID = manuObj.id;
|
| 303 |
-
modifyFiles(manuObj, manuFile);
|
| 304 |
-
console.log('Callback', manuObj?.meta.callback_url);
|
| 305 |
-
if (skipRender != '1') {
|
| 306 |
-
Utils.clearFolder(join(__dirname, 'out'));
|
| 307 |
-
res.writeHead(200, jobID, {
|
| 308 |
-
job_id: jobID,
|
| 309 |
-
'content-type': 'application/json',
|
| 310 |
-
});
|
| 311 |
-
let cb = async (result, cachedUrl) => {
|
| 312 |
-
event.commit(result != 0 ? 'FAILED' : 'COMPLETED');
|
| 313 |
-
if (result != 0) {
|
| 314 |
-
resolver();
|
| 315 |
-
res.write(
|
| 316 |
-
JSON.stringify({
|
| 317 |
-
status: 'FAILED',
|
| 318 |
-
job_id: jobID,
|
| 319 |
-
url: undefined,
|
| 320 |
-
time_taken: perf.elapsedString(),
|
| 321 |
-
})
|
| 322 |
-
);
|
| 323 |
-
res.end();
|
| 324 |
-
return;
|
| 325 |
-
}
|
| 326 |
-
perf.end();
|
| 327 |
-
let uploadResult;
|
| 328 |
-
console.log('Took', perf.elapsedString(), 'to render');
|
| 329 |
-
if (cachedUrl) {
|
| 330 |
-
uploadResult = {
|
| 331 |
-
url: cachedUrl,
|
| 332 |
-
};
|
| 333 |
-
} else {
|
| 334 |
-
let outFile = await generateOutputBundle(jobID);
|
| 335 |
-
uploadResult = await uploader.upload(outFile);
|
| 336 |
-
}
|
| 337 |
-
sendToObserver(jobID, uploadResult.url);
|
| 338 |
-
clear();
|
| 339 |
-
res.write(
|
| 340 |
-
JSON.stringify({
|
| 341 |
-
status: result,
|
| 342 |
-
job_id: jobID,
|
| 343 |
-
url: uploadResult.url,
|
| 344 |
-
time_taken: perf.elapsedString(),
|
| 345 |
-
})
|
| 346 |
-
);
|
| 347 |
-
res.end();
|
| 348 |
-
resolver();
|
| 349 |
-
};
|
| 350 |
-
if (req.query.async == 1) {
|
| 351 |
-
cb = async (result, cachedUrl) => {
|
| 352 |
-
event.commit(result != 0 ? 'FAILED' : 'COMPLETED');
|
| 353 |
-
if (result != 0) {
|
| 354 |
-
notify(
|
| 355 |
-
jobID,
|
| 356 |
-
manuObj,
|
| 357 |
-
undefined,
|
| 358 |
-
bundleUrl,
|
| 359 |
-
'FAILED',
|
| 360 |
-
perf.elapsedString()
|
| 361 |
-
);
|
| 362 |
-
resolver();
|
| 363 |
-
return;
|
| 364 |
-
}
|
| 365 |
-
console.log('Took', perf.elapsedString(), 'to render');
|
| 366 |
-
let uploadResult;
|
| 367 |
-
if (cachedUrl) {
|
| 368 |
-
uploadResult = {
|
| 369 |
-
url: cachedUrl,
|
| 370 |
-
};
|
| 371 |
-
} else {
|
| 372 |
-
let outFile = await generateOutputBundle(jobID);
|
| 373 |
-
uploadResult = await uploader.upload(outFile);
|
| 374 |
-
}
|
| 375 |
-
sendToObserver(jobID, uploadResult.url);
|
| 376 |
-
await notify(
|
| 377 |
-
jobID,
|
| 378 |
-
manuObj,
|
| 379 |
-
uploadResult.url,
|
| 380 |
-
bundleUrl,
|
| 381 |
-
'COMPLETED',
|
| 382 |
-
perf.elapsedString()
|
| 383 |
-
);
|
| 384 |
-
resolver();
|
| 385 |
-
};
|
| 386 |
-
res.write(
|
| 387 |
-
JSON.stringify({
|
| 388 |
-
status: 'SCHEDULED',
|
| 389 |
-
message: 'render started',
|
| 390 |
-
job_id: jobID,
|
| 391 |
-
callback_url: manuObj.meta.callback_url,
|
| 392 |
-
})
|
| 393 |
-
);
|
| 394 |
-
res.end();
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
// check if already processed
|
| 398 |
-
let globalDisableCache = await Utils.getKeyAsync('disable_cache');
|
| 399 |
-
if (req.query.disable_cache != '1' && globalDisableCache != '1') {
|
| 400 |
-
let oZipName = jobID + '.zip';
|
| 401 |
-
let uploadedFileUrl = uploader.targetUploadUrl(oZipName);
|
| 402 |
-
let opZip = path.join(__dirname, 'out/' + oZipName);
|
| 403 |
-
if (fs.existsSync(opZip)) {
|
| 404 |
-
console.log('rendered output already exists: ' + opZip);
|
| 405 |
-
cb(0);
|
| 406 |
-
resolver();
|
| 407 |
-
return;
|
| 408 |
-
}
|
| 409 |
-
|
| 410 |
-
try {
|
| 411 |
-
let resp = await axios.head(uploadedFileUrl);
|
| 412 |
-
if (resp.status == 200) {
|
| 413 |
-
console.log(
|
| 414 |
-
'rendered output already uploaded: ' + uploadedFileUrl
|
| 415 |
-
);
|
| 416 |
-
return cb(0, uploadedFileUrl);
|
| 417 |
-
}
|
| 418 |
-
} catch (e) {
|
| 419 |
-
console.log('error checking for upload cache at ', uploadedFileUrl);
|
| 420 |
-
}
|
| 421 |
-
} else {
|
| 422 |
-
Utils.log(req, `Render Caching disabled. Performing full render`);
|
| 423 |
-
}
|
| 424 |
-
|
| 425 |
-
let npmScript = req.query.media_type == 'image' ? 'render-still' : 'render-build';
|
| 426 |
-
if (manuObj.meta.posterImage) {
|
| 427 |
-
doRenderPoster(jobID, () => {
|
| 428 |
-
doRender(jobID, manuObj.meta.renderComposition, cb, req.query.media_type);
|
| 429 |
-
});
|
| 430 |
-
} else {
|
| 431 |
-
doRender(jobID, manuObj.meta.renderComposition, cb, req.query.media_type);
|
| 432 |
-
}
|
| 433 |
-
} else {
|
| 434 |
-
notify(
|
| 435 |
-
jobID,
|
| 436 |
-
manuObj,
|
| 437 |
-
undefined,
|
| 438 |
-
bundleUrl,
|
| 439 |
-
'SKIPPED',
|
| 440 |
-
perf.elapsedString()
|
| 441 |
-
);
|
| 442 |
-
|
| 443 |
-
console.log('Skipping render');
|
| 444 |
-
resolver();
|
| 445 |
-
res.send({
|
| 446 |
-
status: 'COMPLETED',
|
| 447 |
-
message: 'render skipped',
|
| 448 |
-
job_id: jobID,
|
| 449 |
-
});
|
| 450 |
-
}
|
| 451 |
-
} else {
|
| 452 |
-
resolver();
|
| 453 |
-
res.status(400);
|
| 454 |
-
return res.send({message: 'Unable to locate asset bundle'});
|
| 455 |
-
}
|
| 456 |
-
} catch (e) {
|
| 457 |
-
resolver();
|
| 458 |
-
res.status(500).send({
|
| 459 |
-
message: e.message,
|
| 460 |
-
});
|
| 461 |
-
}
|
| 462 |
-
});
|
| 463 |
|
| 464 |
app.use('/public', express.static(join(__dirname, 'public')));
|
| 465 |
app.use('/out', express.static(join(__dirname, 'out')));
|
| 466 |
-
|
| 467 |
-
const uploadDir = join(__dirname, 'uploads');
|
| 468 |
-
const oracleAPI = OracleStorage.semibit_media.url;
|
| 469 |
-
|
| 470 |
-
if (!existsSync(uploadDir)) {
|
| 471 |
-
try {
|
| 472 |
-
mkdirSync(uploadDir);
|
| 473 |
-
} catch (e) {
|
| 474 |
-
console.log('Error in making uploads dir', e.message);
|
| 475 |
-
}
|
| 476 |
-
}
|
| 477 |
-
|
| 478 |
-
app.post('/upload', async (req, res) => {
|
| 479 |
-
try {
|
| 480 |
-
const file = req.files.file;
|
| 481 |
-
const uploadUrl = oracleAPI + encodeURIComponent(file.name);
|
| 482 |
-
|
| 483 |
-
const readStream = createReadStream(file.path);
|
| 484 |
-
const writeStream = createWriteStream(join(uploadDir, file.name));
|
| 485 |
-
readStream.pipe(writeStream);
|
| 486 |
-
|
| 487 |
-
await new Promise((resolve) => {
|
| 488 |
-
writeStream.on('finish', resolve);
|
| 489 |
-
});
|
| 490 |
-
|
| 491 |
-
const fileData = readFileSync(join(uploadDir, file.name));
|
| 492 |
-
|
| 493 |
-
await axios.put(uploadUrl, fileData);
|
| 494 |
-
|
| 495 |
-
res.send({
|
| 496 |
-
message: 'File upload success',
|
| 497 |
-
size: file.length / (1024 * 1024) + 'mb',
|
| 498 |
-
url: uploadUrl,
|
| 499 |
-
});
|
| 500 |
-
} catch (error) {
|
| 501 |
-
console.error('Error uploading file:', error);
|
| 502 |
-
res.status(500).send({message: 'Error uploading file'});
|
| 503 |
-
}
|
| 504 |
-
});
|
| 505 |
-
|
| 506 |
-
function sendToObserver(jobId, data) {
|
| 507 |
-
if (app.observer) {
|
| 508 |
-
app.observer(jobId, {
|
| 509 |
-
jod_id: jobId,
|
| 510 |
-
message_type: 'info',
|
| 511 |
-
message: data,
|
| 512 |
-
});
|
| 513 |
-
}
|
| 514 |
-
}
|
| 515 |
-
|
| 516 |
-
var curVideoChildProc;
|
| 517 |
-
var curPosterChildProc;
|
| 518 |
-
|
| 519 |
-
app.use('/kill', (req, res) => {
|
| 520 |
-
try {
|
| 521 |
-
if (curPosterChildProc) {
|
| 522 |
-
kill(curPosterChildProc.pid, 'SIGKILL');
|
| 523 |
-
}
|
| 524 |
-
} catch (e) {
|
| 525 |
-
console.log('Error while killing poster proc', e.message);
|
| 526 |
-
}
|
| 527 |
-
|
| 528 |
-
try {
|
| 529 |
-
if (curVideoChildProc) {
|
| 530 |
-
kill(curVideoChildProc.pid, 'SIGKILL');
|
| 531 |
-
}
|
| 532 |
-
} catch (e) {
|
| 533 |
-
console.log('Error while killing video proc', e.message);
|
| 534 |
-
}
|
| 535 |
-
|
| 536 |
-
res.send({
|
| 537 |
-
message: 'killed',
|
| 538 |
-
});
|
| 539 |
-
});
|
| 540 |
-
|
| 541 |
-
function doRender(jobId, composition, statusCb, npmScript = 'render-build') {
|
| 542 |
-
npmScript = 'render-build';
|
| 543 |
-
const renderComposition = composition || 'SemibitComposition';
|
| 544 |
-
let cmd = `npm run ${npmScript} -- ${renderComposition} out/video.mp4`;
|
| 545 |
-
const childProcess = exec(cmd);
|
| 546 |
-
curVideoChildProc = childProcess;
|
| 547 |
-
console.log('Starting video render. ' + cmd);
|
| 548 |
-
let updateCounter = 0;
|
| 549 |
-
childProcess.stdout.on('data', (data) => {
|
| 550 |
-
sendToObserver(jobId, data);
|
| 551 |
-
if (!process.env.is_pm2) console.log(data.toString());
|
| 552 |
-
if (updateCounter++ % 100 == 0 || updateCounter < 5) {
|
| 553 |
-
console.log(data.split('\n')[0]);
|
| 554 |
-
}
|
| 555 |
-
});
|
| 556 |
-
|
| 557 |
-
childProcess.stderr.on('data', (data) => {
|
| 558 |
-
sendToObserver(jobId, data);
|
| 559 |
-
console.error(data.toString());
|
| 560 |
-
});
|
| 561 |
-
|
| 562 |
-
// Listen for the completion of the child process
|
| 563 |
-
childProcess.on('close', (code) => {
|
| 564 |
-
console.log('Render video finished');
|
| 565 |
-
sendToObserver(jobId, code === 0 ? 'completed' : 'failed');
|
| 566 |
-
if (statusCb) {
|
| 567 |
-
statusCb(code);
|
| 568 |
-
}
|
| 569 |
-
if (code === 0) {
|
| 570 |
-
console.log(`'${npmScript}' completed successfully.`);
|
| 571 |
-
} else {
|
| 572 |
-
console.error(`'${npmScript}' failed with code ${code}.`);
|
| 573 |
-
}
|
| 574 |
-
});
|
| 575 |
-
}
|
| 576 |
-
|
| 577 |
-
function doRenderPoster(jobId, statusCb) {
|
| 578 |
-
const npmScript = 'render-still';
|
| 579 |
-
const childProcess = exec(`npm run ${npmScript} -- out/poster.jpg`);
|
| 580 |
-
curPosterChildProc = childProcess;
|
| 581 |
-
console.log('Starting poster render');
|
| 582 |
-
childProcess.stdout.on('data', (data) => {
|
| 583 |
-
sendToObserver(jobId, data);
|
| 584 |
-
if (!process.env.is_pm2) console.log(data.toString());
|
| 585 |
-
});
|
| 586 |
-
|
| 587 |
-
childProcess.stderr.on('data', (data) => {
|
| 588 |
-
sendToObserver(jobId, data);
|
| 589 |
-
console.error(data.toString());
|
| 590 |
-
});
|
| 591 |
-
|
| 592 |
-
// Listen for the completion of the child process
|
| 593 |
-
childProcess.on('close', (code) => {
|
| 594 |
-
console.log('Render poster finished');
|
| 595 |
-
sendToObserver(jobId, code === 0 ? 'completed' : 'failed');
|
| 596 |
-
if (statusCb) {
|
| 597 |
-
statusCb(code);
|
| 598 |
-
}
|
| 599 |
-
if (code === 0) {
|
| 600 |
-
console.log(`'${npmScript}' completed successfully.`);
|
| 601 |
-
} else {
|
| 602 |
-
console.error(`'${npmScript}' failed with code ${code}.`);
|
| 603 |
-
}
|
| 604 |
-
});
|
| 605 |
-
}
|
| 606 |
-
|
| 607 |
-
let indexFile = join(__dirname, 'index.html');
|
| 608 |
-
|
| 609 |
-
// app.get('/execute', (req, res) => {
|
| 610 |
-
// const command = decodeURIComponent(req.query.cmd);
|
| 611 |
-
// console.log(command);
|
| 612 |
-
// exec(command, (error, stdout, stderr) => {
|
| 613 |
-
// if (error) {
|
| 614 |
-
// console.error(`Error executing command: ${error.message}`);
|
| 615 |
-
// res.status(500).json({error: error.message});
|
| 616 |
-
// return;
|
| 617 |
-
// }
|
| 618 |
-
// if (stderr) {
|
| 619 |
-
// console.error(`Command stderr: ${stderr}`);
|
| 620 |
-
// }
|
| 621 |
-
// console.log(stdout);
|
| 622 |
-
// res.send(stdout);
|
| 623 |
-
// });
|
| 624 |
-
// });
|
| 625 |
-
|
| 626 |
app.get('/', (req, res) => {
|
| 627 |
-
res.
|
| 628 |
});
|
| 629 |
|
| 630 |
-
|
| 631 |
-
indexFile,
|
| 632 |
-
readFileSync(indexFile).toString().replace('___ORACLE_API_BASE___', oracleAPI)
|
| 633 |
-
);
|
| 634 |
-
|
| 635 |
-
export default app;
|
| 636 |
-
|
| 637 |
-
// doRender()
|
| 638 |
-
|
| 639 |
-
if (process.env.MODIFY_FILES) {
|
| 640 |
-
let filePath = 'public/@export_bc064e55b5.zip';
|
| 641 |
-
extract(filePath).then(() => {
|
| 642 |
-
let manuFile = join(__dirname, 'public/original_manuscript.json');
|
| 643 |
-
let manuObj = JSON.parse(readFileSync(manuFile).toString());
|
| 644 |
-
modifyFiles(manuObj, manuFile);
|
| 645 |
-
console.log('Sequence Generated');
|
| 646 |
-
});
|
| 647 |
-
}
|
|
|
|
| 1 |
import express from 'express';
|
| 2 |
+
import { resolve as _resolve, join } from 'path';
|
| 3 |
+
import { fileURLToPath } from 'url';
|
| 4 |
+
import { dirname } from 'path';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import RenderRouter from './routes.js';
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
const __filename = fileURLToPath(import.meta.url);
|
| 8 |
const __dirname = dirname(__filename);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const app = express();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
app.use(RenderRouter);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
app.use('/public', express.static(join(__dirname, 'public')));
|
| 14 |
app.use('/out', express.static(join(__dirname, 'out')));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
app.get('/', (req, res) => {
|
| 16 |
+
res.send('Media Render Farm Server is running.');
|
| 17 |
});
|
| 18 |
|
| 19 |
+
export default app;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
routes.js
CHANGED
|
@@ -16,6 +16,7 @@ import bodyParser from 'body-parser';
|
|
| 16 |
import { existsSync } from 'fs';
|
| 17 |
import { applyPluginsPostrender, applyPluginsPrerender } from './server-plugins/apply.js';
|
| 18 |
import { startChildProcess } from './proxy-renderer.js';
|
|
|
|
| 19 |
|
| 20 |
const RenderRouter = Router();
|
| 21 |
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -161,8 +162,8 @@ RenderRouter.post('/api/render-sync', async (req, res) => {
|
|
| 161 |
});
|
| 162 |
}
|
| 163 |
|
| 164 |
-
// make sure only
|
| 165 |
-
// set headers
|
| 166 |
|
| 167 |
res.setHeader('X-Job-Id', jobId);
|
| 168 |
res.setTimeout(0);
|
|
@@ -183,18 +184,24 @@ RenderRouter.post('/api/render-sync', async (req, res) => {
|
|
| 183 |
console.log(`Job ${jobId} assets already exploded to public. Not downloading again.`)
|
| 184 |
}
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
let renderMethod = req.body.method || req.query.method || 'cli';
|
| 189 |
-
|
| 190 |
-
|
| 191 |
const originalManuscriptPath = path.join(__dirname, `public`, `original_manuscript.json`)
|
| 192 |
let originalManuscript = Utils.readFileToObject(originalManuscriptPath);
|
| 193 |
-
await applyPluginsPrerender(plugins, originalManuscript, originalManuscriptPath, jobId)
|
| 194 |
renderMethod = originalManuscript.meta?.generationConfig?.extras?.renderMethod || renderMethod
|
| 195 |
|
| 196 |
if (!skipRender) {
|
| 197 |
-
if (renderMethod == '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
await doRender(
|
| 199 |
jobId,
|
| 200 |
originalManuscript,
|
|
|
|
| 16 |
import { existsSync } from 'fs';
|
| 17 |
import { applyPluginsPostrender, applyPluginsPrerender } from './server-plugins/apply.js';
|
| 18 |
import { startChildProcess } from './proxy-renderer.js';
|
| 19 |
+
import { CaptionRenderer } from './utils/CaptionRender.js';
|
| 20 |
|
| 21 |
const RenderRouter = Router();
|
| 22 |
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
| 162 |
});
|
| 163 |
}
|
| 164 |
|
| 165 |
+
// make sure only 1 request is being processed at a time
|
| 166 |
+
// set headers appropriately to hint that timeout must be large
|
| 167 |
|
| 168 |
res.setHeader('X-Job-Id', jobId);
|
| 169 |
res.setTimeout(0);
|
|
|
|
| 184 |
console.log(`Job ${jobId} assets already exploded to public. Not downloading again.`)
|
| 185 |
}
|
| 186 |
|
|
|
|
|
|
|
| 187 |
let renderMethod = req.body.method || req.query.method || 'cli';
|
|
|
|
|
|
|
| 188 |
const originalManuscriptPath = path.join(__dirname, `public`, `original_manuscript.json`)
|
| 189 |
let originalManuscript = Utils.readFileToObject(originalManuscriptPath);
|
| 190 |
+
await applyPluginsPrerender([...plugins, { name: 'flatten-paths' }], originalManuscript, originalManuscriptPath, jobId)
|
| 191 |
renderMethod = originalManuscript.meta?.generationConfig?.extras?.renderMethod || renderMethod
|
| 192 |
|
| 193 |
if (!skipRender) {
|
| 194 |
+
if (renderMethod == 'caption') {
|
| 195 |
+
let renderer = new CaptionRenderer();
|
| 196 |
+
renderer.validateCaption(originalManuscript);
|
| 197 |
+
await renderer.doRender(jobId,
|
| 198 |
+
originalManuscript,
|
| 199 |
+
(log) => {
|
| 200 |
+
logs.push(log);
|
| 201 |
+
},
|
| 202 |
+
getNpmScript(req.query.media_type), undefined, undefined, controller);
|
| 203 |
+
}
|
| 204 |
+
else if (renderMethod == 'cli' || req.query.media_type == 'image') {
|
| 205 |
await doRender(
|
| 206 |
jobId,
|
| 207 |
originalManuscript,
|
server-plugins/apply.js
CHANGED
|
@@ -2,10 +2,14 @@ import _ from 'lodash'
|
|
| 2 |
import fs from 'fs'
|
| 3 |
import { SplitRenderPlugin } from './split-render.js';
|
| 4 |
import { FramesPlugin } from './frames.js';
|
|
|
|
|
|
|
| 5 |
|
| 6 |
const pluginRegistry = {
|
| 7 |
'split-media-render': SplitRenderPlugin,
|
| 8 |
-
'frames': FramesPlugin
|
|
|
|
|
|
|
| 9 |
};
|
| 10 |
|
| 11 |
export async function applyPluginsPrerender(plugins, originalManuscript, originalManuscriptPath, jobId) {
|
|
|
|
| 2 |
import fs from 'fs'
|
| 3 |
import { SplitRenderPlugin } from './split-render.js';
|
| 4 |
import { FramesPlugin } from './frames.js';
|
| 5 |
+
import { CaptionPlugin } from './generate-captions.js';
|
| 6 |
+
import { FlattenPathsPlugin } from './flatten-paths.js';
|
| 7 |
|
| 8 |
const pluginRegistry = {
|
| 9 |
'split-media-render': SplitRenderPlugin,
|
| 10 |
+
'frames': FramesPlugin,
|
| 11 |
+
'caption': CaptionPlugin,
|
| 12 |
+
'flatten-paths': FlattenPathsPlugin,
|
| 13 |
};
|
| 14 |
|
| 15 |
export async function applyPluginsPrerender(plugins, originalManuscript, originalManuscriptPath, jobId) {
|
server-plugins/flatten-paths.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from "path";
|
| 2 |
+
import { Plugin } from "./plugin.js";
|
| 3 |
+
|
| 4 |
+
export class FlattenPathsPlugin extends Plugin {
|
| 5 |
+
constructor(name, options) {
|
| 6 |
+
super(name, options);
|
| 7 |
+
}
|
| 8 |
+
async applyPrerender(originalManuscript, jobId) {
|
| 9 |
+
let transcript = originalManuscript.transcript
|
| 10 |
+
for (let item of transcript) {
|
| 11 |
+
if (item.mediaAbsPaths && item.mediaAbsPaths.length > 0) {
|
| 12 |
+
item.mediaAbsPaths = item.mediaAbsPaths.map((mediaObj) => {
|
| 13 |
+
let flattenedPath = path.join('public', path.basename(mediaObj.path));
|
| 14 |
+
return {
|
| 15 |
+
...mediaObj,
|
| 16 |
+
path: flattenedPath,
|
| 17 |
+
};
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
if (item.audioCaptionFile) {
|
| 21 |
+
item.audioCaptionFile = path.join('public', path.basename(item.audioCaptionFile));
|
| 22 |
+
}
|
| 23 |
+
if (item.audioFullPath) {
|
| 24 |
+
item.audioFullPath = path.join('public', path.basename(item.audioFullPath));
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
}
|
server-plugins/generate-captions.js
CHANGED
|
@@ -1,5 +1,164 @@
|
|
| 1 |
-
import {
|
|
|
|
|
|
|
| 2 |
import path from 'path';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
/**
|
| 5 |
* Format seconds to ASS timestamp format (H:MM:SS.cc)
|
|
@@ -88,174 +247,3 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
| 88 |
`;
|
| 89 |
}
|
| 90 |
|
| 91 |
-
/**
|
| 92 |
-
* Generate ASS subtitle file with word highlighting
|
| 93 |
-
* @param {Object} options
|
| 94 |
-
* @param {string} options.captionFilePath - Path to input JSON caption file
|
| 95 |
-
* @param {string} options.outputFilePath - Path to output ASS file
|
| 96 |
-
* @param {number} [options.tiltDegrees=8] - Tilt angle in degrees (alternates between +/-)
|
| 97 |
-
* @param {number} [options.translateY=200] - Distance from bottom in pixels
|
| 98 |
-
* @param {number} [options.widthPercent=80] - Width percentage for text centering (0-100)
|
| 99 |
-
* @param {string} [options.fontName='Impact'] - Font name
|
| 100 |
-
* @param {number} [options.fontSize=72] - Font size
|
| 101 |
-
* @param {number} [options.wordsPerGroup=4] - Number of words per caption group
|
| 102 |
-
* @param {number} [options.videoWidth=1920] - Video width for positioning
|
| 103 |
-
* @param {number} [options.videoHeight=1080] - Video height for positioning
|
| 104 |
-
* @returns {Promise<string>} Path to generated ASS file
|
| 105 |
-
*/
|
| 106 |
-
async function generateCaptions(options) {
|
| 107 |
-
const {
|
| 108 |
-
captionFilePath,
|
| 109 |
-
outputFilePath,
|
| 110 |
-
tiltDegrees = 8,
|
| 111 |
-
translateY = 200,
|
| 112 |
-
widthPercent = 80,
|
| 113 |
-
fontName = 'Impact',
|
| 114 |
-
fontSize = 72,
|
| 115 |
-
wordsPerGroup = 4,
|
| 116 |
-
videoWidth = 1920,
|
| 117 |
-
videoHeight = 1080
|
| 118 |
-
} = options;
|
| 119 |
-
|
| 120 |
-
// Read and parse JSON file
|
| 121 |
-
const jsonData = JSON.parse(readFileSync(captionFilePath, 'utf-8'));
|
| 122 |
-
const transcript = jsonData.transcript || '';
|
| 123 |
-
let words = jsonData.words || [];
|
| 124 |
-
|
| 125 |
-
if (words.length === 0) {
|
| 126 |
-
throw new Error('No words found in caption file');
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
// Assign sentence indices to words
|
| 130 |
-
words = assignSentenceToWords(words, transcript);
|
| 131 |
-
|
| 132 |
-
// Calculate margins for centering within width percentage
|
| 133 |
-
const totalMargin = videoWidth * (1 - widthPercent / 100);
|
| 134 |
-
const sideMargin = Math.floor(totalMargin / 2);
|
| 135 |
-
|
| 136 |
-
// Create output stream
|
| 137 |
-
const output = createWriteStream(outputFilePath);
|
| 138 |
-
|
| 139 |
-
// Write header with calculated margins
|
| 140 |
-
output.write(createASSHeader(videoWidth, videoHeight, fontName, fontSize, translateY, sideMargin, sideMargin));
|
| 141 |
-
|
| 142 |
-
// Process words in groups respecting sentence boundaries
|
| 143 |
-
let i = 0;
|
| 144 |
-
let groupIdx = 0;
|
| 145 |
-
|
| 146 |
-
while (i < words.length) {
|
| 147 |
-
const currentSentence = words[i].sentence_idx || 0;
|
| 148 |
-
|
| 149 |
-
// Collect words for this group (up to wordsPerGroup, same sentence only)
|
| 150 |
-
const wordGroup = [];
|
| 151 |
-
let j = i;
|
| 152 |
-
|
| 153 |
-
while (j < words.length && wordGroup.length < wordsPerGroup) {
|
| 154 |
-
if ((words[j].sentence_idx || 0) === currentSentence) {
|
| 155 |
-
wordGroup.push(words[j]);
|
| 156 |
-
j++;
|
| 157 |
-
} else {
|
| 158 |
-
break; // Stop at sentence boundary
|
| 159 |
-
}
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
if (wordGroup.length === 0) {
|
| 163 |
-
i++;
|
| 164 |
-
continue;
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
// Alternate tilt
|
| 168 |
-
const currentTilt = groupIdx % 2 === 0 ? tiltDegrees : -tiltDegrees;
|
| 169 |
-
const tiltTag = `{\\frz${currentTilt}}`;
|
| 170 |
-
|
| 171 |
-
// Calculate positioning for centering
|
| 172 |
-
const posTag = sideMargin > 0 ? `{\\an2\\pos(${videoWidth / 2},${videoHeight - translateY})}` : '';
|
| 173 |
-
|
| 174 |
-
// For each word in the group, create an event with highlighting
|
| 175 |
-
for (let wordIdx = 0; wordIdx < wordGroup.length; wordIdx++) {
|
| 176 |
-
const wordObj = wordGroup[wordIdx];
|
| 177 |
-
const wordStart = wordObj.start;
|
| 178 |
-
const wordEnd = wordObj.end;
|
| 179 |
-
|
| 180 |
-
// Build the caption text with highlighting
|
| 181 |
-
const captionParts = wordGroup.map((w, idx) => {
|
| 182 |
-
if (idx === wordIdx) {
|
| 183 |
-
// Current word - highlighted in green
|
| 184 |
-
return `{\\c&H00FF00&}${w.word}{\\c&HFFFFFF&}`;
|
| 185 |
-
} else {
|
| 186 |
-
// Other words - white
|
| 187 |
-
return w.word;
|
| 188 |
-
}
|
| 189 |
-
});
|
| 190 |
-
|
| 191 |
-
const captionText = tiltTag + posTag + captionParts.join(' ');
|
| 192 |
-
|
| 193 |
-
// Write dialogue line
|
| 194 |
-
output.write(`Dialogue: 0,${formatTimestampASS(wordStart)},${formatTimestampASS(wordEnd)},Default,,0,0,0,,${captionText}\n`);
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
i = j;
|
| 198 |
-
groupIdx++;
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
output.end();
|
| 202 |
-
|
| 203 |
-
return new Promise((resolve, reject) => {
|
| 204 |
-
output.on('finish', () => {
|
| 205 |
-
console.log(`Created ${outputFilePath} with word-by-word highlighting and tilted groups`);
|
| 206 |
-
resolve(outputFilePath);
|
| 207 |
-
});
|
| 208 |
-
output.on('error', reject);
|
| 209 |
-
});
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
// Example usage
|
| 213 |
-
// Check if this is the main module in ESM
|
| 214 |
-
import { fileURLToPath } from 'url';
|
| 215 |
-
import { dirname } from 'path';
|
| 216 |
-
|
| 217 |
-
const __filename = fileURLToPath(import.meta.url);
|
| 218 |
-
const isMainModule = process.argv[1] === __filename;
|
| 219 |
-
|
| 220 |
-
if (isMainModule) {
|
| 221 |
-
const args = process.argv.slice(2);
|
| 222 |
-
|
| 223 |
-
if (args.length < 2) {
|
| 224 |
-
console.log('Usage: node generateCaptions.js <input.json> <output.ass> [options]');
|
| 225 |
-
console.log('\nOptions:');
|
| 226 |
-
console.log(' --tilt <degrees> Tilt angle (default: 8)');
|
| 227 |
-
console.log(' --translateY <pixels> Distance from bottom (default: 200)');
|
| 228 |
-
console.log(' --width <percent> Width percentage 0-100 (default: 80)');
|
| 229 |
-
console.log(' --font <name> Font name (default: Impact)');
|
| 230 |
-
console.log(' --fontSize <size> Font size (default: 72)');
|
| 231 |
-
console.log(' --wordsPerGroup <num> Words per caption group (default: 4)');
|
| 232 |
-
console.log('\nExample:');
|
| 233 |
-
console.log(' node generateCaptions.js input.json output.ass --tilt 10 --width 90');
|
| 234 |
-
process.exit(1);
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
const captionFilePath = args[0];
|
| 238 |
-
const outputFilePath = args[1];
|
| 239 |
-
|
| 240 |
-
// Parse optional arguments
|
| 241 |
-
const options = {
|
| 242 |
-
captionFilePath,
|
| 243 |
-
outputFilePath
|
| 244 |
-
};
|
| 245 |
-
|
| 246 |
-
for (let i = 2; i < args.length; i += 2) {
|
| 247 |
-
const key = args[i].replace('--', '');
|
| 248 |
-
const value = args[i + 1];
|
| 249 |
-
|
| 250 |
-
if (key === 'tilt') options.tiltDegrees = parseFloat(value);
|
| 251 |
-
else if (key === 'translateY') options.translateY = parseInt(value);
|
| 252 |
-
else if (key === 'width') options.widthPercent = parseFloat(value);
|
| 253 |
-
else if (key === 'font') options.fontName = value;
|
| 254 |
-
else if (key === 'fontSize') options.fontSize = parseInt(value);
|
| 255 |
-
else if (key === 'wordsPerGroup') options.wordsPerGroup = parseInt(value);
|
| 256 |
-
}
|
| 257 |
-
|
| 258 |
-
generateCaptions(options).catch(console.error);
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
export default { generateCaptions };
|
|
|
|
| 1 |
+
import { fileURLToPath } from 'url';
|
| 2 |
+
import { readFileSync, createWriteStream, existsSync } from 'fs';
|
| 3 |
+
import { Plugin } from './plugin.js';
|
| 4 |
import path from 'path';
|
| 5 |
+
import fs from 'fs';
|
| 6 |
+
|
| 7 |
+
export class CaptionPlugin extends Plugin {
|
| 8 |
+
constructor(name, options) {
|
| 9 |
+
super(name, options);
|
| 10 |
+
}
|
| 11 |
+
async applyPrerender(originalManuscript, jobId) {
|
| 12 |
+
let transcript = originalManuscript.transcript
|
| 13 |
+
for (let item of transcript) {
|
| 14 |
+
let audioCaptionFileFileName = path.basename(item.audioCaptionFile)
|
| 15 |
+
if (path.extname(audioCaptionFileFileName) == '.ass') {
|
| 16 |
+
continue
|
| 17 |
+
}
|
| 18 |
+
let originalCaption = path.join(process.cwd(), item.audioCaptionFile)
|
| 19 |
+
if (!fs.existsSync(originalCaption)) {
|
| 20 |
+
originalCaption = path.join(process.cwd(), 'public', audioCaptionFileFileName)
|
| 21 |
+
}
|
| 22 |
+
if (!originalCaption) continue;
|
| 23 |
+
let outputCaptionFile = originalCaption.replace('.json', '.ass')
|
| 24 |
+
await this.generateCaptions(
|
| 25 |
+
{
|
| 26 |
+
...this.options,
|
| 27 |
+
captionFilePath: originalCaption,
|
| 28 |
+
outputFilePath: outputCaptionFile,
|
| 29 |
+
}
|
| 30 |
+
)
|
| 31 |
+
item._audioCaptionFile = item.audioCaptionFile
|
| 32 |
+
item.audioCaptionFile = item.audioCaptionFile.replace('.json', '.ass')
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
async applyPostrender(originalManuscript, jobId, outFiles) {
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Generate ASS subtitle file with word highlighting
|
| 42 |
+
* @param {Object} options
|
| 43 |
+
* @param {string} options.captionFilePath - Path to input JSON caption file
|
| 44 |
+
* @param {string} options.outputFilePath - Path to output ASS file
|
| 45 |
+
* @param {number} [options.tiltDegrees=8] - Tilt angle in degrees (alternates between +/-)
|
| 46 |
+
* @param {number} [options.translateY=200] - Distance from bottom in pixels
|
| 47 |
+
* @param {number} [options.widthPercent=80] - Width percentage for text centering (0-100)
|
| 48 |
+
* @param {string} [options.fontName='Impact'] - Font name
|
| 49 |
+
* @param {number} [options.fontSize=72] - Font size
|
| 50 |
+
* @param {number} [options.wordsPerGroup=4] - Number of words per caption group
|
| 51 |
+
* @param {number} [options.videoWidth=1920] - Video width for positioning
|
| 52 |
+
* @param {number} [options.videoHeight=1080] - Video height for positioning
|
| 53 |
+
* @returns {Promise<string>} Path to generated ASS file
|
| 54 |
+
*/
|
| 55 |
+
async generateCaptions(options) {
|
| 56 |
+
const {
|
| 57 |
+
captionFilePath,
|
| 58 |
+
outputFilePath,
|
| 59 |
+
tiltDegrees = 8,
|
| 60 |
+
translateY = 200,
|
| 61 |
+
widthPercent = 80,
|
| 62 |
+
fontName = 'Impact',
|
| 63 |
+
fontSize = 72,
|
| 64 |
+
wordsPerGroup = 4,
|
| 65 |
+
videoWidth = 1920,
|
| 66 |
+
videoHeight = 1080
|
| 67 |
+
} = options;
|
| 68 |
+
|
| 69 |
+
// Read and parse JSON file
|
| 70 |
+
const jsonData = JSON.parse(readFileSync(captionFilePath, 'utf-8'));
|
| 71 |
+
const transcript = jsonData.transcript || '';
|
| 72 |
+
let words = jsonData.words || [];
|
| 73 |
+
|
| 74 |
+
if (words.length === 0) {
|
| 75 |
+
throw new Error('No words found in caption file');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Assign sentence indices to words
|
| 79 |
+
words = assignSentenceToWords(words, transcript);
|
| 80 |
+
|
| 81 |
+
// Calculate margins for centering within width percentage
|
| 82 |
+
const totalMargin = videoWidth * (1 - widthPercent / 100);
|
| 83 |
+
const sideMargin = Math.floor(totalMargin / 2);
|
| 84 |
+
|
| 85 |
+
// Create output stream
|
| 86 |
+
const output = createWriteStream(outputFilePath);
|
| 87 |
+
|
| 88 |
+
// Write header with calculated margins
|
| 89 |
+
output.write(createASSHeader(videoWidth, videoHeight, fontName, fontSize, translateY, sideMargin, sideMargin));
|
| 90 |
+
|
| 91 |
+
// Process words in groups respecting sentence boundaries
|
| 92 |
+
let i = 0;
|
| 93 |
+
let groupIdx = 0;
|
| 94 |
+
|
| 95 |
+
while (i < words.length) {
|
| 96 |
+
const currentSentence = words[i].sentence_idx || 0;
|
| 97 |
+
|
| 98 |
+
// Collect words for this group (up to wordsPerGroup, same sentence only)
|
| 99 |
+
const wordGroup = [];
|
| 100 |
+
let j = i;
|
| 101 |
+
|
| 102 |
+
while (j < words.length && wordGroup.length < wordsPerGroup) {
|
| 103 |
+
if ((words[j].sentence_idx || 0) === currentSentence) {
|
| 104 |
+
wordGroup.push(words[j]);
|
| 105 |
+
j++;
|
| 106 |
+
} else {
|
| 107 |
+
break; // Stop at sentence boundary
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (wordGroup.length === 0) {
|
| 112 |
+
i++;
|
| 113 |
+
continue;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Alternate tilt
|
| 117 |
+
const currentTilt = groupIdx % 2 === 0 ? tiltDegrees : -tiltDegrees;
|
| 118 |
+
const tiltTag = `{\\frz${currentTilt}}`;
|
| 119 |
+
|
| 120 |
+
// Calculate positioning for centering
|
| 121 |
+
const posTag = sideMargin > 0 ? `{\\an2\\pos(${videoWidth / 2},${videoHeight - translateY})}` : '';
|
| 122 |
+
|
| 123 |
+
// For each word in the group, create an event with highlighting
|
| 124 |
+
for (let wordIdx = 0; wordIdx < wordGroup.length; wordIdx++) {
|
| 125 |
+
const wordObj = wordGroup[wordIdx];
|
| 126 |
+
const wordStart = wordObj.start;
|
| 127 |
+
const wordEnd = wordObj.end;
|
| 128 |
+
|
| 129 |
+
// Build the caption text with highlighting
|
| 130 |
+
const captionParts = wordGroup.map((w, idx) => {
|
| 131 |
+
if (idx === wordIdx) {
|
| 132 |
+
// Current word - highlighted in green
|
| 133 |
+
return `{\\c&H00FF00&}${w.word}{\\c&HFFFFFF&}`;
|
| 134 |
+
} else {
|
| 135 |
+
// Other words - white
|
| 136 |
+
return w.word;
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
const captionText = tiltTag + posTag + captionParts.join(' ');
|
| 141 |
+
|
| 142 |
+
// Write dialogue line
|
| 143 |
+
output.write(`Dialogue: 0,${formatTimestampASS(wordStart)},${formatTimestampASS(wordEnd)},Default,,0,0,0,,${captionText}\n`);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
i = j;
|
| 147 |
+
groupIdx++;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
output.end();
|
| 151 |
+
|
| 152 |
+
return new Promise((resolve, reject) => {
|
| 153 |
+
output.on('finish', () => {
|
| 154 |
+
this.log(`Generated ${path.basename(outputFilePath)} captions`);
|
| 155 |
+
resolve(outputFilePath);
|
| 156 |
+
});
|
| 157 |
+
output.on('error', reject);
|
| 158 |
+
});
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
|
| 163 |
/**
|
| 164 |
* Format seconds to ASS timestamp format (H:MM:SS.cc)
|
|
|
|
| 247 |
`;
|
| 248 |
}
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server-plugins/plugin.js
CHANGED
|
@@ -11,5 +11,9 @@ export class Plugin {
|
|
| 11 |
async applyPostrender(originalManuscript, jobId, outFiles) {
|
| 12 |
console.log(`Applying post-render plugin: ${this.name} with options:`, this.options);
|
| 13 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
|
|
|
| 11 |
async applyPostrender(originalManuscript, jobId, outFiles) {
|
| 12 |
console.log(`Applying post-render plugin: ${this.name} with options:`, this.options);
|
| 13 |
}
|
| 14 |
+
|
| 15 |
+
log(...args) {
|
| 16 |
+
console.log(`[Plugin: ${this.name}]`, ...args);
|
| 17 |
+
}
|
| 18 |
}
|
| 19 |
|
utils/CaptionRender.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { spawn } from 'child_process';
|
| 2 |
+
import os from 'os';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
import fs from 'fs';
|
| 5 |
+
|
| 6 |
+
var ffmpegLocation = os.platform() === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
|
| 7 |
+
|
| 8 |
+
export class CaptionRenderer {
|
| 9 |
+
validateCaption(originalManuscript) {
|
| 10 |
+
let captionFiles = originalManuscript.transcript.map(item => item.audioCaptionFile);
|
| 11 |
+
// make sure the caption files are in ass format and exist
|
| 12 |
+
for (let captionFile of captionFiles) {
|
| 13 |
+
if (!captionFile || !captionFile.endsWith('.ass')) {
|
| 14 |
+
throw new Error('Invalid caption file format. Expected .ass files for item ' + (captionFiles.indexOf(captionFile) + 1) + '. Did you forget to use `caption` plugin?');
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
return true;
|
| 18 |
+
}
|
| 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 chunkFiles = [];
|
| 27 |
+
|
| 28 |
+
// Process each transcript section
|
| 29 |
+
for (let i = 0; i < originalManuscript.transcript.length; i++) {
|
| 30 |
+
const section = originalManuscript.transcript[i];
|
| 31 |
+
const audioDuration = section.durationInSeconds;
|
| 32 |
+
const audioPath = section.audioFullPath;
|
| 33 |
+
const captionFile = section.audioCaptionFile;
|
| 34 |
+
const mediaFiles = section.mediaAbsPaths || [];
|
| 35 |
+
|
| 36 |
+
const chunkOutput = path.join(outDir, `chunk_${i}.mp4`);
|
| 37 |
+
|
| 38 |
+
if (mediaFiles.length === 0) {
|
| 39 |
+
console.log(`Skipping section ${i}: no media files`);
|
| 40 |
+
continue;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// Build filter_complex for this section
|
| 44 |
+
let filterComplex = '';
|
| 45 |
+
let inputs = [];
|
| 46 |
+
|
| 47 |
+
// Add all media files as inputs
|
| 48 |
+
for (let j = 0; j < mediaFiles.length; j++) {
|
| 49 |
+
inputs.push('-i', mediaFiles[j].path);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Add audio input
|
| 53 |
+
inputs.push('-i', audioPath);
|
| 54 |
+
const audioIndex = mediaFiles.length;
|
| 55 |
+
|
| 56 |
+
// Trim and concatenate video clips to match audio duration
|
| 57 |
+
let videoFilters = [];
|
| 58 |
+
let totalVideoDuration = 0;
|
| 59 |
+
|
| 60 |
+
for (let j = 0; j < mediaFiles.length; j++) {
|
| 61 |
+
const clipDuration = Math.min(mediaFiles[j].durationSec, audioDuration - totalVideoDuration);
|
| 62 |
+
if (clipDuration <= 0) break;
|
| 63 |
+
|
| 64 |
+
videoFilters.push(`[${j}:v]trim=duration=${clipDuration},setpts=PTS-STARTPTS[v${j}]`);
|
| 65 |
+
totalVideoDuration += clipDuration;
|
| 66 |
+
|
| 67 |
+
if (totalVideoDuration >= audioDuration) break;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Concatenate trimmed videos
|
| 71 |
+
const videoCount = videoFilters.length;
|
| 72 |
+
filterComplex = videoFilters.join(';') + ';';
|
| 73 |
+
filterComplex += videoFilters.map((_, idx) => `[v${idx}]`).join('') + `concat=n=${videoCount}:v=1:a=0[vconcat]`;
|
| 74 |
+
|
| 75 |
+
// Apply subtitles
|
| 76 |
+
filterComplex += `;[vconcat]subtitles=${captionFile.replace(/\\/g, '/')}[vout]`;
|
| 77 |
+
|
| 78 |
+
// Execute ffmpeg for this chunk
|
| 79 |
+
await this.runFFmpeg([
|
| 80 |
+
...inputs,
|
| 81 |
+
'-filter_complex', filterComplex,
|
| 82 |
+
'-map', '[vout]',
|
| 83 |
+
'-map', `${audioIndex}:a`,
|
| 84 |
+
'-t', audioDuration.toString(),
|
| 85 |
+
'-c:v', 'libx264',
|
| 86 |
+
'-c:a', 'aac',
|
| 87 |
+
'-y',
|
| 88 |
+
chunkOutput
|
| 89 |
+
], controller, onLog);
|
| 90 |
+
|
| 91 |
+
chunkFiles.push(chunkOutput);
|
| 92 |
+
console.log(`Completed chunk ${i}: ${chunkOutput}`);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Concatenate all chunks
|
| 96 |
+
const outFile = path.join(outDir, `${jobId}_final.mp4`);
|
| 97 |
+
const concatListPath = path.join(outDir, 'concat_list.txt');
|
| 98 |
+
fs.writeFileSync(concatListPath, chunkFiles.map(f => `file '${path.basename(f)}'`).join('\n'));
|
| 99 |
+
|
| 100 |
+
await this.runFFmpeg([
|
| 101 |
+
'-f', 'concat',
|
| 102 |
+
'-safe', '0',
|
| 103 |
+
'-i', concatListPath,
|
| 104 |
+
'-c', 'copy',
|
| 105 |
+
'-y',
|
| 106 |
+
outFile
|
| 107 |
+
], controller, onLog);
|
| 108 |
+
|
| 109 |
+
// Clean up intermediate files
|
| 110 |
+
console.log('Cleaning up intermediate files...');
|
| 111 |
+
for (const chunkFile of chunkFiles) {
|
| 112 |
+
try {
|
| 113 |
+
if (fs.existsSync(chunkFile)) {
|
| 114 |
+
fs.unlinkSync(chunkFile);
|
| 115 |
+
console.log(`Deleted: ${chunkFile}`);
|
| 116 |
+
}
|
| 117 |
+
} catch (err) {
|
| 118 |
+
console.error(`Failed to delete ${chunkFile}:`, err);
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
try {
|
| 123 |
+
if (fs.existsSync(concatListPath)) {
|
| 124 |
+
fs.unlinkSync(concatListPath);
|
| 125 |
+
console.log(`Deleted: ${concatListPath}`);
|
| 126 |
+
}
|
| 127 |
+
} catch (err) {
|
| 128 |
+
console.error(`Failed to delete ${concatListPath}:`, err);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
console.log(`Final output: ${outFile}`);
|
| 132 |
+
return outFile;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async runFFmpeg(args, controller, onLog) {
|
| 136 |
+
return new Promise((resolve, reject) => {
|
| 137 |
+
const ffmpegProcess = spawn(ffmpegLocation, [...args], {
|
| 138 |
+
detached: true,
|
| 139 |
+
cwd: process.cwd()
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
if (controller) {
|
| 143 |
+
controller.stop = () => {
|
| 144 |
+
console.log('Stopping ffmpeg process');
|
| 145 |
+
try {
|
| 146 |
+
process.kill(-ffmpegProcess.pid, 'SIGKILL');
|
| 147 |
+
} catch (e) {
|
| 148 |
+
console.error(`Failed to kill process group ${-ffmpegProcess.pid}`, e);
|
| 149 |
+
ffmpegProcess.kill('SIGKILL');
|
| 150 |
+
}
|
| 151 |
+
};
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
ffmpegProcess.stdout.on('data', (data) => {
|
| 155 |
+
const msg = data.toString();
|
| 156 |
+
console.log(msg);
|
| 157 |
+
if (onLog) onLog(msg);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
ffmpegProcess.stderr.on('data', (data) => {
|
| 161 |
+
const msg = data.toString();
|
| 162 |
+
console.error(msg);
|
| 163 |
+
if (onLog) onLog(msg);
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
ffmpegProcess.on('close', (code) => {
|
| 167 |
+
if (code === 0) {
|
| 168 |
+
resolve();
|
| 169 |
+
} else {
|
| 170 |
+
reject(new Error(`FFmpeg process exited with code ${code}`));
|
| 171 |
+
}
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
ffmpegProcess.on('error', (err) => {
|
| 175 |
+
reject(err);
|
| 176 |
+
});
|
| 177 |
+
});
|
| 178 |
+
}
|
| 179 |
+
}
|