orator-conversion 1.0.7 → 1.0.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orator-conversion",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "File format conversion endpoints for Orator service servers.",
5
5
  "main": "source/Orator-File-Translation.js",
6
6
  "scripts": {
@@ -43,13 +43,13 @@
43
43
  "dependencies": {
44
44
  "fable-serviceproviderbase": "^3.0.19",
45
45
  "sharp": "^0.34.5",
46
- "ultravisor-beacon": "^0.0.8",
46
+ "ultravisor-beacon": "^0.0.9",
47
47
  "ws": "^8.20.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "fable": "^3.1.67",
51
51
  "orator": "^6.0.4",
52
- "orator-serviceserver-restify": "^2.0.9",
52
+ "orator-serviceserver-restify": "^2.0.10",
53
53
  "quackage": "^1.0.65"
54
54
  }
55
55
  }
@@ -767,6 +767,116 @@ class ConversionCore
767
767
  });
768
768
  }
769
769
 
770
+ /**
771
+ * Extract MULTIPLE frames from a video file in a single work item.
772
+ *
773
+ * This is the batch counterpart to videoExtractFrame. It exists so the
774
+ * dispatcher can request all N frames the video explorer wants in one
775
+ * trip — instead of triggering an entire operation graph 20 times — which
776
+ * means one address resolve, one file-transfer / shared-fs check, one
777
+ * probe (optional), and one ffmpeg invocation per frame all served from
778
+ * the same on-disk file.
779
+ *
780
+ * Each frame extraction is a separate ffmpeg call internally because
781
+ * ffmpeg's `-ss` before `-i` is fast (uses container index), and chaining
782
+ * `select=eq(t,X)+eq(t,Y)+...` filters is brittle and produces wrong-sized
783
+ * outputs for variable-bitrate streams. Sequential single-frame extracts
784
+ * give us the same correctness as videoExtractFrame, just amortized.
785
+ *
786
+ * @param {string} pInputPath - Path to the input video file.
787
+ * @param {Array} pFrameSpecs - Frame specs:
788
+ * [
789
+ * { Timestamp: '00:00:05.000', OutputPath: '/abs/path/frame_0000.jpg' },
790
+ * { Timestamp: '00:00:10.000', OutputPath: '/abs/path/frame_0001.jpg' },
791
+ * ...
792
+ * ]
793
+ * @param {object} pOptions - Shared extract options applied to every frame:
794
+ * { Width, Height }
795
+ * @param {Function} fCallback - Called with (pError, pResult) where
796
+ * pResult is { Frames: [{ Index, Timestamp, OutputPath, Size, Success, Error? }, ...] }
797
+ */
798
+ videoExtractFramesBatch(pInputPath, pFrameSpecs, pOptions, fCallback)
799
+ {
800
+ let tmpSelf = this;
801
+
802
+ if (!Array.isArray(pFrameSpecs) || pFrameSpecs.length === 0)
803
+ {
804
+ return fCallback(new Error('videoExtractFramesBatch: Frames array is required and must be non-empty.'));
805
+ }
806
+
807
+ // Quick existence check on the input video — fail fast rather than per-frame
808
+ if (!libFS.existsSync(pInputPath))
809
+ {
810
+ return fCallback(new Error(`videoExtractFramesBatch: input video not found: ${pInputPath}`));
811
+ }
812
+
813
+ let tmpResults = [];
814
+ let tmpIndex = 0;
815
+
816
+ let _extractNext = () =>
817
+ {
818
+ if (tmpIndex >= pFrameSpecs.length)
819
+ {
820
+ return fCallback(null, { Frames: tmpResults });
821
+ }
822
+
823
+ let tmpI = tmpIndex;
824
+ let tmpSpec = pFrameSpecs[tmpI];
825
+ tmpIndex++;
826
+
827
+ if (!tmpSpec || !tmpSpec.OutputPath || !tmpSpec.Timestamp)
828
+ {
829
+ tmpResults.push(
830
+ {
831
+ Index: tmpI,
832
+ Timestamp: tmpSpec ? tmpSpec.Timestamp : null,
833
+ OutputPath: tmpSpec ? tmpSpec.OutputPath : null,
834
+ Size: 0,
835
+ Success: false,
836
+ Error: 'Missing Timestamp or OutputPath'
837
+ });
838
+ return _extractNext();
839
+ }
840
+
841
+ tmpSelf.videoExtractFrame(pInputPath, tmpSpec.OutputPath,
842
+ {
843
+ Timestamp: tmpSpec.Timestamp,
844
+ Width: pOptions ? pOptions.Width : undefined,
845
+ Height: pOptions ? pOptions.Height : undefined
846
+ },
847
+ (pError, pResultPath) =>
848
+ {
849
+ if (pError)
850
+ {
851
+ tmpResults.push(
852
+ {
853
+ Index: tmpI,
854
+ Timestamp: tmpSpec.Timestamp,
855
+ OutputPath: tmpSpec.OutputPath,
856
+ Size: 0,
857
+ Success: false,
858
+ Error: pError.message
859
+ });
860
+ return _extractNext();
861
+ }
862
+
863
+ let tmpSize = 0;
864
+ try { tmpSize = libFS.statSync(pResultPath).size; } catch (pIgnore) { /* ignore */ }
865
+ tmpResults.push(
866
+ {
867
+ Index: tmpI,
868
+ Timestamp: tmpSpec.Timestamp,
869
+ OutputPath: pResultPath,
870
+ Size: tmpSize,
871
+ Success: true
872
+ });
873
+ return _extractNext();
874
+ });
875
+ };
876
+
877
+ _extractNext();
878
+ }
879
+
770
880
  /**
771
881
  * Generate a thumbnail from a video file.
772
882
  *
@@ -192,6 +192,18 @@ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
192
192
  { Name: 'Height', DataType: 'Number', Required: false, Description: 'Scale height (width auto)' }
193
193
  ]
194
194
  },
195
+ 'VideoExtractFrames':
196
+ {
197
+ Description: 'Extract MULTIPLE frames from a video file in a single work item. Each frame spec includes a timestamp and an output filename. All frames are written to the same OutputDir, which must be writable from this beacon (typically via shared filesystem with the dispatcher). Returns a JSON manifest with per-frame results.',
198
+ SettingsSchema:
199
+ [
200
+ { Name: 'InputFile', DataType: 'String', Required: true, Description: 'Path to input video file' },
201
+ { Name: 'OutputDir', DataType: 'String', Required: true, Description: 'Absolute directory where frames should be written. Must be writable from this beacon.' },
202
+ { Name: 'Frames', DataType: 'String', Required: true, Description: 'JSON-encoded array of {Timestamp, Filename} pairs, e.g. [{"Timestamp":"00:00:05","Filename":"frame_0000.jpg"}, ...]' },
203
+ { Name: 'Width', DataType: 'Number', Required: false, Description: 'Scale width applied to every frame' },
204
+ { Name: 'Height', DataType: 'Number', Required: false, Description: 'Scale height applied to every frame' }
205
+ ]
206
+ },
195
207
  'VideoThumbnail':
196
208
  {
197
209
  Description: 'Generate a thumbnail from a video file.',
@@ -325,7 +337,7 @@ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
325
337
  tmpLog.info(`[OratorConversion] Input file OK: ${tmpInputPath} (${libFS.statSync(tmpInputPath).size} bytes)`);
326
338
 
327
339
  // File-path actions skip the buffer read — Sharp and ffmpeg handle files directly
328
- let tmpFilePathActions = { 'ImageResize': true, 'ImageConvert': true, 'MediaProbe': true, 'VideoExtractFrame': true, 'VideoThumbnail': true, 'AudioExtractSegment': true, 'AudioWaveform': true };
340
+ let tmpFilePathActions = { 'ImageResize': true, 'ImageConvert': true, 'MediaProbe': true, 'VideoExtractFrame': true, 'VideoExtractFrames': true, 'VideoThumbnail': true, 'AudioExtractSegment': true, 'AudioWaveform': true };
329
341
  if (tmpFilePathActions[pAction])
330
342
  {
331
343
  return this._executeFilePathAction(pAction, tmpSettings, tmpInputPath, tmpOutputPath, fCallback, fReportProgress);
@@ -605,6 +617,144 @@ class OratorConversionBeaconProvider extends libBeaconCapabilityProvider
605
617
  break;
606
618
  }
607
619
 
620
+ case 'VideoExtractFrames':
621
+ {
622
+ // Batch frame extraction. Reads InputFile once, extracts N frames
623
+ // to OutputDir, returns a JSON manifest. Used by the video explorer
624
+ // to avoid 20 separate work-item dispatches per video.
625
+ if (!this._FfmpegAvailable)
626
+ {
627
+ return fCallback(null, {
628
+ Outputs: { StdOut: 'ffmpeg not available on this beacon.', ExitCode: -1, Result: '' },
629
+ Log: ['OratorConversion: ffmpeg required for VideoExtractFrames but not found.']
630
+ });
631
+ }
632
+
633
+ let tmpOutputDir = pSettings.OutputDir;
634
+ if (!tmpOutputDir)
635
+ {
636
+ return fCallback(null, {
637
+ Outputs: { StdOut: 'No OutputDir specified.', ExitCode: -1, Result: '' },
638
+ Log: ['OratorConversion VideoExtractFrames: OutputDir is required.']
639
+ });
640
+ }
641
+
642
+ let tmpFrameSpecs;
643
+ try
644
+ {
645
+ tmpFrameSpecs = JSON.parse(pSettings.Frames || '[]');
646
+ }
647
+ catch (pParseError)
648
+ {
649
+ return fCallback(null, {
650
+ Outputs: { StdOut: `Invalid Frames JSON: ${pParseError.message}`, ExitCode: -1, Result: '' },
651
+ Log: [`OratorConversion VideoExtractFrames: failed to parse Frames JSON: ${pParseError.message}`]
652
+ });
653
+ }
654
+
655
+ if (!Array.isArray(tmpFrameSpecs) || tmpFrameSpecs.length === 0)
656
+ {
657
+ return fCallback(null, {
658
+ Outputs: { StdOut: 'Frames array is empty.', ExitCode: -1, Result: '' },
659
+ Log: ['OratorConversion VideoExtractFrames: Frames must be a non-empty array.']
660
+ });
661
+ }
662
+
663
+ // Ensure OutputDir exists. With shared-fs the dispatcher already
664
+ // created it; mkdir -p is a no-op in that case but needed when
665
+ // the dispatcher is on a different host.
666
+ try
667
+ {
668
+ libFS.mkdirSync(tmpOutputDir, { recursive: true });
669
+ }
670
+ catch (pMkdirError)
671
+ {
672
+ return fCallback(null, {
673
+ Outputs: { StdOut: `Could not create OutputDir: ${pMkdirError.message}`, ExitCode: -1, Result: '' },
674
+ Log: [`OratorConversion VideoExtractFrames: mkdir failed for ${tmpOutputDir}: ${pMkdirError.message}`]
675
+ });
676
+ }
677
+
678
+ // Resolve absolute output paths from { Timestamp, Filename } pairs
679
+ let tmpResolvedSpecs = [];
680
+ for (let i = 0; i < tmpFrameSpecs.length; i++)
681
+ {
682
+ let tmpEntry = tmpFrameSpecs[i];
683
+ if (!tmpEntry || !tmpEntry.Timestamp || !tmpEntry.Filename)
684
+ {
685
+ tmpResolvedSpecs.push(tmpEntry);
686
+ continue;
687
+ }
688
+ // Reject filenames with path separators or traversal — the
689
+ // caller is meant to give us flat names like frame_0000.jpg
690
+ if (tmpEntry.Filename.indexOf('/') !== -1 ||
691
+ tmpEntry.Filename.indexOf('\\') !== -1 ||
692
+ tmpEntry.Filename.indexOf('..') !== -1)
693
+ {
694
+ return fCallback(null, {
695
+ Outputs: { StdOut: `Invalid filename in Frames: ${tmpEntry.Filename}`, ExitCode: -1, Result: '' },
696
+ Log: [`OratorConversion VideoExtractFrames: refusing filename with path separators: ${tmpEntry.Filename}`]
697
+ });
698
+ }
699
+ tmpResolvedSpecs.push(
700
+ {
701
+ Timestamp: tmpEntry.Timestamp,
702
+ OutputPath: libPath.join(tmpOutputDir, tmpEntry.Filename)
703
+ });
704
+ }
705
+
706
+ if (fReportProgress) fReportProgress({ Percent: 5, Message: `Extracting ${tmpResolvedSpecs.length} frames...` });
707
+
708
+ this._Core.videoExtractFramesBatch(pInputPath, tmpResolvedSpecs,
709
+ {
710
+ Width: pSettings.Width,
711
+ Height: pSettings.Height
712
+ },
713
+ (pError, pBatchResult) =>
714
+ {
715
+ if (pError)
716
+ {
717
+ return fCallback(null, {
718
+ Outputs: { StdOut: `Batch frame extraction failed: ${pError.message}`, ExitCode: 1, Result: '' },
719
+ Log: [`OratorConversion VideoExtractFrames error: ${pError.message}`]
720
+ });
721
+ }
722
+
723
+ // Re-attach the original Filename field to each result so
724
+ // the dispatcher can correlate against its own naming.
725
+ let tmpFrames = pBatchResult.Frames || [];
726
+ for (let i = 0; i < tmpFrames.length; i++)
727
+ {
728
+ let tmpOriginal = tmpFrameSpecs[i];
729
+ if (tmpOriginal && tmpOriginal.Filename)
730
+ {
731
+ tmpFrames[i].Filename = tmpOriginal.Filename;
732
+ }
733
+ }
734
+
735
+ let tmpSuccessCount = tmpFrames.filter((f) => f.Success).length;
736
+ let tmpManifest =
737
+ {
738
+ FrameCount: tmpFrames.length,
739
+ SuccessCount: tmpSuccessCount,
740
+ OutputDir: tmpOutputDir,
741
+ Frames: tmpFrames
742
+ };
743
+
744
+ return fCallback(null, {
745
+ Outputs:
746
+ {
747
+ StdOut: `Extracted ${tmpSuccessCount}/${tmpFrames.length} frames from ${pSettings.InputFile} → ${tmpOutputDir}`,
748
+ ExitCode: tmpSuccessCount === tmpFrames.length ? 0 : 1,
749
+ Result: JSON.stringify(tmpManifest),
750
+ ContentType: 'application/json'
751
+ },
752
+ Log: [`OratorConversion VideoExtractFrames: ${tmpSuccessCount}/${tmpFrames.length} frames written to ${tmpOutputDir}`]
753
+ });
754
+ });
755
+ break;
756
+ }
757
+
608
758
  case 'VideoThumbnail':
609
759
  {
610
760
  if (!this._FfmpegAvailable)
@@ -529,6 +529,10 @@ class OratorFileTranslation extends libFableServiceProviderBase
529
529
  * - MaxConcurrent {number} Max concurrent work items (default: 2)
530
530
  * - StagingPath {string} Local staging directory (default: cwd)
531
531
  * - Tags {object} Beacon tags (default: {})
532
+ * - HostID {string} Override for the host identity (default: os.hostname())
533
+ * - SharedMounts {Array<{MountID, Root}>} Shared filesystem mounts to advertise
534
+ * so the reachability matrix can detect zero-copy paths between this
535
+ * beacon and other beacons on the same host
532
536
  * @param {Function} fCallback Called with (pError, pBeaconInfo)
533
537
  */
534
538
  connectBeacon(pBeaconConfig, fCallback)
@@ -560,8 +564,10 @@ class OratorFileTranslation extends libFableServiceProviderBase
560
564
  // Already exists — fine
561
565
  }
562
566
 
563
- // Instantiate the beacon service with the provided config
564
- this._BeaconService = this.fable.instantiateServiceProviderWithoutRegistration('UltravisorBeacon',
567
+ // Build the beacon service options. SharedMounts is forwarded as-is so
568
+ // the caller controls which mounts get advertised — and matches the
569
+ // MountID computation done by other beacons on the same host.
570
+ let tmpBeaconOptions =
565
571
  {
566
572
  ServerURL: pBeaconConfig.ServerURL,
567
573
  Name: pBeaconConfig.Name || 'orator-conversion',
@@ -570,7 +576,18 @@ class OratorFileTranslation extends libFableServiceProviderBase
570
576
  StagingPath: tmpStagingPath,
571
577
  Tags: pBeaconConfig.Tags || {},
572
578
  BindAddresses: pBeaconConfig.BindAddresses || []
573
- });
579
+ };
580
+ if (pBeaconConfig.HostID)
581
+ {
582
+ tmpBeaconOptions.HostID = pBeaconConfig.HostID;
583
+ }
584
+ if (Array.isArray(pBeaconConfig.SharedMounts) && pBeaconConfig.SharedMounts.length > 0)
585
+ {
586
+ tmpBeaconOptions.SharedMounts = pBeaconConfig.SharedMounts;
587
+ }
588
+
589
+ // Instantiate the beacon service with the provided config
590
+ this._BeaconService = this.fable.instantiateServiceProviderWithoutRegistration('UltravisorBeacon', tmpBeaconOptions);
574
591
 
575
592
  // Create the MediaConversion capability provider and register it
576
593
  let tmpProvider = new libOratorConversionBeaconProvider(