retold-remote 0.0.13 → 0.0.15

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 (28) 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 +144 -0
  13. package/source/server/RetoldRemote-MediaService.js +208 -34
  14. package/source/server/RetoldRemote-ToolDetector.js +27 -3
  15. package/source/server/RetoldRemote-UltravisorDispatcher.js +288 -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/retold-remote.js +58 -22
  26. package/web-application/retold-remote.js.map +1 -1
  27. package/web-application/retold-remote.min.js +6 -6
  28. package/web-application/retold-remote.min.js.map +1 -1
@@ -48,6 +48,7 @@ const libRetoldRemoteMetadataCache = require('../server/RetoldRemote-MetadataCac
48
48
  const libRetoldRemoteFileOperationService = require('../server/RetoldRemote-FileOperationService.js');
49
49
  const libRetoldRemoteAISortService = require('../server/RetoldRemote-AISortService.js');
50
50
  const libRetoldRemoteImageService = require('../server/RetoldRemote-ImageService.js');
51
+ const libRetoldRemoteUltravisorDispatcher = require('../server/RetoldRemote-UltravisorDispatcher.js');
51
52
  const libUrl = require('url');
52
53
 
53
54
  function setupRetoldRemoteServer(pOptions, fCallback)
@@ -83,6 +84,16 @@ function setupRetoldRemoteServer(pOptions, fCallback)
83
84
  tmpSettings.ParimeCacheServer = pOptions.CacheServer;
84
85
  }
85
86
 
87
+ // If an Ultravisor URL is specified, offload heavy processing to beacons
88
+ if (pOptions.UltravisorURL)
89
+ {
90
+ tmpSettings.UltravisorURL = pOptions.UltravisorURL;
91
+ }
92
+ if (pOptions.ContentAPIURL)
93
+ {
94
+ tmpSettings.ContentAPIURL = pOptions.ContentAPIURL;
95
+ }
96
+
86
97
  let tmpFable = new libFable(tmpSettings);
87
98
 
88
99
  // Ensure the content directory exists
@@ -201,6 +212,16 @@ function setupRetoldRemoteServer(pOptions, fCallback)
201
212
  PathRegistry: tmpPathRegistry
202
213
  });
203
214
 
215
+ // Set up the Ultravisor dispatcher for offloading heavy processing
216
+ let tmpDispatcher = new libRetoldRemoteUltravisorDispatcher(tmpFable, {});
217
+
218
+ // Wire the dispatcher to services that can offload processing
219
+ tmpMediaService.setDispatcher(tmpDispatcher);
220
+ tmpVideoFrameService.setDispatcher(tmpDispatcher);
221
+ tmpAudioWaveformService.setDispatcher(tmpDispatcher);
222
+ tmpEbookService.setDispatcher(tmpDispatcher);
223
+ tmpImageService.setDispatcher(tmpDispatcher);
224
+
204
225
  // Share tool capabilities with the image service so it can
205
226
  // use dcraw and ImageMagick for raw camera format conversion.
206
227
  // Also provides the centrally-verified sharp module reference.
@@ -458,6 +479,10 @@ function setupRetoldRemoteServer(pOptions, fCallback)
458
479
  {
459
480
  tmpFable.log.info('EPUB metadata service initialized.');
460
481
  });
482
+ tmpDispatcher.checkConnection(() =>
483
+ {
484
+ // Non-fatal — if Ultravisor is down, processing stays local
485
+ });
461
486
 
462
487
  // --- GET /api/media/metadata ---
463
488
  // Get cached metadata (with ID3/format tags) for a single file.
@@ -2114,6 +2139,7 @@ function setupRetoldRemoteServer(pOptions, fCallback)
2114
2139
  ParimeCache: tmpParimeCache,
2115
2140
  MetadataCache: tmpMetadataCache,
2116
2141
  FileOperationService: tmpFileOperationService,
2142
+ UltravisorDispatcher: tmpDispatcher,
2117
2143
  Port: tmpPort
2118
2144
  });
2119
2145
  });
@@ -563,6 +563,12 @@ class GalleryNavigationProvider extends libPictProvider
563
563
  let tmpGalleryContainer = document.getElementById('RetoldRemote-Gallery-Container');
564
564
  let tmpViewerContainer = document.getElementById('RetoldRemote-Viewer-Container');
565
565
 
566
+ // Stop any playing video/audio and release resources before hiding the viewer
567
+ let tmpVideo = document.querySelector('#RetoldRemote-Viewer-Container video');
568
+ let tmpAudio = document.querySelector('#RetoldRemote-Viewer-Container audio');
569
+ if (tmpVideo) { tmpVideo.pause(); tmpVideo.removeAttribute('src'); tmpVideo.load(); }
570
+ if (tmpAudio) { tmpAudio.pause(); tmpAudio.removeAttribute('src'); tmpAudio.load(); }
571
+
566
572
  if (tmpGalleryContainer) tmpGalleryContainer.style.display = '';
567
573
  if (tmpViewerContainer) tmpViewerContainer.style.display = 'none';
568
574
 
@@ -1380,14 +1386,16 @@ class GalleryNavigationProvider extends libPictProvider
1380
1386
  // iOS/iPadOS: use vlc-x-callback for reliable app launching
1381
1387
  tmpVLCURL = 'vlc-x-callback://x-callback-url/stream?url=' + encodeURIComponent(tmpStreamURL);
1382
1388
  }
1383
- else if (tmpIsWindows || tmpIsAndroid)
1389
+ else if (tmpIsAndroid)
1384
1390
  {
1385
- // Windows and Android: VLC handles the raw URL natively
1391
+ // Android: VLC app handles the raw URL natively via intent
1386
1392
  tmpVLCURL = 'vlc://' + tmpStreamURL;
1387
1393
  }
1388
1394
  else
1389
1395
  {
1390
- // macOS/Linux: our custom handlers URL-decode, so we encode
1396
+ // Windows, macOS, Linux: encode the URL so the custom protocol
1397
+ // handler can decode it. On Windows this is required because the
1398
+ // shell strips the colon from nested http:// URLs.
1391
1399
  tmpVLCURL = 'vlc://' + encodeURIComponent(tmpStreamURL);
1392
1400
  }
1393
1401
 
@@ -94,6 +94,11 @@ function handleAudioExplorerKey(pGalleryNav, pEvent)
94
94
  }
95
95
  }
96
96
  break;
97
+
98
+ case 'v':
99
+ pEvent.preventDefault();
100
+ pGalleryNav._streamWithVLC();
101
+ break;
97
102
  }
98
103
  }
99
104
 
@@ -133,6 +133,22 @@ function handleVideoExplorerKey(pGalleryNav, pEvent)
133
133
  }
134
134
  }
135
135
  break;
136
+
137
+ case 'v':
138
+ pEvent.preventDefault();
139
+ pGalleryNav._streamWithVLC();
140
+ break;
141
+
142
+ case ' ':
143
+ pEvent.preventDefault();
144
+ {
145
+ let tmpPlayVEX = pGalleryNav.pict.views['RetoldRemote-VideoExplorer'];
146
+ if (tmpPlayVEX)
147
+ {
148
+ tmpPlayVEX.playInBrowser();
149
+ }
150
+ }
151
+ break;
136
152
  }
137
153
  }
138
154
 
@@ -51,6 +51,9 @@ class RetoldRemoteAudioWaveformService extends libFableServiceProviderBase
51
51
 
52
52
  this.contentPath = libPath.resolve(this.options.ContentPath);
53
53
 
54
+ // Ultravisor dispatcher — set via setDispatcher()
55
+ this._dispatcher = null;
56
+
54
57
  // Detect audiowaveform availability
55
58
  this.hasAudiowaveform = this._detectCommand('audiowaveform --version');
56
59
 
@@ -61,6 +64,16 @@ class RetoldRemoteAudioWaveformService extends libFableServiceProviderBase
61
64
  this.fable.log.info(` audiowaveform tool: ${this.hasAudiowaveform ? 'available' : 'not found (using ffprobe fallback)'}`);
62
65
  }
63
66
 
67
+ /**
68
+ * Set the Ultravisor dispatcher for offloading heavy processing.
69
+ *
70
+ * @param {object} pDispatcher - RetoldRemoteUltravisorDispatcher instance
71
+ */
72
+ setDispatcher(pDispatcher)
73
+ {
74
+ this._dispatcher = pDispatcher;
75
+ }
76
+
64
77
  /**
65
78
  * Check if a command-line tool is available.
66
79
  *
@@ -114,45 +127,76 @@ class RetoldRemoteAudioWaveformService extends libFableServiceProviderBase
114
127
 
115
128
  /**
116
129
  * Probe an audio file with ffprobe to get metadata.
130
+ * Tries Ultravisor dispatch first, falls back to local execution.
117
131
  *
118
132
  * @param {string} pAbsPath - Absolute path to the audio file
119
133
  * @param {Function} fCallback - Callback(pError, { duration, sampleRate, channels, codec, bitrate, size })
120
134
  */
121
135
  _probeAudio(pAbsPath, fCallback)
122
136
  {
123
- try
124
- {
125
- let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pAbsPath}"`;
126
- let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 15000 });
127
- let tmpData = JSON.parse(tmpOutput.toString());
128
-
129
- let tmpResult = {};
137
+ let tmpSelf = this;
130
138
 
131
- if (tmpData.format)
139
+ // Try Ultravisor dispatch first
140
+ if (this._dispatcher && this._dispatcher.isAvailable())
141
+ {
142
+ let tmpRelPath;
143
+ try
132
144
  {
133
- tmpResult.duration = parseFloat(tmpData.format.duration) || null;
134
- tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
135
- tmpResult.size = parseInt(tmpData.format.size, 10) || null;
136
- tmpResult.formatName = tmpData.format.format_name || null;
145
+ tmpRelPath = libPath.relative(this.contentPath, pAbsPath);
146
+ }
147
+ catch (pErr)
148
+ {
149
+ tmpRelPath = null;
137
150
  }
138
151
 
139
- if (tmpData.streams)
152
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
140
153
  {
141
- for (let i = 0; i < tmpData.streams.length; i++)
154
+ let tmpCommand = `ffprobe -v quiet -print_format json -show_format -show_streams "{SourcePath}"`;
155
+
156
+ this._dispatcher.dispatchMediaCommand(
157
+ {
158
+ Command: tmpCommand,
159
+ InputPath: tmpRelPath,
160
+ AffinityKey: tmpRelPath,
161
+ TimeoutMs: 30000
162
+ },
163
+ (pDispatchError, pResult) =>
142
164
  {
143
- let tmpStream = tmpData.streams[i];
144
- if (tmpStream.codec_type === 'audio')
165
+ if (!pDispatchError && pResult && pResult.Outputs && pResult.Outputs.StdOut)
145
166
  {
146
- tmpResult.sampleRate = parseInt(tmpStream.sample_rate, 10) || null;
147
- tmpResult.channels = tmpStream.channels || null;
148
- tmpResult.codec = tmpStream.codec_name || null;
149
- tmpResult.channelLayout = tmpStream.channel_layout || null;
150
- break;
167
+ try
168
+ {
169
+ let tmpData = JSON.parse(pResult.Outputs.StdOut);
170
+ let tmpParsed = tmpSelf._parseAudioProbeData(tmpData);
171
+ tmpSelf.fable.log.info(`ffprobe (audio) via Ultravisor for ${tmpRelPath}`);
172
+ return fCallback(null, tmpParsed);
173
+ }
174
+ catch (pParseError)
175
+ {
176
+ // Fall through to local
177
+ }
151
178
  }
152
- }
179
+
180
+ tmpSelf._probeAudioLocal(pAbsPath, fCallback);
181
+ });
182
+ return;
153
183
  }
184
+ }
185
+
186
+ return this._probeAudioLocal(pAbsPath, fCallback);
187
+ }
154
188
 
155
- return fCallback(null, tmpResult);
189
+ /**
190
+ * Probe an audio file locally with ffprobe.
191
+ */
192
+ _probeAudioLocal(pAbsPath, fCallback)
193
+ {
194
+ try
195
+ {
196
+ let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pAbsPath}"`;
197
+ let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 15000 });
198
+ let tmpData = JSON.parse(tmpOutput.toString());
199
+ return fCallback(null, this._parseAudioProbeData(tmpData));
156
200
  }
157
201
  catch (pError)
158
202
  {
@@ -160,6 +204,40 @@ class RetoldRemoteAudioWaveformService extends libFableServiceProviderBase
160
204
  }
161
205
  }
162
206
 
207
+ /**
208
+ * Parse ffprobe JSON output for audio metadata.
209
+ */
210
+ _parseAudioProbeData(pData)
211
+ {
212
+ let tmpResult = {};
213
+
214
+ if (pData.format)
215
+ {
216
+ tmpResult.duration = parseFloat(pData.format.duration) || null;
217
+ tmpResult.bitrate = parseInt(pData.format.bit_rate, 10) || null;
218
+ tmpResult.size = parseInt(pData.format.size, 10) || null;
219
+ tmpResult.formatName = pData.format.format_name || null;
220
+ }
221
+
222
+ if (pData.streams)
223
+ {
224
+ for (let i = 0; i < pData.streams.length; i++)
225
+ {
226
+ let tmpStream = pData.streams[i];
227
+ if (tmpStream.codec_type === 'audio')
228
+ {
229
+ tmpResult.sampleRate = parseInt(tmpStream.sample_rate, 10) || null;
230
+ tmpResult.channels = tmpStream.channels || null;
231
+ tmpResult.codec = tmpStream.codec_name || null;
232
+ tmpResult.channelLayout = tmpStream.channel_layout || null;
233
+ break;
234
+ }
235
+ }
236
+ }
237
+
238
+ return tmpResult;
239
+ }
240
+
163
241
  /**
164
242
  * Extract waveform peaks using BBC audiowaveform tool.
165
243
  * Generates a JSON file with min/max peak pairs.
@@ -59,9 +59,22 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
59
59
 
60
60
  this.contentPath = libPath.resolve(this.options.ContentPath);
61
61
 
62
+ // Ultravisor dispatcher — set via setDispatcher()
63
+ this._dispatcher = null;
64
+
62
65
  this.fable.log.info('Ebook Service: using ParimeBinaryStorage (category: ebook-cache)');
63
66
  }
64
67
 
68
+ /**
69
+ * Set the Ultravisor dispatcher for offloading heavy processing.
70
+ *
71
+ * @param {object} pDispatcher - RetoldRemoteUltravisorDispatcher instance
72
+ */
73
+ setDispatcher(pDispatcher)
74
+ {
75
+ this._dispatcher = pDispatcher;
76
+ }
77
+
65
78
  /**
66
79
  * Check if a file extension is convertible to EPUB.
67
80
  *
@@ -146,25 +159,125 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
146
159
 
147
160
  this.fable.log.info(`Converting ebook: ${pRelPath} -> EPUB`);
148
161
 
162
+ let _finishConversion = (pOutputPath, pOutputFilename, pCacheDir, pManifestPath) =>
163
+ {
164
+ if (!libFs.existsSync(pOutputPath))
165
+ {
166
+ return fCallback(new Error('Conversion completed but output file not found.'));
167
+ }
168
+
169
+ let tmpOutputStat = libFs.statSync(pOutputPath);
170
+
171
+ let tmpResult =
172
+ {
173
+ Success: true,
174
+ SourcePath: pRelPath,
175
+ CacheKey: libPath.basename(pCacheDir),
176
+ OutputFilename: pOutputFilename,
177
+ FileSize: tmpOutputStat.size,
178
+ ConvertedAt: new Date().toISOString()
179
+ };
180
+
181
+ // Write manifest to cache
182
+ try
183
+ {
184
+ libFs.writeFileSync(pManifestPath, JSON.stringify(tmpResult, null, '\t'));
185
+ }
186
+ catch (pWriteError)
187
+ {
188
+ tmpSelf.fable.log.warn(`Could not write ebook manifest: ${pWriteError.message}`);
189
+ }
190
+
191
+ tmpSelf.fable.log.info(`Converted ebook: ${pRelPath} (${tmpOutputStat.size} bytes)`);
192
+ return fCallback(null, tmpResult);
193
+ };
194
+
195
+ // Try Ultravisor dispatch first
196
+ if (this._dispatcher && this._dispatcher.isAvailable())
197
+ {
198
+ let tmpRelPath;
199
+ try
200
+ {
201
+ tmpRelPath = libPath.relative(this.contentPath, pAbsPath);
202
+ }
203
+ catch (pErr)
204
+ {
205
+ tmpRelPath = null;
206
+ }
207
+
208
+ if (tmpRelPath && !tmpRelPath.startsWith('..'))
209
+ {
210
+ let tmpCommand = `ebook-convert "{SourcePath}" "{OutputPath}"`;
211
+
212
+ this._dispatcher.dispatchMediaCommand(
213
+ {
214
+ Command: tmpCommand,
215
+ InputPath: tmpRelPath,
216
+ OutputFilename: tmpOutputFilename,
217
+ AffinityKey: tmpRelPath,
218
+ TimeoutMs: 180000
219
+ },
220
+ (pDispatchError, pResult) =>
221
+ {
222
+ if (!pDispatchError && pResult && pResult.OutputBuffer)
223
+ {
224
+ try
225
+ {
226
+ libFs.writeFileSync(tmpOutputPath, pResult.OutputBuffer);
227
+ tmpSelf.fable.log.info(`Ebook converted via Ultravisor for ${tmpRelPath}`);
228
+ return _finishConversion(tmpOutputPath, tmpOutputFilename, tmpCacheDir, tmpManifestPath);
229
+ }
230
+ catch (pWriteError)
231
+ {
232
+ // Fall through to local
233
+ }
234
+ }
235
+
236
+ // Fall through to local processing
237
+ tmpSelf.fable.log.info(`Ultravisor dispatch failed for ebook conversion, falling back to local: ${pDispatchError ? pDispatchError.message : 'no output'}`);
238
+ tmpSelf._convertToEpubLocal(pAbsPath, tmpOutputPath, tmpOutputFilename, tmpCacheDir, tmpManifestPath, pRelPath, fCallback);
239
+ });
240
+ return;
241
+ }
242
+ }
243
+
244
+ this._convertToEpubLocal(pAbsPath, tmpOutputPath, tmpOutputFilename, tmpCacheDir, tmpManifestPath, pRelPath, fCallback);
245
+ }
246
+
247
+ /**
248
+ * Convert an ebook to EPUB locally using ebook-convert.
249
+ *
250
+ * @param {string} pAbsPath - Absolute path to the source ebook
251
+ * @param {string} pOutputPath - Absolute path for the output EPUB
252
+ * @param {string} pOutputFilename - Output filename
253
+ * @param {string} pCacheDir - Cache directory path
254
+ * @param {string} pManifestPath - Manifest file path
255
+ * @param {string} pRelPath - Relative path (for logging)
256
+ * @param {Function} fCallback - Callback(pError, pResult)
257
+ */
258
+ _convertToEpubLocal(pAbsPath, pOutputPath, pOutputFilename, pCacheDir, pManifestPath, pRelPath, fCallback)
259
+ {
260
+ let tmpSelf = this;
261
+
149
262
  try
150
263
  {
151
264
  // ebook-convert input.mobi output.epub
152
- let tmpCmd = `ebook-convert "${pAbsPath}" "${tmpOutputPath}"`;
265
+ let tmpCmd = `ebook-convert "${pAbsPath}" "${pOutputPath}"`;
153
266
  libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 120000 });
154
267
 
155
- if (!libFs.existsSync(tmpOutputPath))
268
+ if (!libFs.existsSync(pOutputPath))
156
269
  {
157
270
  return fCallback(new Error('Conversion completed but output file not found.'));
158
271
  }
159
272
 
160
- let tmpOutputStat = libFs.statSync(tmpOutputPath);
273
+ let tmpOutputStat = libFs.statSync(pOutputPath);
161
274
 
162
275
  let tmpResult =
163
276
  {
164
277
  Success: true,
165
278
  SourcePath: pRelPath,
166
- CacheKey: libPath.basename(tmpCacheDir),
167
- OutputFilename: tmpOutputFilename,
279
+ CacheKey: libPath.basename(pCacheDir),
280
+ OutputFilename: pOutputFilename,
168
281
  FileSize: tmpOutputStat.size,
169
282
  ConvertedAt: new Date().toISOString()
170
283
  };
@@ -172,7 +285,7 @@ class RetoldRemoteEbookService extends libFableServiceProviderBase
172
285
  // Write manifest to cache
173
286
  try
174
287
  {
175
- libFs.writeFileSync(tmpManifestPath, JSON.stringify(tmpResult, null, '\t'));
288
+ libFs.writeFileSync(pManifestPath, JSON.stringify(tmpResult, null, '\t'));
176
289
  }
177
290
  catch (pWriteError)
178
291
  {
@@ -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
  {