orator-conversion 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Orator Conversion — Ultravisor Beacon Capability Provider
3
+ *
4
+ * Wraps the Conversion-Core module as a beacon capability provider,
5
+ * exposing image, PDF, video, and audio conversion actions to the
6
+ * Ultravisor mesh.
7
+ *
8
+ * Capability: MediaConversion
9
+ * Actions:
10
+ * ImageJpgToPng — JPEG to PNG
11
+ * ImagePngToJpg — PNG to JPEG
12
+ * ImageResize — Resize image with format/dimension/fit options
13
+ * ImageRotate — Rotate/flip/flop an image
14
+ * ImageConvert — Convert between image formats (any-to-any)
15
+ * PdfPageToPng — Render PDF page as PNG
16
+ * PdfPageToJpg — Render PDF page as JPEG
17
+ * PdfPageToPngSized — Render PDF page as PNG, constrained to a max dimension
18
+ * PdfPageToJpgSized — Render PDF page as JPEG, constrained to a max dimension
19
+ * MediaProbe — Extract media metadata via ffprobe
20
+ * VideoExtractFrame — Extract a single video frame at a timestamp
21
+ * VideoThumbnail — Generate a video thumbnail (convenience)
22
+ * AudioExtractSegment— Extract an audio time-range segment
23
+ * AudioWaveform — Extract waveform peak data
24
+ *
25
+ * Provider config:
26
+ * PdftkPath {string} — Path to pdftk binary (default: 'pdftk')
27
+ * PdftoppmPath {string} — Path to pdftoppm binary (default: 'pdftoppm')
28
+ * FfmpegPath {string} — Path to ffmpeg binary (default: 'ffmpeg')
29
+ * FfprobePath {string} — Path to ffprobe binary (default: 'ffprobe')
30
+ * MaxFileSizeBytes {number} — Max input file size (default: 100MB)
31
+ */
32
+
33
+ const libFS = require('fs');
34
+ const libPath = require('path');
35
+
36
+ const libBeaconCapabilityProvider = require('ultravisor-beacon/source/Ultravisor-Beacon-CapabilityProvider.cjs');
37
+
38
+ const libConversionCore = require('./Conversion-Core.js');
39
+
40
+ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
41
+ {
42
+ constructor(pProviderConfig)
43
+ {
44
+ super(pProviderConfig);
45
+
46
+ this.Name = 'OratorConversion';
47
+ this.Capability = 'MediaConversion';
48
+
49
+ this._Core = new libConversionCore({
50
+ PdftkPath: this._ProviderConfig.PdftkPath || 'pdftk',
51
+ PdftoppmPath: this._ProviderConfig.PdftoppmPath || 'pdftoppm',
52
+ FfmpegPath: this._ProviderConfig.FfmpegPath || 'ffmpeg',
53
+ FfprobePath: this._ProviderConfig.FfprobePath || 'ffprobe',
54
+ MaxFileSize: this._ProviderConfig.MaxFileSizeBytes || (100 * 1024 * 1024),
55
+ LogLevel: this._ProviderConfig.LogLevel || 0
56
+ });
57
+
58
+ // Track which tools are available
59
+ this._SharpAvailable = false;
60
+ this._PdftoppmAvailable = false;
61
+ this._PdftkAvailable = false;
62
+ this._FfmpegAvailable = false;
63
+ this._FfprobeAvailable = false;
64
+ }
65
+
66
+ get actions()
67
+ {
68
+ return {
69
+ 'ImageJpgToPng':
70
+ {
71
+ Description: 'Convert a JPEG image to PNG format.',
72
+ SettingsSchema:
73
+ [
74
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input JPEG file (relative to staging)' },
75
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output PNG file (relative to staging)' }
76
+ ]
77
+ },
78
+ 'ImagePngToJpg':
79
+ {
80
+ Description: 'Convert a PNG image to JPEG format.',
81
+ SettingsSchema:
82
+ [
83
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input PNG file (relative to staging)' },
84
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output JPEG file (relative to staging)' }
85
+ ]
86
+ },
87
+ 'ImageResize':
88
+ {
89
+ Description: 'Resize an image with format, dimension, fit, and position options.',
90
+ SettingsSchema:
91
+ [
92
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input image file' },
93
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output image file' },
94
+ { Name: 'Width', DataType: 'Number', Required: false, Description: 'Target width in pixels' },
95
+ { Name: 'Height', DataType: 'Number', Required: false, Description: 'Target height in pixels' },
96
+ { Name: 'Format', DataType: 'String', Required: false, Description: 'Output format: png, jpeg, webp, avif, tiff (default: jpeg)' },
97
+ { Name: 'Quality', DataType: 'Number', Required: false, Description: 'Quality for lossy formats 1-100 (default: 80)' },
98
+ { Name: 'AutoOrient', DataType: 'Boolean', Required: false, Description: 'Auto-orient from EXIF (default: true)' },
99
+ { Name: 'Fit', DataType: 'String', Required: false, Description: 'Resize fit mode: cover, contain, fill, inside, outside' },
100
+ { Name: 'Position', DataType: 'String', Required: false, Description: 'Resize position/gravity: centre, north, south, etc.' }
101
+ ]
102
+ },
103
+ 'ImageRotate':
104
+ {
105
+ Description: 'Rotate and/or flip an image.',
106
+ SettingsSchema:
107
+ [
108
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input image file' },
109
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output image file' },
110
+ { Name: 'Angle', DataType: 'Number', Required: false, Description: 'Rotation angle in degrees' },
111
+ { Name: 'Flip', DataType: 'Boolean', Required: false, Description: 'Flip vertically' },
112
+ { Name: 'Flop', DataType: 'Boolean', Required: false, Description: 'Flip horizontally' }
113
+ ]
114
+ },
115
+ 'ImageConvert':
116
+ {
117
+ Description: 'Convert an image between formats (jpeg, png, webp, avif, tiff).',
118
+ SettingsSchema:
119
+ [
120
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input image file' },
121
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output image file' },
122
+ { Name: 'Format', DataType: 'String', Required: true, Description: 'Target format: jpeg, png, webp, avif, tiff' },
123
+ { Name: 'Quality', DataType: 'Number', Required: false, Description: 'Quality for lossy formats 1-100 (default: 80)' }
124
+ ]
125
+ },
126
+ 'PdfPageToPng':
127
+ {
128
+ Description: 'Render a PDF page as a PNG image.',
129
+ SettingsSchema:
130
+ [
131
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input PDF file' },
132
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output PNG file' },
133
+ { Name: 'Page', DataType: 'Number', Required: true, Description: '1-based page number to render' }
134
+ ]
135
+ },
136
+ 'PdfPageToJpg':
137
+ {
138
+ Description: 'Render a PDF page as a JPEG image.',
139
+ SettingsSchema:
140
+ [
141
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input PDF file' },
142
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output JPEG file' },
143
+ { Name: 'Page', DataType: 'Number', Required: true, Description: '1-based page number to render' }
144
+ ]
145
+ },
146
+ 'PdfPageToPngSized':
147
+ {
148
+ Description: 'Render a PDF page as a PNG image constrained to a maximum dimension.',
149
+ SettingsSchema:
150
+ [
151
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input PDF file' },
152
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output PNG file' },
153
+ { Name: 'Page', DataType: 'Number', Required: true, Description: '1-based page number to render' },
154
+ { Name: 'LongSidePixels', DataType: 'Number', Required: true, Description: 'Maximum pixels for the longest dimension' }
155
+ ]
156
+ },
157
+ 'PdfPageToJpgSized':
158
+ {
159
+ Description: 'Render a PDF page as a JPEG image constrained to a maximum dimension.',
160
+ SettingsSchema:
161
+ [
162
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input PDF file' },
163
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output JPEG file' },
164
+ { Name: 'Page', DataType: 'Number', Required: true, Description: '1-based page number to render' },
165
+ { Name: 'LongSidePixels', DataType: 'Number', Required: true, Description: 'Maximum pixels for the longest dimension' }
166
+ ]
167
+ },
168
+ 'MediaProbe':
169
+ {
170
+ Description: 'Extract media metadata (format, streams, duration, etc.) via ffprobe.',
171
+ SettingsSchema:
172
+ [
173
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input media file' }
174
+ ]
175
+ },
176
+ 'VideoExtractFrame':
177
+ {
178
+ Description: 'Extract a single frame from a video at a given timestamp.',
179
+ SettingsSchema:
180
+ [
181
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input video file' },
182
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output JPEG image' },
183
+ { Name: 'Timestamp', DataType: 'String', Required: false, Description: 'Seek position (default: 00:00:00)' },
184
+ { Name: 'Width', DataType: 'Number', Required: false, Description: 'Scale width (height auto)' },
185
+ { Name: 'Height', DataType: 'Number', Required: false, Description: 'Scale height (width auto)' }
186
+ ]
187
+ },
188
+ 'VideoThumbnail':
189
+ {
190
+ Description: 'Generate a thumbnail from a video file.',
191
+ SettingsSchema:
192
+ [
193
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input video file' },
194
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output JPEG thumbnail' },
195
+ { Name: 'Timestamp', DataType: 'String', Required: false, Description: 'Seek position (default: 00:00:01)' },
196
+ { Name: 'Width', DataType: 'Number', Required: false, Description: 'Thumbnail width (default: 320)' }
197
+ ]
198
+ },
199
+ 'AudioExtractSegment':
200
+ {
201
+ Description: 'Extract a time-range segment from an audio or video file.',
202
+ SettingsSchema:
203
+ [
204
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input audio/video file' },
205
+ { Name: 'OutputFile', DataType: 'String', Required: true, Description: 'Path for output audio file' },
206
+ { Name: 'Start', DataType: 'String', Required: false, Description: 'Start time in seconds or HH:MM:SS (default: 0)' },
207
+ { Name: 'Duration', DataType: 'String', Required: true, Description: 'Segment duration in seconds or HH:MM:SS' },
208
+ { Name: 'Codec', DataType: 'String', Required: false, Description: 'Output codec: mp3, wav, flac, ogg, aac (default: mp3)' }
209
+ ]
210
+ },
211
+ 'AudioWaveform':
212
+ {
213
+ Description: 'Extract waveform peak data from an audio file.',
214
+ SettingsSchema:
215
+ [
216
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input audio/video file' },
217
+ { Name: 'SampleRate', DataType: 'Number', Required: false, Description: 'PCM sample rate (default: 8000)' },
218
+ { Name: 'Samples', DataType: 'Number', Required: false, Description: 'Number of peak values to return (default: 800)' }
219
+ ]
220
+ }
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Validate prerequisites: Sharp must be available; pdftoppm, pdftk, ffmpeg, ffprobe are optional.
226
+ */
227
+ initialize(fCallback)
228
+ {
229
+ this._Core.checkSharp((pSharpError, pSharpAvailable) =>
230
+ {
231
+ if (pSharpError)
232
+ {
233
+ return fCallback(new Error(`OratorConversion provider requires Sharp: ${pSharpError.message}`));
234
+ }
235
+ this._SharpAvailable = true;
236
+ console.log(` [OratorConversion] Sharp: available`);
237
+
238
+ this._Core.checkPdftoppm((pPdftoppmError, pPdftoppmAvailable) =>
239
+ {
240
+ this._PdftoppmAvailable = !pPdftoppmError;
241
+ console.log(` [OratorConversion] pdftoppm: ${this._PdftoppmAvailable ? 'available' : 'not found (PDF actions disabled)'}`);
242
+
243
+ this._Core.checkPdftk((pPdftkError, pPdftkAvailable) =>
244
+ {
245
+ this._PdftkAvailable = !pPdftkError;
246
+ console.log(` [OratorConversion] pdftk: ${this._PdftkAvailable ? 'available' : 'not found (PDF extraction disabled)'}`);
247
+
248
+ this._Core.checkFfmpeg((pFfmpegError, pFfmpegAvailable) =>
249
+ {
250
+ this._FfmpegAvailable = !pFfmpegError;
251
+ console.log(` [OratorConversion] ffmpeg: ${this._FfmpegAvailable ? 'available' : 'not found (video/audio actions disabled)'}`);
252
+
253
+ this._Core.checkFfprobe((pFfprobeError, pFfprobeAvailable) =>
254
+ {
255
+ this._FfprobeAvailable = !pFfprobeError;
256
+ console.log(` [OratorConversion] ffprobe: ${this._FfprobeAvailable ? 'available' : 'not found (media probe disabled)'}`);
257
+
258
+ return fCallback(null);
259
+ });
260
+ });
261
+ });
262
+ });
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Execute a conversion action.
268
+ */
269
+ execute(pAction, pWorkItem, pContext, fCallback, fReportProgress)
270
+ {
271
+ let tmpLog = pContext && pContext.log ? pContext.log : { info: console.log, warn: console.warn, error: console.error };
272
+ tmpLog.info(`[OratorConversion] execute: action="${pAction}" workItem=${pWorkItem.WorkItemHash || '?'} settings=${JSON.stringify(pWorkItem.Settings || {}).substring(0, 200)}`);
273
+ let tmpSettings = pWorkItem.Settings || {};
274
+ let tmpStagingPath = pContext.StagingPath || process.cwd();
275
+
276
+ // Coerce settings types from the action's schema.
277
+ // Template engines and JSON transport may deliver numbers as strings.
278
+ let tmpActionDef = this.actions[pAction];
279
+ if (tmpActionDef && tmpActionDef.SettingsSchema)
280
+ {
281
+ for (let i = 0; i < tmpActionDef.SettingsSchema.length; i++)
282
+ {
283
+ let tmpField = tmpActionDef.SettingsSchema[i];
284
+ let tmpVal = tmpSettings[tmpField.Name];
285
+ if (tmpVal === undefined || tmpVal === null || tmpVal === '') { continue; }
286
+ if (tmpField.DataType === 'Number' && typeof tmpVal === 'string')
287
+ {
288
+ let tmpNum = Number(tmpVal);
289
+ if (!isNaN(tmpNum)) { tmpSettings[tmpField.Name] = tmpNum; }
290
+ }
291
+ else if (tmpField.DataType === 'Boolean' && typeof tmpVal === 'string')
292
+ {
293
+ tmpSettings[tmpField.Name] = (tmpVal === 'true' || tmpVal === '1');
294
+ }
295
+ }
296
+ }
297
+
298
+ let tmpInputPath = this._resolvePath(tmpSettings.InputFile, tmpStagingPath);
299
+ let tmpOutputPath = this._resolvePath(tmpSettings.OutputFile, tmpStagingPath);
300
+
301
+ if (!tmpInputPath)
302
+ {
303
+ return fCallback(null, {
304
+ Outputs: { StdOut: 'No InputFile specified.', ExitCode: -1, Result: '' },
305
+ Log: ['OratorConversion: no InputFile specified.']
306
+ });
307
+ }
308
+
309
+ if (!libFS.existsSync(tmpInputPath))
310
+ {
311
+ tmpLog.warn(`[OratorConversion] Input file NOT FOUND: ${tmpInputPath} (settings.InputFile=${tmpSettings.InputFile})`);
312
+ return fCallback(null, {
313
+ Outputs: { StdOut: `Input file not found: ${tmpSettings.InputFile}`, ExitCode: -1, Result: '' },
314
+ Log: [`OratorConversion: input file not found: ${tmpInputPath}`]
315
+ });
316
+ }
317
+
318
+ tmpLog.info(`[OratorConversion] Input file OK: ${tmpInputPath} (${libFS.statSync(tmpInputPath).size} bytes)`);
319
+
320
+ // File-path actions skip the buffer read — Sharp and ffmpeg handle files directly
321
+ let tmpFilePathActions = { 'ImageResize': true, 'ImageConvert': true, 'MediaProbe': true, 'VideoExtractFrame': true, 'VideoThumbnail': true, 'AudioExtractSegment': true, 'AudioWaveform': true };
322
+ if (tmpFilePathActions[pAction])
323
+ {
324
+ return this._executeFilePathAction(pAction, tmpSettings, tmpInputPath, tmpOutputPath, fCallback, fReportProgress);
325
+ }
326
+
327
+ // Buffer-based actions: read input into memory
328
+ let tmpInputBuffer;
329
+ try
330
+ {
331
+ tmpInputBuffer = libFS.readFileSync(tmpInputPath);
332
+ }
333
+ catch (pReadError)
334
+ {
335
+ return fCallback(null, {
336
+ Outputs: { StdOut: `Failed to read input file: ${pReadError.message}`, ExitCode: -1, Result: '' },
337
+ Log: [`OratorConversion: read error: ${pReadError.message}`]
338
+ });
339
+ }
340
+
341
+ // Ensure output directory exists
342
+ if (tmpOutputPath)
343
+ {
344
+ let tmpOutputDir = libPath.dirname(tmpOutputPath);
345
+ if (!libFS.existsSync(tmpOutputDir))
346
+ {
347
+ libFS.mkdirSync(tmpOutputDir, { recursive: true });
348
+ }
349
+ }
350
+
351
+ let tmpWriteAndReturn = (pError, pOutputBuffer, pContentType) =>
352
+ {
353
+ if (pError)
354
+ {
355
+ tmpLog.error(`[OratorConversion] ${pAction} FAILED: ${pError.message}`);
356
+ return fCallback(null, {
357
+ Outputs: { StdOut: `Conversion failed: ${pError.message}`, ExitCode: 1, Result: '' },
358
+ Log: [`OratorConversion ${pAction} error: ${pError.message}`]
359
+ });
360
+ }
361
+
362
+ tmpLog.info(`[OratorConversion] ${pAction} SUCCESS: ${pOutputBuffer.length} bytes → ${tmpOutputPath}`);
363
+
364
+ try
365
+ {
366
+ libFS.writeFileSync(tmpOutputPath, pOutputBuffer);
367
+ }
368
+ catch (pWriteError)
369
+ {
370
+ return fCallback(null, {
371
+ Outputs: { StdOut: `Failed to write output: ${pWriteError.message}`, ExitCode: 1, Result: '' },
372
+ Log: [`OratorConversion: write error: ${pWriteError.message}`]
373
+ });
374
+ }
375
+
376
+ return fCallback(null, {
377
+ Outputs:
378
+ {
379
+ StdOut: `Converted ${tmpSettings.InputFile} → ${tmpSettings.OutputFile}`,
380
+ ExitCode: 0,
381
+ Result: tmpOutputPath,
382
+ ContentType: pContentType || '',
383
+ OutputSize: pOutputBuffer.length
384
+ },
385
+ Log: [`OratorConversion ${pAction}: ${tmpSettings.InputFile} → ${tmpSettings.OutputFile} (${pOutputBuffer.length} bytes)`]
386
+ });
387
+ };
388
+
389
+ switch (pAction)
390
+ {
391
+ case 'ImageJpgToPng':
392
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting JPEG to PNG...' });
393
+ this._Core.jpgToPng(tmpInputBuffer, tmpWriteAndReturn);
394
+ break;
395
+
396
+ case 'ImagePngToJpg':
397
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting PNG to JPEG...' });
398
+ this._Core.pngToJpg(tmpInputBuffer, tmpWriteAndReturn);
399
+ break;
400
+
401
+ case 'ImageResize':
402
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Resizing image...' });
403
+ this._Core.imageResize(tmpInputBuffer,
404
+ {
405
+ Width: tmpSettings.Width,
406
+ Height: tmpSettings.Height,
407
+ Format: tmpSettings.Format,
408
+ Quality: tmpSettings.Quality,
409
+ AutoOrient: tmpSettings.AutoOrient,
410
+ Fit: tmpSettings.Fit,
411
+ Position: tmpSettings.Position
412
+ },
413
+ tmpWriteAndReturn);
414
+ break;
415
+
416
+ case 'ImageRotate':
417
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rotating image...' });
418
+ this._Core.imageRotate(tmpInputBuffer,
419
+ {
420
+ Angle: tmpSettings.Angle,
421
+ Flip: tmpSettings.Flip,
422
+ Flop: tmpSettings.Flop
423
+ },
424
+ tmpWriteAndReturn);
425
+ break;
426
+
427
+ case 'ImageConvert':
428
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting image format...' });
429
+ this._Core.imageConvert(tmpInputBuffer,
430
+ {
431
+ Format: tmpSettings.Format,
432
+ Quality: tmpSettings.Quality
433
+ },
434
+ tmpWriteAndReturn);
435
+ break;
436
+
437
+ case 'PdfPageToPng':
438
+ if (!this._PdftoppmAvailable)
439
+ {
440
+ return fCallback(null, {
441
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
442
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
443
+ });
444
+ }
445
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to PNG...' });
446
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'png',
447
+ (pError, pImageBuffer) =>
448
+ {
449
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/png');
450
+ });
451
+ break;
452
+
453
+ case 'PdfPageToJpg':
454
+ if (!this._PdftoppmAvailable)
455
+ {
456
+ return fCallback(null, {
457
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
458
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
459
+ });
460
+ }
461
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to JPEG...' });
462
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'jpeg',
463
+ (pError, pImageBuffer) =>
464
+ {
465
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/jpeg');
466
+ });
467
+ break;
468
+
469
+ case 'PdfPageToPngSized':
470
+ if (!this._PdftoppmAvailable)
471
+ {
472
+ return fCallback(null, {
473
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
474
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
475
+ });
476
+ }
477
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to sized PNG...' });
478
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'png',
479
+ (pError, pImageBuffer) =>
480
+ {
481
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/png');
482
+ },
483
+ { LongSidePixels: parseInt(tmpSettings.LongSidePixels, 10) });
484
+ break;
485
+
486
+ case 'PdfPageToJpgSized':
487
+ if (!this._PdftoppmAvailable)
488
+ {
489
+ return fCallback(null, {
490
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
491
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
492
+ });
493
+ }
494
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to sized JPEG...' });
495
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'jpeg',
496
+ (pError, pImageBuffer) =>
497
+ {
498
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/jpeg');
499
+ },
500
+ { LongSidePixels: parseInt(tmpSettings.LongSidePixels, 10) });
501
+ break;
502
+
503
+ default:
504
+ return fCallback(null, {
505
+ Outputs: { StdOut: `Unknown action: ${pAction}`, ExitCode: -1, Result: '' },
506
+ Log: [`OratorConversion: unknown action "${pAction}".`]
507
+ });
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Execute a file-path based action (video, audio, probe).
513
+ *
514
+ * These actions work directly with file paths instead of reading the
515
+ * entire input into a buffer, since media files can be very large.
516
+ */
517
+ _executeFilePathAction(pAction, pSettings, pInputPath, pOutputPath, fCallback, fReportProgress)
518
+ {
519
+ switch (pAction)
520
+ {
521
+ case 'MediaProbe':
522
+ {
523
+ if (!this._FfprobeAvailable)
524
+ {
525
+ return fCallback(null, {
526
+ Outputs: { StdOut: 'ffprobe not available on this beacon.', ExitCode: -1, Result: '' },
527
+ Log: ['OratorConversion: ffprobe required for MediaProbe but not found.']
528
+ });
529
+ }
530
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Probing media metadata...' });
531
+ this._Core.mediaProbe(pInputPath,
532
+ (pError, pMetadata) =>
533
+ {
534
+ if (pError)
535
+ {
536
+ return fCallback(null, {
537
+ Outputs: { StdOut: `Probe failed: ${pError.message}`, ExitCode: 1, Result: '' },
538
+ Log: [`OratorConversion MediaProbe error: ${pError.message}`]
539
+ });
540
+ }
541
+
542
+ return fCallback(null, {
543
+ Outputs:
544
+ {
545
+ StdOut: `Probed ${pSettings.InputFile}`,
546
+ ExitCode: 0,
547
+ Result: JSON.stringify(pMetadata),
548
+ ContentType: 'application/json'
549
+ },
550
+ Log: [`OratorConversion MediaProbe: ${pSettings.InputFile}`]
551
+ });
552
+ });
553
+ break;
554
+ }
555
+
556
+ case 'VideoExtractFrame':
557
+ {
558
+ if (!this._FfmpegAvailable)
559
+ {
560
+ return fCallback(null, {
561
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
562
+ Log: ['OratorConversion: ffmpeg required for VideoExtractFrame but not found.']
563
+ });
564
+ }
565
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Extracting video frame...' });
566
+
567
+ this._Core.videoExtractFrame(pInputPath, pOutputPath,
568
+ {
569
+ Timestamp: pSettings.Timestamp,
570
+ Width: pSettings.Width,
571
+ Height: pSettings.Height
572
+ },
573
+ (pError, pResultPath) =>
574
+ {
575
+ if (pError)
576
+ {
577
+ return fCallback(null, {
578
+ Outputs: { StdOut: `Frame extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
579
+ Log: [`OratorConversion VideoExtractFrame error: ${pError.message}`]
580
+ });
581
+ }
582
+
583
+ let tmpOutputSize = 0;
584
+ try { tmpOutputSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
585
+
586
+ return fCallback(null, {
587
+ Outputs:
588
+ {
589
+ StdOut: `Extracted frame from ${pSettings.InputFile} → ${pSettings.OutputFile}`,
590
+ ExitCode: 0,
591
+ Result: pResultPath,
592
+ ContentType: 'image/jpeg',
593
+ OutputSize: tmpOutputSize
594
+ },
595
+ Log: [`OratorConversion VideoExtractFrame: ${pSettings.InputFile} → ${pSettings.OutputFile} (${tmpOutputSize} bytes)`]
596
+ });
597
+ });
598
+ break;
599
+ }
600
+
601
+ case 'VideoThumbnail':
602
+ {
603
+ if (!this._FfmpegAvailable)
604
+ {
605
+ return fCallback(null, {
606
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
607
+ Log: ['OratorConversion: ffmpeg required for VideoThumbnail but not found.']
608
+ });
609
+ }
610
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Generating video thumbnail...' });
611
+
612
+ this._Core.videoThumbnail(pInputPath, pOutputPath,
613
+ {
614
+ Timestamp: pSettings.Timestamp,
615
+ Width: pSettings.Width
616
+ },
617
+ (pError, pResultPath) =>
618
+ {
619
+ if (pError)
620
+ {
621
+ return fCallback(null, {
622
+ Outputs: { StdOut: `Thumbnail generation failed: ${pError.message}`, ExitCode: 1, Result: '' },
623
+ Log: [`OratorConversion VideoThumbnail error: ${pError.message}`]
624
+ });
625
+ }
626
+
627
+ let tmpOutputSize = 0;
628
+ try { tmpOutputSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
629
+
630
+ return fCallback(null, {
631
+ Outputs:
632
+ {
633
+ StdOut: `Generated thumbnail from ${pSettings.InputFile} → ${pSettings.OutputFile}`,
634
+ ExitCode: 0,
635
+ Result: pResultPath,
636
+ ContentType: 'image/jpeg',
637
+ OutputSize: tmpOutputSize
638
+ },
639
+ Log: [`OratorConversion VideoThumbnail: ${pSettings.InputFile} → ${pSettings.OutputFile} (${tmpOutputSize} bytes)`]
640
+ });
641
+ });
642
+ break;
643
+ }
644
+
645
+ case 'AudioExtractSegment':
646
+ {
647
+ if (!this._FfmpegAvailable)
648
+ {
649
+ return fCallback(null, {
650
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
651
+ Log: ['OratorConversion: ffmpeg required for AudioExtractSegment but not found.']
652
+ });
653
+ }
654
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Extracting audio segment...' });
655
+
656
+ this._Core.audioExtractSegment(pInputPath, pOutputPath,
657
+ {
658
+ Start: pSettings.Start,
659
+ Duration: pSettings.Duration,
660
+ Codec: pSettings.Codec
661
+ },
662
+ (pError, pResultPath) =>
663
+ {
664
+ if (pError)
665
+ {
666
+ return fCallback(null, {
667
+ Outputs: { StdOut: `Audio extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
668
+ Log: [`OratorConversion AudioExtractSegment error: ${pError.message}`]
669
+ });
670
+ }
671
+
672
+ let tmpOutputSize = 0;
673
+ try { tmpOutputSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
674
+
675
+ return fCallback(null, {
676
+ Outputs:
677
+ {
678
+ StdOut: `Extracted audio segment from ${pSettings.InputFile} → ${pSettings.OutputFile}`,
679
+ ExitCode: 0,
680
+ Result: pResultPath,
681
+ ContentType: 'audio/mpeg',
682
+ OutputSize: tmpOutputSize
683
+ },
684
+ Log: [`OratorConversion AudioExtractSegment: ${pSettings.InputFile} → ${pSettings.OutputFile} (${tmpOutputSize} bytes)`]
685
+ });
686
+ });
687
+ break;
688
+ }
689
+
690
+ case 'AudioWaveform':
691
+ {
692
+ if (!this._FfmpegAvailable)
693
+ {
694
+ return fCallback(null, {
695
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
696
+ Log: ['OratorConversion: ffmpeg required for AudioWaveform but not found.']
697
+ });
698
+ }
699
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Extracting waveform data...' });
700
+
701
+ this._Core.audioWaveform(pInputPath,
702
+ {
703
+ SampleRate: pSettings.SampleRate,
704
+ Samples: pSettings.Samples
705
+ },
706
+ (pError, pPeaks) =>
707
+ {
708
+ if (pError)
709
+ {
710
+ return fCallback(null, {
711
+ Outputs: { StdOut: `Waveform extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
712
+ Log: [`OratorConversion AudioWaveform error: ${pError.message}`]
713
+ });
714
+ }
715
+
716
+ return fCallback(null, {
717
+ Outputs:
718
+ {
719
+ StdOut: `Extracted waveform from ${pSettings.InputFile} (${pPeaks.length} peaks)`,
720
+ ExitCode: 0,
721
+ Result: JSON.stringify(pPeaks),
722
+ ContentType: 'application/json'
723
+ },
724
+ Log: [`OratorConversion AudioWaveform: ${pSettings.InputFile} (${pPeaks.length} peaks)`]
725
+ });
726
+ });
727
+ break;
728
+ }
729
+
730
+ case 'ImageResize':
731
+ {
732
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Resizing image...' });
733
+ this._Core.imageResize(pInputPath,
734
+ {
735
+ Width: pSettings.Width,
736
+ Height: pSettings.Height,
737
+ Format: pSettings.Format,
738
+ Quality: pSettings.Quality,
739
+ AutoOrient: pSettings.AutoOrient,
740
+ Fit: pSettings.Fit,
741
+ Position: pSettings.Position
742
+ },
743
+ (pError, pOutputBuffer, pContentType) =>
744
+ {
745
+ if (pError)
746
+ {
747
+ return fCallback(null, {
748
+ Outputs: { StdOut: `Resize failed: ${pError.message}`, ExitCode: 1, Result: '' },
749
+ Log: [`OratorConversion ImageResize error: ${pError.message}`]
750
+ });
751
+ }
752
+
753
+ try
754
+ {
755
+ libFS.writeFileSync(pOutputPath, pOutputBuffer);
756
+ }
757
+ catch (pWriteError)
758
+ {
759
+ return fCallback(null, {
760
+ Outputs: { StdOut: `Write failed: ${pWriteError.message}`, ExitCode: 1, Result: '' },
761
+ Log: [`OratorConversion ImageResize write error: ${pWriteError.message}`]
762
+ });
763
+ }
764
+
765
+ return fCallback(null, {
766
+ Outputs:
767
+ {
768
+ StdOut: `Resized ${pSettings.InputFile} → ${pSettings.OutputFile}`,
769
+ ExitCode: 0,
770
+ Result: pOutputPath,
771
+ ContentType: pContentType || '',
772
+ OutputSize: pOutputBuffer.length
773
+ },
774
+ Log: [`OratorConversion ImageResize: ${pSettings.InputFile} → ${pSettings.OutputFile} (${pOutputBuffer.length} bytes)`]
775
+ });
776
+ });
777
+ break;
778
+ }
779
+
780
+ case 'ImageConvert':
781
+ {
782
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting image...' });
783
+ this._Core.imageResize(pInputPath,
784
+ {
785
+ Format: pSettings.Format,
786
+ Quality: pSettings.Quality,
787
+ AutoOrient: pSettings.AutoOrient
788
+ },
789
+ (pError, pOutputBuffer, pContentType) =>
790
+ {
791
+ if (pError)
792
+ {
793
+ return fCallback(null, {
794
+ Outputs: { StdOut: `Convert failed: ${pError.message}`, ExitCode: 1, Result: '' },
795
+ Log: [`OratorConversion ImageConvert error: ${pError.message}`]
796
+ });
797
+ }
798
+
799
+ try
800
+ {
801
+ libFS.writeFileSync(pOutputPath, pOutputBuffer);
802
+ }
803
+ catch (pWriteError)
804
+ {
805
+ return fCallback(null, {
806
+ Outputs: { StdOut: `Write failed: ${pWriteError.message}`, ExitCode: 1, Result: '' },
807
+ Log: [`OratorConversion ImageConvert write error: ${pWriteError.message}`]
808
+ });
809
+ }
810
+
811
+ return fCallback(null, {
812
+ Outputs:
813
+ {
814
+ StdOut: `Converted ${pSettings.InputFile} → ${pSettings.OutputFile}`,
815
+ ExitCode: 0,
816
+ Result: pOutputPath,
817
+ ContentType: pContentType || '',
818
+ OutputSize: pOutputBuffer.length
819
+ },
820
+ Log: [`OratorConversion ImageConvert: ${pSettings.InputFile} → ${pSettings.OutputFile} (${pOutputBuffer.length} bytes)`]
821
+ });
822
+ });
823
+ break;
824
+ }
825
+
826
+ default:
827
+ return fCallback(null, {
828
+ Outputs: { StdOut: `Unknown file-path action: ${pAction}`, ExitCode: -1, Result: '' },
829
+ Log: [`OratorConversion: unknown file-path action "${pAction}".`]
830
+ });
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Resolve a file path relative to the staging directory.
836
+ * Absolute paths are returned as-is.
837
+ */
838
+ _resolvePath(pFilePath, pStagingPath)
839
+ {
840
+ if (!pFilePath)
841
+ {
842
+ return null;
843
+ }
844
+ if (libPath.isAbsolute(pFilePath))
845
+ {
846
+ return pFilePath;
847
+ }
848
+ return libPath.join(pStagingPath, pFilePath);
849
+ }
850
+ }
851
+
852
+ module.exports = OratorConversionBeaconProvider;