orator-conversion 1.0.2 → 1.0.3

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,726 @@
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 tmpSettings = pWorkItem.Settings || {};
272
+ let tmpStagingPath = pContext.StagingPath || process.cwd();
273
+
274
+ let tmpInputPath = this._resolvePath(tmpSettings.InputFile, tmpStagingPath);
275
+ let tmpOutputPath = this._resolvePath(tmpSettings.OutputFile, tmpStagingPath);
276
+
277
+ if (!tmpInputPath)
278
+ {
279
+ return fCallback(null, {
280
+ Outputs: { StdOut: 'No InputFile specified.', ExitCode: -1, Result: '' },
281
+ Log: ['OratorConversion: no InputFile specified.']
282
+ });
283
+ }
284
+
285
+ if (!libFS.existsSync(tmpInputPath))
286
+ {
287
+ return fCallback(null, {
288
+ Outputs: { StdOut: `Input file not found: ${tmpSettings.InputFile}`, ExitCode: -1, Result: '' },
289
+ Log: [`OratorConversion: input file not found: ${tmpInputPath}`]
290
+ });
291
+ }
292
+
293
+ // File-path actions (video, audio, probe) skip the buffer read
294
+ let tmpFilePathActions = { 'MediaProbe': true, 'VideoExtractFrame': true, 'VideoThumbnail': true, 'AudioExtractSegment': true, 'AudioWaveform': true };
295
+ if (tmpFilePathActions[pAction])
296
+ {
297
+ return this._executeFilePathAction(pAction, tmpSettings, tmpInputPath, tmpOutputPath, fCallback, fReportProgress);
298
+ }
299
+
300
+ // Buffer-based actions: read input into memory
301
+ let tmpInputBuffer;
302
+ try
303
+ {
304
+ tmpInputBuffer = libFS.readFileSync(tmpInputPath);
305
+ }
306
+ catch (pReadError)
307
+ {
308
+ return fCallback(null, {
309
+ Outputs: { StdOut: `Failed to read input file: ${pReadError.message}`, ExitCode: -1, Result: '' },
310
+ Log: [`OratorConversion: read error: ${pReadError.message}`]
311
+ });
312
+ }
313
+
314
+ // Ensure output directory exists
315
+ if (tmpOutputPath)
316
+ {
317
+ let tmpOutputDir = libPath.dirname(tmpOutputPath);
318
+ if (!libFS.existsSync(tmpOutputDir))
319
+ {
320
+ libFS.mkdirSync(tmpOutputDir, { recursive: true });
321
+ }
322
+ }
323
+
324
+ let tmpWriteAndReturn = (pError, pOutputBuffer, pContentType) =>
325
+ {
326
+ if (pError)
327
+ {
328
+ return fCallback(null, {
329
+ Outputs: { StdOut: `Conversion failed: ${pError.message}`, ExitCode: 1, Result: '' },
330
+ Log: [`OratorConversion ${pAction} error: ${pError.message}`]
331
+ });
332
+ }
333
+
334
+ try
335
+ {
336
+ libFS.writeFileSync(tmpOutputPath, pOutputBuffer);
337
+ }
338
+ catch (pWriteError)
339
+ {
340
+ return fCallback(null, {
341
+ Outputs: { StdOut: `Failed to write output: ${pWriteError.message}`, ExitCode: 1, Result: '' },
342
+ Log: [`OratorConversion: write error: ${pWriteError.message}`]
343
+ });
344
+ }
345
+
346
+ return fCallback(null, {
347
+ Outputs:
348
+ {
349
+ StdOut: `Converted ${tmpSettings.InputFile} → ${tmpSettings.OutputFile}`,
350
+ ExitCode: 0,
351
+ Result: tmpOutputPath,
352
+ ContentType: pContentType || '',
353
+ OutputSize: pOutputBuffer.length
354
+ },
355
+ Log: [`OratorConversion ${pAction}: ${tmpSettings.InputFile} → ${tmpSettings.OutputFile} (${pOutputBuffer.length} bytes)`]
356
+ });
357
+ };
358
+
359
+ switch (pAction)
360
+ {
361
+ case 'ImageJpgToPng':
362
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting JPEG to PNG...' });
363
+ this._Core.jpgToPng(tmpInputBuffer, tmpWriteAndReturn);
364
+ break;
365
+
366
+ case 'ImagePngToJpg':
367
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting PNG to JPEG...' });
368
+ this._Core.pngToJpg(tmpInputBuffer, tmpWriteAndReturn);
369
+ break;
370
+
371
+ case 'ImageResize':
372
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Resizing image...' });
373
+ this._Core.imageResize(tmpInputBuffer,
374
+ {
375
+ Width: tmpSettings.Width,
376
+ Height: tmpSettings.Height,
377
+ Format: tmpSettings.Format,
378
+ Quality: tmpSettings.Quality,
379
+ AutoOrient: tmpSettings.AutoOrient,
380
+ Fit: tmpSettings.Fit,
381
+ Position: tmpSettings.Position
382
+ },
383
+ tmpWriteAndReturn);
384
+ break;
385
+
386
+ case 'ImageRotate':
387
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rotating image...' });
388
+ this._Core.imageRotate(tmpInputBuffer,
389
+ {
390
+ Angle: tmpSettings.Angle,
391
+ Flip: tmpSettings.Flip,
392
+ Flop: tmpSettings.Flop
393
+ },
394
+ tmpWriteAndReturn);
395
+ break;
396
+
397
+ case 'ImageConvert':
398
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Converting image format...' });
399
+ this._Core.imageConvert(tmpInputBuffer,
400
+ {
401
+ Format: tmpSettings.Format,
402
+ Quality: tmpSettings.Quality
403
+ },
404
+ tmpWriteAndReturn);
405
+ break;
406
+
407
+ case 'PdfPageToPng':
408
+ if (!this._PdftoppmAvailable)
409
+ {
410
+ return fCallback(null, {
411
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
412
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
413
+ });
414
+ }
415
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to PNG...' });
416
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'png',
417
+ (pError, pImageBuffer) =>
418
+ {
419
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/png');
420
+ });
421
+ break;
422
+
423
+ case 'PdfPageToJpg':
424
+ if (!this._PdftoppmAvailable)
425
+ {
426
+ return fCallback(null, {
427
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
428
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
429
+ });
430
+ }
431
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to JPEG...' });
432
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'jpeg',
433
+ (pError, pImageBuffer) =>
434
+ {
435
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/jpeg');
436
+ });
437
+ break;
438
+
439
+ case 'PdfPageToPngSized':
440
+ if (!this._PdftoppmAvailable)
441
+ {
442
+ return fCallback(null, {
443
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
444
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
445
+ });
446
+ }
447
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to sized PNG...' });
448
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'png',
449
+ (pError, pImageBuffer) =>
450
+ {
451
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/png');
452
+ },
453
+ { LongSidePixels: parseInt(tmpSettings.LongSidePixels, 10) });
454
+ break;
455
+
456
+ case 'PdfPageToJpgSized':
457
+ if (!this._PdftoppmAvailable)
458
+ {
459
+ return fCallback(null, {
460
+ Outputs: { StdOut: 'pdftoppm not available on this beacon.', ExitCode: -1, Result: '' },
461
+ Log: ['OratorConversion: pdftoppm required for PDF rendering but not found.']
462
+ });
463
+ }
464
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Rendering PDF page to sized JPEG...' });
465
+ this._Core.renderPdfPageToImage(tmpInputBuffer, parseInt(tmpSettings.Page, 10), 'jpeg',
466
+ (pError, pImageBuffer) =>
467
+ {
468
+ tmpWriteAndReturn(pError, pImageBuffer, 'image/jpeg');
469
+ },
470
+ { LongSidePixels: parseInt(tmpSettings.LongSidePixels, 10) });
471
+ break;
472
+
473
+ default:
474
+ return fCallback(null, {
475
+ Outputs: { StdOut: `Unknown action: ${pAction}`, ExitCode: -1, Result: '' },
476
+ Log: [`OratorConversion: unknown action "${pAction}".`]
477
+ });
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Execute a file-path based action (video, audio, probe).
483
+ *
484
+ * These actions work directly with file paths instead of reading the
485
+ * entire input into a buffer, since media files can be very large.
486
+ */
487
+ _executeFilePathAction(pAction, pSettings, pInputPath, pOutputPath, fCallback, fReportProgress)
488
+ {
489
+ switch (pAction)
490
+ {
491
+ case 'MediaProbe':
492
+ {
493
+ if (!this._FfprobeAvailable)
494
+ {
495
+ return fCallback(null, {
496
+ Outputs: { StdOut: 'ffprobe not available on this beacon.', ExitCode: -1, Result: '' },
497
+ Log: ['OratorConversion: ffprobe required for MediaProbe but not found.']
498
+ });
499
+ }
500
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Probing media metadata...' });
501
+ this._Core.mediaProbe(pInputPath,
502
+ (pError, pMetadata) =>
503
+ {
504
+ if (pError)
505
+ {
506
+ return fCallback(null, {
507
+ Outputs: { StdOut: `Probe failed: ${pError.message}`, ExitCode: 1, Result: '' },
508
+ Log: [`OratorConversion MediaProbe error: ${pError.message}`]
509
+ });
510
+ }
511
+
512
+ return fCallback(null, {
513
+ Outputs:
514
+ {
515
+ StdOut: `Probed ${pSettings.InputFile}`,
516
+ ExitCode: 0,
517
+ Result: JSON.stringify(pMetadata),
518
+ ContentType: 'application/json'
519
+ },
520
+ Log: [`OratorConversion MediaProbe: ${pSettings.InputFile}`]
521
+ });
522
+ });
523
+ break;
524
+ }
525
+
526
+ case 'VideoExtractFrame':
527
+ {
528
+ if (!this._FfmpegAvailable)
529
+ {
530
+ return fCallback(null, {
531
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
532
+ Log: ['OratorConversion: ffmpeg required for VideoExtractFrame but not found.']
533
+ });
534
+ }
535
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Extracting video frame...' });
536
+
537
+ this._Core.videoExtractFrame(pInputPath, pOutputPath,
538
+ {
539
+ Timestamp: pSettings.Timestamp,
540
+ Width: pSettings.Width,
541
+ Height: pSettings.Height
542
+ },
543
+ (pError, pResultPath) =>
544
+ {
545
+ if (pError)
546
+ {
547
+ return fCallback(null, {
548
+ Outputs: { StdOut: `Frame extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
549
+ Log: [`OratorConversion VideoExtractFrame error: ${pError.message}`]
550
+ });
551
+ }
552
+
553
+ let tmpOutputSize = 0;
554
+ try { tmpOutputSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
555
+
556
+ return fCallback(null, {
557
+ Outputs:
558
+ {
559
+ StdOut: `Extracted frame from ${pSettings.InputFile} → ${pSettings.OutputFile}`,
560
+ ExitCode: 0,
561
+ Result: pResultPath,
562
+ ContentType: 'image/jpeg',
563
+ OutputSize: tmpOutputSize
564
+ },
565
+ Log: [`OratorConversion VideoExtractFrame: ${pSettings.InputFile} → ${pSettings.OutputFile} (${tmpOutputSize} bytes)`]
566
+ });
567
+ });
568
+ break;
569
+ }
570
+
571
+ case 'VideoThumbnail':
572
+ {
573
+ if (!this._FfmpegAvailable)
574
+ {
575
+ return fCallback(null, {
576
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
577
+ Log: ['OratorConversion: ffmpeg required for VideoThumbnail but not found.']
578
+ });
579
+ }
580
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Generating video thumbnail...' });
581
+
582
+ this._Core.videoThumbnail(pInputPath, pOutputPath,
583
+ {
584
+ Timestamp: pSettings.Timestamp,
585
+ Width: pSettings.Width
586
+ },
587
+ (pError, pResultPath) =>
588
+ {
589
+ if (pError)
590
+ {
591
+ return fCallback(null, {
592
+ Outputs: { StdOut: `Thumbnail generation failed: ${pError.message}`, ExitCode: 1, Result: '' },
593
+ Log: [`OratorConversion VideoThumbnail error: ${pError.message}`]
594
+ });
595
+ }
596
+
597
+ let tmpOutputSize = 0;
598
+ try { tmpOutputSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
599
+
600
+ return fCallback(null, {
601
+ Outputs:
602
+ {
603
+ StdOut: `Generated thumbnail from ${pSettings.InputFile} → ${pSettings.OutputFile}`,
604
+ ExitCode: 0,
605
+ Result: pResultPath,
606
+ ContentType: 'image/jpeg',
607
+ OutputSize: tmpOutputSize
608
+ },
609
+ Log: [`OratorConversion VideoThumbnail: ${pSettings.InputFile} → ${pSettings.OutputFile} (${tmpOutputSize} bytes)`]
610
+ });
611
+ });
612
+ break;
613
+ }
614
+
615
+ case 'AudioExtractSegment':
616
+ {
617
+ if (!this._FfmpegAvailable)
618
+ {
619
+ return fCallback(null, {
620
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
621
+ Log: ['OratorConversion: ffmpeg required for AudioExtractSegment but not found.']
622
+ });
623
+ }
624
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Extracting audio segment...' });
625
+
626
+ this._Core.audioExtractSegment(pInputPath, pOutputPath,
627
+ {
628
+ Start: pSettings.Start,
629
+ Duration: pSettings.Duration,
630
+ Codec: pSettings.Codec
631
+ },
632
+ (pError, pResultPath) =>
633
+ {
634
+ if (pError)
635
+ {
636
+ return fCallback(null, {
637
+ Outputs: { StdOut: `Audio extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
638
+ Log: [`OratorConversion AudioExtractSegment error: ${pError.message}`]
639
+ });
640
+ }
641
+
642
+ let tmpOutputSize = 0;
643
+ try { tmpOutputSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
644
+
645
+ return fCallback(null, {
646
+ Outputs:
647
+ {
648
+ StdOut: `Extracted audio segment from ${pSettings.InputFile} → ${pSettings.OutputFile}`,
649
+ ExitCode: 0,
650
+ Result: pResultPath,
651
+ ContentType: 'audio/mpeg',
652
+ OutputSize: tmpOutputSize
653
+ },
654
+ Log: [`OratorConversion AudioExtractSegment: ${pSettings.InputFile} → ${pSettings.OutputFile} (${tmpOutputSize} bytes)`]
655
+ });
656
+ });
657
+ break;
658
+ }
659
+
660
+ case 'AudioWaveform':
661
+ {
662
+ if (!this._FfmpegAvailable)
663
+ {
664
+ return fCallback(null, {
665
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
666
+ Log: ['OratorConversion: ffmpeg required for AudioWaveform but not found.']
667
+ });
668
+ }
669
+ if (fReportProgress) fReportProgress({ Percent: 10, Message: 'Extracting waveform data...' });
670
+
671
+ this._Core.audioWaveform(pInputPath,
672
+ {
673
+ SampleRate: pSettings.SampleRate,
674
+ Samples: pSettings.Samples
675
+ },
676
+ (pError, pPeaks) =>
677
+ {
678
+ if (pError)
679
+ {
680
+ return fCallback(null, {
681
+ Outputs: { StdOut: `Waveform extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
682
+ Log: [`OratorConversion AudioWaveform error: ${pError.message}`]
683
+ });
684
+ }
685
+
686
+ return fCallback(null, {
687
+ Outputs:
688
+ {
689
+ StdOut: `Extracted waveform from ${pSettings.InputFile} (${pPeaks.length} peaks)`,
690
+ ExitCode: 0,
691
+ Result: JSON.stringify(pPeaks),
692
+ ContentType: 'application/json'
693
+ },
694
+ Log: [`OratorConversion AudioWaveform: ${pSettings.InputFile} (${pPeaks.length} peaks)`]
695
+ });
696
+ });
697
+ break;
698
+ }
699
+
700
+ default:
701
+ return fCallback(null, {
702
+ Outputs: { StdOut: `Unknown file-path action: ${pAction}`, ExitCode: -1, Result: '' },
703
+ Log: [`OratorConversion: unknown file-path action "${pAction}".`]
704
+ });
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Resolve a file path relative to the staging directory.
710
+ * Absolute paths are returned as-is.
711
+ */
712
+ _resolvePath(pFilePath, pStagingPath)
713
+ {
714
+ if (!pFilePath)
715
+ {
716
+ return null;
717
+ }
718
+ if (libPath.isAbsolute(pFilePath))
719
+ {
720
+ return pFilePath;
721
+ }
722
+ return libPath.join(pStagingPath, pFilePath);
723
+ }
724
+ }
725
+
726
+ module.exports = OratorConversionBeaconProvider;