orator-conversion 1.0.8 → 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.8",
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)