retold-remote 0.0.13 → 0.0.17

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.
Files changed (30) hide show
  1. package/css/retold-remote.css +75 -0
  2. package/docs/_sidebar.md +2 -0
  3. package/docs/ultravisor-configuration.md +212 -0
  4. package/docs/ultravisor-integration.md +140 -0
  5. package/package.json +121 -96
  6. package/source/cli/RetoldRemote-Server-Setup.js +26 -0
  7. package/source/providers/Pict-Provider-GalleryNavigation.js +11 -3
  8. package/source/providers/keyboard-handlers/KeyHandler-AudioExplorer.js +5 -0
  9. package/source/providers/keyboard-handlers/KeyHandler-VideoExplorer.js +16 -0
  10. package/source/server/RetoldRemote-AudioWaveformService.js +101 -23
  11. package/source/server/RetoldRemote-EbookService.js +119 -6
  12. package/source/server/RetoldRemote-ImageService.js +254 -2
  13. package/source/server/RetoldRemote-MediaService.js +274 -37
  14. package/source/server/RetoldRemote-ToolDetector.js +27 -3
  15. package/source/server/RetoldRemote-UltravisorDispatcher.js +599 -0
  16. package/source/server/RetoldRemote-VideoFrameService.js +309 -77
  17. package/source/views/PictView-Remote-AudioExplorer.js +28 -14
  18. package/source/views/PictView-Remote-ImageExplorer.js +31 -11
  19. package/source/views/PictView-Remote-VLCSetup.js +22 -12
  20. package/source/views/PictView-Remote-VideoExplorer.js +29 -14
  21. package/web-application/css/retold-remote.css +75 -0
  22. package/web-application/docs/_sidebar.md +2 -0
  23. package/web-application/docs/ultravisor-configuration.md +212 -0
  24. package/web-application/docs/ultravisor-integration.md +140 -0
  25. package/web-application/js/pict.min.js +2 -2
  26. package/web-application/js/pict.min.js.map +1 -1
  27. package/web-application/retold-remote.js +4763 -4238
  28. package/web-application/retold-remote.js.map +1 -1
  29. package/web-application/retold-remote.min.js +16 -16
  30. package/web-application/retold-remote.min.js.map +1 -1
@@ -78,6 +78,19 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
78
78
 
79
79
  // Tool capabilities — set by MediaService after ToolDetector runs
80
80
  this._capabilities = {};
81
+
82
+ // Ultravisor dispatcher — set via setDispatcher()
83
+ this._dispatcher = null;
84
+ }
85
+
86
+ /**
87
+ * Set the Ultravisor dispatcher for offloading heavy processing.
88
+ *
89
+ * @param {object} pDispatcher - RetoldRemoteUltravisorDispatcher instance
90
+ */
91
+ setDispatcher(pDispatcher)
92
+ {
93
+ this._dispatcher = pDispatcher;
81
94
  }
82
95
 
83
96
  /**
@@ -336,6 +349,7 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
336
349
 
337
350
  /**
338
351
  * Convert a raw image to JPEG using dcraw piped through Sharp.
352
+ * Tries Ultravisor dispatch first, falls back to local execution.
339
353
  * dcraw outputs PPM to stdout; Sharp converts to JPEG.
340
354
  *
341
355
  * @param {string} pAbsPath - Raw file path
@@ -344,6 +358,71 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
344
358
  * @param {Function} fCallback - Callback(pError)
345
359
  */
346
360
  _convertWithDcraw(pAbsPath, pOutputPath, pFullResolution, fCallback)
361
+ {
362
+ let tmpSelf = this;
363
+
364
+ // Try Ultravisor dispatch first
365
+ if (this._dispatcher && this._dispatcher.isAvailable())
366
+ {
367
+ let tmpRelPath;
368
+ try
369
+ {
370
+ tmpRelPath = libPath.relative(this.contentPath, pAbsPath);
371
+ }
372
+ catch (pErr)
373
+ {
374
+ tmpRelPath = null;
375
+ }
376
+
377
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
378
+ {
379
+ let tmpHalfFlag = pFullResolution ? '' : ' -h';
380
+ let tmpOutputFilename = libPath.basename(pOutputPath);
381
+ let tmpCommand = `dcraw -c -w${tmpHalfFlag} "{SourcePath}" | convert ppm:- jpeg:"{OutputPath}"`;
382
+
383
+ this._dispatcher.dispatchMediaCommand(
384
+ {
385
+ Command: tmpCommand,
386
+ InputPath: tmpRelPath,
387
+ OutputFilename: tmpOutputFilename,
388
+ AffinityKey: tmpRelPath,
389
+ TimeoutMs: 180000
390
+ },
391
+ (pDispatchError, pResult) =>
392
+ {
393
+ if (!pDispatchError && pResult && pResult.OutputBuffer)
394
+ {
395
+ try
396
+ {
397
+ let tmpDir = libPath.dirname(pOutputPath);
398
+ if (!libFs.existsSync(tmpDir))
399
+ {
400
+ libFs.mkdirSync(tmpDir, { recursive: true });
401
+ }
402
+ libFs.writeFileSync(pOutputPath, pResult.OutputBuffer);
403
+ tmpSelf.fable.log.info(`Raw conversion via Ultravisor (dcraw) for ${tmpRelPath}`);
404
+ return fCallback(null);
405
+ }
406
+ catch (pWriteError)
407
+ {
408
+ // Fall through to local
409
+ }
410
+ }
411
+
412
+ // Fall through to local processing
413
+ tmpSelf._convertWithDcrawLocal(pAbsPath, pOutputPath, pFullResolution, fCallback);
414
+ });
415
+ return;
416
+ }
417
+ }
418
+
419
+ return this._convertWithDcrawLocal(pAbsPath, pOutputPath, pFullResolution, fCallback);
420
+ }
421
+
422
+ /**
423
+ * Convert a raw image to JPEG locally using dcraw piped through Sharp.
424
+ */
425
+ _convertWithDcrawLocal(pAbsPath, pOutputPath, pFullResolution, fCallback)
347
426
  {
348
427
  if (!this._capabilities.dcraw || !this._sharp)
349
428
  {
@@ -413,6 +492,7 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
413
492
 
414
493
  /**
415
494
  * Convert a raw image to JPEG using ImageMagick's convert command.
495
+ * Tries Ultravisor dispatch first, falls back to local execution.
416
496
  * Works if ImageMagick has the dcraw/ufraw delegate installed.
417
497
  *
418
498
  * @param {string} pAbsPath - Raw file path
@@ -420,6 +500,70 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
420
500
  * @param {Function} fCallback - Callback(pError)
421
501
  */
422
502
  _convertWithImageMagick(pAbsPath, pOutputPath, fCallback)
503
+ {
504
+ let tmpSelf = this;
505
+
506
+ // Try Ultravisor dispatch first
507
+ if (this._dispatcher && this._dispatcher.isAvailable())
508
+ {
509
+ let tmpRelPath;
510
+ try
511
+ {
512
+ tmpRelPath = libPath.relative(this.contentPath, pAbsPath);
513
+ }
514
+ catch (pErr)
515
+ {
516
+ tmpRelPath = null;
517
+ }
518
+
519
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
520
+ {
521
+ let tmpOutputFilename = libPath.basename(pOutputPath);
522
+ let tmpCommand = `convert "{SourcePath}" -auto-orient -quality 92 "{OutputPath}"`;
523
+
524
+ this._dispatcher.dispatchMediaCommand(
525
+ {
526
+ Command: tmpCommand,
527
+ InputPath: tmpRelPath,
528
+ OutputFilename: tmpOutputFilename,
529
+ AffinityKey: tmpRelPath,
530
+ TimeoutMs: 180000
531
+ },
532
+ (pDispatchError, pResult) =>
533
+ {
534
+ if (!pDispatchError && pResult && pResult.OutputBuffer)
535
+ {
536
+ try
537
+ {
538
+ let tmpDir = libPath.dirname(pOutputPath);
539
+ if (!libFs.existsSync(tmpDir))
540
+ {
541
+ libFs.mkdirSync(tmpDir, { recursive: true });
542
+ }
543
+ libFs.writeFileSync(pOutputPath, pResult.OutputBuffer);
544
+ tmpSelf.fable.log.info(`Raw conversion via Ultravisor (ImageMagick) for ${tmpRelPath}`);
545
+ return fCallback(null);
546
+ }
547
+ catch (pWriteError)
548
+ {
549
+ // Fall through to local
550
+ }
551
+ }
552
+
553
+ // Fall through to local processing
554
+ tmpSelf._convertWithImageMagickLocal(pAbsPath, pOutputPath, fCallback);
555
+ });
556
+ return;
557
+ }
558
+ }
559
+
560
+ return this._convertWithImageMagickLocal(pAbsPath, pOutputPath, fCallback);
561
+ }
562
+
563
+ /**
564
+ * Convert a raw image to JPEG locally using ImageMagick's convert command.
565
+ */
566
+ _convertWithImageMagickLocal(pAbsPath, pOutputPath, fCallback)
423
567
  {
424
568
  if (!this._capabilities.imagemagick)
425
569
  {
@@ -608,7 +752,11 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
608
752
 
609
753
  if (!this._sharp && !this._capabilities.imagemagick)
610
754
  {
611
- return fCallback(new Error('Neither sharp nor ImageMagick is available.'));
755
+ // Neither Sharp nor ImageMagick available locally — check for Ultravisor
756
+ if (!(this._dispatcher && this._dispatcher.isAvailable()))
757
+ {
758
+ return fCallback(new Error('Neither sharp nor ImageMagick is available.'));
759
+ }
612
760
  }
613
761
 
614
762
  let tmpMaxDim = pMaxDimension || this.options.DefaultMaxPreviewDimension;
@@ -682,11 +830,20 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
682
830
  {
683
831
  this._doGeneratePreview(pAbsPath, pAbsPath, pRelPath, tmpMaxDim, tmpCacheKey, tmpOutputFilename, tmpCacheDir, tmpManifestPath, tmpOutputPath, tmpStat, false, fCallback);
684
832
  }
685
- else
833
+ else if (this._capabilities.imagemagick)
686
834
  {
687
835
  // No Sharp available — use ImageMagick for standard images too
688
836
  this._doGeneratePreviewWithImageMagick(pAbsPath, pRelPath, tmpMaxDim, tmpCacheKey, tmpOutputFilename, tmpManifestPath, tmpOutputPath, tmpStat, false, fCallback);
689
837
  }
838
+ else if (this._dispatcher && this._dispatcher.isAvailable())
839
+ {
840
+ // No local tools available — dispatch to Ultravisor beacon
841
+ this._doGeneratePreviewWithDispatcher(pAbsPath, pRelPath, tmpMaxDim, tmpCacheKey, tmpOutputFilename, tmpCacheDir, tmpManifestPath, tmpOutputPath, tmpStat, tmpIsRaw, fCallback);
842
+ }
843
+ else
844
+ {
845
+ return fCallback(new Error('No preview tools available.'));
846
+ }
690
847
  }
691
848
 
692
849
  /**
@@ -943,6 +1100,101 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
943
1100
  }
944
1101
  }
945
1102
 
1103
+ /**
1104
+ * Generate a preview by dispatching to an Ultravisor beacon.
1105
+ * Uses the MediaConversion capability (ImageResize) with a shell
1106
+ * convert fallback. The result is written to the cache directory.
1107
+ *
1108
+ * @param {string} pInputPath - Absolute path to the source image
1109
+ * @param {string} pRelPath - Relative path (for response/logging)
1110
+ * @param {number} pMaxDim - Max dimension
1111
+ * @param {string} pCacheKey - Cache key
1112
+ * @param {string} pOutputFilename - Output filename
1113
+ * @param {string} pCacheDir - Cache directory path
1114
+ * @param {string} pManifestPath - Manifest file path
1115
+ * @param {string} pOutputPath - Output file path
1116
+ * @param {object} pStat - Original file stat
1117
+ * @param {boolean} pIsRaw - Whether this is a raw camera format
1118
+ * @param {Function} fCallback - Callback(pError, pResult)
1119
+ */
1120
+ _doGeneratePreviewWithDispatcher(pInputPath, pRelPath, pMaxDim, pCacheKey, pOutputFilename, pCacheDir, pManifestPath, pOutputPath, pStat, pIsRaw, fCallback)
1121
+ {
1122
+ let tmpSelf = this;
1123
+ let tmpRelPath;
1124
+
1125
+ try
1126
+ {
1127
+ tmpRelPath = libPath.relative(this.contentPath, pInputPath);
1128
+ }
1129
+ catch (pErr)
1130
+ {
1131
+ return fCallback(new Error('Could not resolve relative path for dispatch.'));
1132
+ }
1133
+
1134
+ if (!tmpRelPath || tmpRelPath.startsWith('..'))
1135
+ {
1136
+ return fCallback(new Error('File is outside content root.'));
1137
+ }
1138
+
1139
+ this._dispatcher.dispatchConversion(
1140
+ {
1141
+ Action: 'ImageResize',
1142
+ InputPath: tmpRelPath,
1143
+ OutputFilename: pOutputFilename,
1144
+ Width: pMaxDim,
1145
+ Height: pMaxDim,
1146
+ Format: 'jpeg',
1147
+ Quality: this.options.PreviewQuality || 85,
1148
+ AffinityKey: tmpRelPath,
1149
+ TimeoutMs: 120000,
1150
+ FallbackCommand: `convert "{SourcePath}"[0] -auto-orient -resize ${pMaxDim}x${pMaxDim} -quality ${this.options.PreviewQuality || 85} "{OutputPath}"`
1151
+ },
1152
+ (pDispatchError, pResult) =>
1153
+ {
1154
+ if (pDispatchError || !pResult || !pResult.OutputBuffer)
1155
+ {
1156
+ return fCallback(new Error('Ultravisor preview generation failed: ' + (pDispatchError ? pDispatchError.message : 'no output')));
1157
+ }
1158
+
1159
+ try
1160
+ {
1161
+ libFs.writeFileSync(pOutputPath, pResult.OutputBuffer);
1162
+ }
1163
+ catch (pWriteError)
1164
+ {
1165
+ return fCallback(new Error('Failed to write preview output: ' + pWriteError.message));
1166
+ }
1167
+
1168
+ let tmpResult =
1169
+ {
1170
+ Success: true,
1171
+ SourcePath: pRelPath,
1172
+ CacheKey: pCacheKey,
1173
+ OutputFilename: pOutputFilename,
1174
+ Width: pMaxDim,
1175
+ Height: pMaxDim,
1176
+ OrigWidth: 0,
1177
+ OrigHeight: 0,
1178
+ FileSize: pResult.OutputBuffer.length,
1179
+ NeedsPreview: true,
1180
+ IsRawFormat: pIsRaw,
1181
+ GeneratedAt: new Date().toISOString()
1182
+ };
1183
+
1184
+ try
1185
+ {
1186
+ libFs.writeFileSync(pManifestPath, JSON.stringify(tmpResult, null, '\t'));
1187
+ }
1188
+ catch (pWriteError)
1189
+ {
1190
+ tmpSelf.fable.log.warn(`Could not write preview manifest: ${pWriteError.message}`);
1191
+ }
1192
+
1193
+ tmpSelf.fable.log.info(`Generated image preview (Ultravisor): ${pRelPath}`);
1194
+ return fCallback(null, tmpResult);
1195
+ });
1196
+ }
1197
+
946
1198
  // ---------------------------------------------------------------
947
1199
  // DZI tile generation
948
1200
  // ---------------------------------------------------------------
@@ -52,9 +52,22 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
52
52
  this.thumbnailCache = new libThumbnailCache(this.fable);
53
53
  this.pathRegistry = this.options.PathRegistry || null;
54
54
 
55
+ // Ultravisor dispatcher — set via setDispatcher()
56
+ this._dispatcher = null;
57
+
55
58
  this.fable.log.info(`Media Service: capabilities = ${JSON.stringify(this.capabilities)}`);
56
59
  }
57
60
 
61
+ /**
62
+ * Set the Ultravisor dispatcher for offloading heavy processing.
63
+ *
64
+ * @param {object} pDispatcher - RetoldRemoteUltravisorDispatcher instance
65
+ */
66
+ setDispatcher(pDispatcher)
67
+ {
68
+ this._dispatcher = pDispatcher;
69
+ }
70
+
58
71
  /**
59
72
  * Sanitize a file path to prevent directory traversal.
60
73
  *
@@ -483,6 +496,53 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
483
496
  }
484
497
  }
485
498
 
499
+ // Try Ultravisor dispatch as last resort for image thumbnails.
500
+ // Uses structured MediaConversion capability (orator-conversion beacon)
501
+ // with shell convert fallback if MediaConversion is not available.
502
+ if (this._dispatcher && this._dispatcher.isAvailable())
503
+ {
504
+ let tmpRelPath;
505
+ try
506
+ {
507
+ tmpRelPath = libPath.relative(this.contentPath, pFullPath);
508
+ }
509
+ catch (pErr)
510
+ {
511
+ tmpRelPath = null;
512
+ }
513
+
514
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
515
+ {
516
+ let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'jpeg';
517
+ let tmpOutputFilename = `thumbnail.${pFormat === 'webp' ? 'webp' : 'jpg'}`;
518
+
519
+ this._dispatcher.dispatchConversion(
520
+ {
521
+ Action: 'ImageResize',
522
+ InputPath: tmpRelPath,
523
+ OutputFilename: tmpOutputFilename,
524
+ Width: pWidth,
525
+ Height: pHeight,
526
+ Format: tmpOutputFormat,
527
+ Quality: 80,
528
+ AffinityKey: tmpRelPath,
529
+ TimeoutMs: 30000,
530
+ FallbackCommand: `convert "{SourcePath}" -thumbnail ${pWidth}x${pHeight} -auto-orient ${tmpOutputFormat}:"{OutputPath}"`
531
+ },
532
+ (pDispatchError, pResult) =>
533
+ {
534
+ if (!pDispatchError && pResult && pResult.OutputBuffer)
535
+ {
536
+ this.fable.log.info(`Image thumbnail generated via Ultravisor for ${tmpRelPath}`);
537
+ return fCallback(null, pResult.OutputBuffer);
538
+ }
539
+
540
+ return fCallback(new Error('No image thumbnail tools available.'));
541
+ });
542
+ return;
543
+ }
544
+ }
545
+
486
546
  return fCallback(new Error('No image thumbnail tools available.'));
487
547
  }
488
548
 
@@ -623,10 +683,11 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
623
683
  }
624
684
 
625
685
  /**
626
- * Fallback raw thumbnail generation: ImageMagick → exifr embedded preview.
686
+ * Fallback raw thumbnail generation: ImageMagick → Ultravisor MediaConversion → exifr embedded preview.
627
687
  */
628
688
  _generateRawThumbnailFallback(pFullPath, pWidth, pHeight, pFormat, fCallback)
629
689
  {
690
+ let tmpSelf = this;
630
691
  let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'jpeg';
631
692
 
632
693
  // Strategy 2: ImageMagick (may have dcraw delegate)
@@ -640,11 +701,67 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
640
701
  }
641
702
  catch (pError)
642
703
  {
643
- // Fall through to exifr
704
+ // Fall through to Ultravisor
705
+ }
706
+ }
707
+
708
+ // Strategy 2.5: Try Ultravisor MediaConversion (beacon may have Sharp
709
+ // even if local machine does not have ImageMagick)
710
+ if (this._dispatcher && this._dispatcher.isAvailable())
711
+ {
712
+ let tmpRelPath;
713
+ try
714
+ {
715
+ tmpRelPath = libPath.relative(this.contentPath, pFullPath);
716
+ }
717
+ catch (pErr)
718
+ {
719
+ tmpRelPath = null;
720
+ }
721
+
722
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
723
+ {
724
+ let tmpOutputFilename = `thumbnail.${pFormat === 'webp' ? 'webp' : 'jpg'}`;
725
+
726
+ this._dispatcher.dispatchConversion(
727
+ {
728
+ Action: 'ImageResize',
729
+ InputPath: tmpRelPath,
730
+ OutputFilename: tmpOutputFilename,
731
+ Width: pWidth,
732
+ Height: pHeight,
733
+ Format: tmpOutputFormat,
734
+ Quality: 80,
735
+ AffinityKey: tmpRelPath,
736
+ TimeoutMs: 60000,
737
+ FallbackCommand: `convert "{SourcePath}" -thumbnail ${pWidth}x${pHeight} -auto-orient ${tmpOutputFormat}:"{OutputPath}"`
738
+ },
739
+ (pDispatchError, pResult) =>
740
+ {
741
+ if (!pDispatchError && pResult && pResult.OutputBuffer)
742
+ {
743
+ tmpSelf.fable.log.info(`Raw thumbnail generated via Ultravisor for ${tmpRelPath}`);
744
+ return fCallback(null, pResult.OutputBuffer);
745
+ }
746
+
747
+ // Fall through to exifr embedded preview
748
+ tmpSelf._generateRawThumbnailExifr(pFullPath, pWidth, pHeight, pFormat, fCallback);
749
+ });
750
+ return;
644
751
  }
645
752
  }
646
753
 
647
- // Strategy 3: Extract embedded JPEG preview via exifr, resize with sharp
754
+ return this._generateRawThumbnailExifr(pFullPath, pWidth, pHeight, pFormat, fCallback);
755
+ }
756
+
757
+ /**
758
+ * Strategy 3 for raw thumbnails: extract embedded JPEG preview via exifr,
759
+ * resize with sharp. Most cameras embed a full-size JPEG preview in the raw file.
760
+ */
761
+ _generateRawThumbnailExifr(pFullPath, pWidth, pHeight, pFormat, fCallback)
762
+ {
763
+ let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'jpeg';
764
+
648
765
  if (this.capabilities.sharp)
649
766
  {
650
767
  let tmpSharp = this.capabilities.sharpModule;
@@ -743,8 +860,62 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
743
860
 
744
861
  /**
745
862
  * Generate a video thumbnail by extracting a frame with ffmpeg.
863
+ * Tries Ultravisor dispatch first, falls back to local execution.
746
864
  */
747
865
  _generateVideoThumbnail(pFullPath, pWidth, pHeight, pFormat, fCallback)
866
+ {
867
+ let tmpSelf = this;
868
+ let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'mjpeg';
869
+
870
+ // Try Ultravisor dispatch first
871
+ if (this._dispatcher && this._dispatcher.isAvailable())
872
+ {
873
+ let tmpRelPath;
874
+ try
875
+ {
876
+ tmpRelPath = libPath.relative(this.contentPath, pFullPath);
877
+ }
878
+ catch (pErr)
879
+ {
880
+ tmpRelPath = null;
881
+ }
882
+
883
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
884
+ {
885
+ let tmpOutputFilename = `thumbnail.${pFormat === 'webp' ? 'webp' : 'jpg'}`;
886
+ let tmpCommand = `ffmpeg -ss 00:00:02 -i "{SourcePath}" -vframes 1 -vf "scale=${pWidth}:${pHeight}:force_original_aspect_ratio=decrease" -f ${tmpOutputFormat} "{OutputPath}"`;
887
+
888
+ this._dispatcher.dispatchMediaCommand(
889
+ {
890
+ Command: tmpCommand,
891
+ InputPath: tmpRelPath,
892
+ OutputFilename: tmpOutputFilename,
893
+ AffinityKey: tmpRelPath,
894
+ TimeoutMs: 60000
895
+ },
896
+ (pDispatchError, pResult) =>
897
+ {
898
+ if (!pDispatchError && pResult && pResult.OutputBuffer)
899
+ {
900
+ tmpSelf.fable.log.info(`Video thumbnail generated via Ultravisor for ${tmpRelPath}`);
901
+ return fCallback(null, pResult.OutputBuffer);
902
+ }
903
+
904
+ // Fall through to local processing
905
+ tmpSelf.fable.log.info(`Ultravisor dispatch failed for video thumbnail, falling back to local: ${pDispatchError ? pDispatchError.message : 'no output'}`);
906
+ tmpSelf._generateVideoThumbnailLocal(pFullPath, pWidth, pHeight, pFormat, fCallback);
907
+ });
908
+ return;
909
+ }
910
+ }
911
+
912
+ return this._generateVideoThumbnailLocal(pFullPath, pWidth, pHeight, pFormat, fCallback);
913
+ }
914
+
915
+ /**
916
+ * Generate a video thumbnail locally using ffmpeg.
917
+ */
918
+ _generateVideoThumbnailLocal(pFullPath, pWidth, pHeight, pFormat, fCallback)
748
919
  {
749
920
  try
750
921
  {
@@ -799,60 +970,126 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
799
970
 
800
971
  /**
801
972
  * Run ffprobe and parse the output.
973
+ * Tries Ultravisor dispatch first, falls back to local execution.
802
974
  */
803
975
  _ffprobe(pFullPath, fCallback)
976
+ {
977
+ let tmpSelf = this;
978
+
979
+ // Try Ultravisor dispatch first
980
+ if (this._dispatcher && this._dispatcher.isAvailable())
981
+ {
982
+ let tmpRelPath;
983
+ try
984
+ {
985
+ tmpRelPath = libPath.relative(this.contentPath, pFullPath);
986
+ }
987
+ catch (pErr)
988
+ {
989
+ tmpRelPath = null;
990
+ }
991
+
992
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
993
+ {
994
+ let tmpCommand = `ffprobe -v quiet -print_format json -show_format -show_streams "{SourcePath}"`;
995
+
996
+ this._dispatcher.dispatchMediaCommand(
997
+ {
998
+ Command: tmpCommand,
999
+ InputPath: tmpRelPath,
1000
+ AffinityKey: tmpRelPath,
1001
+ TimeoutMs: 30000
1002
+ },
1003
+ (pDispatchError, pResult) =>
1004
+ {
1005
+ if (!pDispatchError && pResult && pResult.Outputs && pResult.Outputs.StdOut)
1006
+ {
1007
+ try
1008
+ {
1009
+ let tmpData = JSON.parse(pResult.Outputs.StdOut);
1010
+ let tmpParsed = tmpSelf._parseFfprobeData(tmpData);
1011
+ tmpSelf.fable.log.info(`ffprobe via Ultravisor for ${tmpRelPath}`);
1012
+ return fCallback(null, tmpParsed);
1013
+ }
1014
+ catch (pParseError)
1015
+ {
1016
+ // Fall through to local
1017
+ }
1018
+ }
1019
+
1020
+ // Fall through to local processing
1021
+ tmpSelf._ffprobeLocal(pFullPath, fCallback);
1022
+ });
1023
+ return;
1024
+ }
1025
+ }
1026
+
1027
+ return this._ffprobeLocal(pFullPath, fCallback);
1028
+ }
1029
+
1030
+ /**
1031
+ * Run ffprobe locally and parse the output.
1032
+ */
1033
+ _ffprobeLocal(pFullPath, fCallback)
804
1034
  {
805
1035
  try
806
1036
  {
807
1037
  let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pFullPath}"`;
808
1038
  let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 10000 });
809
1039
  let tmpData = JSON.parse(tmpOutput.toString());
1040
+ return fCallback(null, this._parseFfprobeData(tmpData));
1041
+ }
1042
+ catch (pError)
1043
+ {
1044
+ return fCallback(pError);
1045
+ }
1046
+ }
810
1047
 
811
- let tmpResult = {};
1048
+ /**
1049
+ * Parse ffprobe JSON output into a normalized result object.
1050
+ */
1051
+ _parseFfprobeData(pData)
1052
+ {
1053
+ let tmpResult = {};
812
1054
 
813
- if (tmpData.format)
814
- {
815
- tmpResult.duration = parseFloat(tmpData.format.duration) || null;
816
- tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
1055
+ if (pData.format)
1056
+ {
1057
+ tmpResult.duration = parseFloat(pData.format.duration) || null;
1058
+ tmpResult.bitrate = parseInt(pData.format.bit_rate, 10) || null;
817
1059
 
818
- // Extract format-level tags (ID3, Vorbis comments, etc.)
819
- if (tmpData.format.tags)
1060
+ // Extract format-level tags (ID3, Vorbis comments, etc.)
1061
+ if (pData.format.tags)
1062
+ {
1063
+ tmpResult.tags = {};
1064
+ let tmpTagKeys = Object.keys(pData.format.tags);
1065
+ for (let t = 0; t < tmpTagKeys.length; t++)
820
1066
  {
821
- tmpResult.tags = {};
822
- let tmpTagKeys = Object.keys(tmpData.format.tags);
823
- for (let t = 0; t < tmpTagKeys.length; t++)
824
- {
825
- tmpResult.tags[tmpTagKeys[t].toLowerCase()] = tmpData.format.tags[tmpTagKeys[t]];
826
- }
1067
+ tmpResult.tags[tmpTagKeys[t].toLowerCase()] = pData.format.tags[tmpTagKeys[t]];
827
1068
  }
828
1069
  }
1070
+ }
829
1071
 
830
- // Find video stream for dimensions
831
- if (tmpData.streams)
1072
+ // Find video stream for dimensions
1073
+ if (pData.streams)
1074
+ {
1075
+ for (let i = 0; i < pData.streams.length; i++)
832
1076
  {
833
- for (let i = 0; i < tmpData.streams.length; i++)
1077
+ let tmpStream = pData.streams[i];
1078
+ if (tmpStream.codec_type === 'video')
834
1079
  {
835
- let tmpStream = tmpData.streams[i];
836
- if (tmpStream.codec_type === 'video')
837
- {
838
- tmpResult.width = tmpStream.width;
839
- tmpResult.height = tmpStream.height;
840
- tmpResult.codec = tmpStream.codec_name;
841
- break;
842
- }
843
- if (tmpStream.codec_type === 'audio' && !tmpResult.codec)
844
- {
845
- tmpResult.codec = tmpStream.codec_name;
846
- }
1080
+ tmpResult.width = tmpStream.width;
1081
+ tmpResult.height = tmpStream.height;
1082
+ tmpResult.codec = tmpStream.codec_name;
1083
+ break;
1084
+ }
1085
+ if (tmpStream.codec_type === 'audio' && !tmpResult.codec)
1086
+ {
1087
+ tmpResult.codec = tmpStream.codec_name;
847
1088
  }
848
1089
  }
849
-
850
- return fCallback(null, tmpResult);
851
- }
852
- catch (pError)
853
- {
854
- return fCallback(pError);
855
1090
  }
1091
+
1092
+ return tmpResult;
856
1093
  }
857
1094
  }
858
1095