shiveshnavin commited on
Commit
8dd5bba
·
1 Parent(s): 8ba367b

WIP renderer

Browse files
app.js CHANGED
@@ -1,647 +1,19 @@
1
  import express from 'express';
2
- import fs, {
3
- existsSync,
4
- readFileSync,
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.sendFile(indexFile);
628
  });
629
 
630
- writeFileSync(
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 i request is being processed at a time
165
- // set headers appoprately to hint that timeout must be large
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 == 'cli' || req.query.media_type == 'image') {
 
 
 
 
 
 
 
 
 
 
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 { readFileSync, createWriteStream } from 'fs';
 
 
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
+ }