retold-remote 0.0.23 → 0.0.26

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 (79) hide show
  1. package/css/retold-remote.css +343 -20
  2. package/docs/.nojekyll +0 -0
  3. package/docs/README.md +64 -12
  4. package/docs/_cover.md +6 -6
  5. package/docs/_sidebar.md +2 -0
  6. package/docs/_topbar.md +1 -1
  7. package/docs/_version.json +7 -0
  8. package/docs/collections.md +30 -0
  9. package/docs/css/docuserve.css +327 -0
  10. package/docs/ebook-reader.md +75 -1
  11. package/docs/image-explorer.md +62 -2
  12. package/docs/index.html +39 -0
  13. package/docs/retold-catalog.json +254 -0
  14. package/docs/retold-keyword-index.json +31216 -0
  15. package/docs/server-setup.md +122 -91
  16. package/docs/stack-launcher.md +218 -0
  17. package/docs/synology.md +585 -0
  18. package/docs/ultravisor-configuration.md +5 -5
  19. package/docs/ultravisor-integration.md +4 -2
  20. package/package.json +20 -14
  21. package/source/Pict-Application-RetoldRemote.js +22 -0
  22. package/source/RetoldRemote-ExtensionMaps.js +1 -1
  23. package/source/cli/RetoldRemote-Server-Setup.js +460 -7
  24. package/source/cli/RetoldRemote-Stack-Launcher.js +563 -0
  25. package/source/cli/RetoldRemote-Stack-Run.js +41 -0
  26. package/source/cli/commands/RetoldRemote-Command-Serve.js +129 -54
  27. package/source/providers/CollectionManager-AddItems.js +166 -0
  28. package/source/providers/Pict-Provider-GalleryNavigation.js +55 -0
  29. package/source/providers/Pict-Provider-OperationStatus.js +597 -0
  30. package/source/providers/keyboard-handlers/KeyHandler-ImageExplorer.js +20 -1
  31. package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +23 -0
  32. package/source/server/RetoldRemote-AudioWaveformService.js +49 -3
  33. package/source/server/RetoldRemote-CollectionExportService.js +763 -0
  34. package/source/server/RetoldRemote-CollectionService.js +5 -0
  35. package/source/server/RetoldRemote-EbookService.js +218 -3
  36. package/source/server/RetoldRemote-ImageService.js +221 -46
  37. package/source/server/RetoldRemote-MediaService.js +63 -4
  38. package/source/server/RetoldRemote-MetadataCache.js +25 -5
  39. package/source/server/RetoldRemote-OperationBroadcaster.js +363 -0
  40. package/source/server/RetoldRemote-SubimageService.js +680 -0
  41. package/source/server/RetoldRemote-ToolDetector.js +50 -0
  42. package/source/server/RetoldRemote-UltravisorBeacon.js +18 -3
  43. package/source/server/RetoldRemote-UltravisorDispatcher.js +65 -491
  44. package/source/server/RetoldRemote-UltravisorOperations.js +133 -20
  45. package/source/server/RetoldRemote-VideoFrameService.js +302 -9
  46. package/source/views/MediaViewer-EbookViewer.js +419 -1
  47. package/source/views/MediaViewer-PdfViewer.js +1050 -0
  48. package/source/views/PictView-Remote-AudioExplorer.js +77 -1
  49. package/source/views/PictView-Remote-CollectionsPanel.js +213 -0
  50. package/source/views/PictView-Remote-Gallery.js +365 -64
  51. package/source/views/PictView-Remote-ImageExplorer.js +1529 -44
  52. package/source/views/PictView-Remote-ImageViewer.js +2 -2
  53. package/source/views/PictView-Remote-Layout.js +58 -0
  54. package/source/views/PictView-Remote-MediaViewer.js +100 -25
  55. package/source/views/PictView-Remote-RegionsBrowser.js +554 -0
  56. package/source/views/PictView-Remote-SubimagesPanel.js +353 -0
  57. package/source/views/PictView-Remote-TopBar.js +1 -0
  58. package/source/views/PictView-Remote-VideoExplorer.js +77 -1
  59. package/web-application/css/docuserve.css +277 -23
  60. package/web-application/css/retold-remote.css +343 -20
  61. package/web-application/docs/README.md +64 -12
  62. package/web-application/docs/_cover.md +6 -6
  63. package/web-application/docs/_sidebar.md +2 -0
  64. package/web-application/docs/_topbar.md +1 -1
  65. package/web-application/docs/collections.md +30 -0
  66. package/web-application/docs/ebook-reader.md +75 -1
  67. package/web-application/docs/image-explorer.md +62 -2
  68. package/web-application/docs/server-setup.md +122 -91
  69. package/web-application/docs/stack-launcher.md +218 -0
  70. package/web-application/docs/synology.md +585 -0
  71. package/web-application/docs/ultravisor-configuration.md +5 -5
  72. package/web-application/docs/ultravisor-integration.md +4 -2
  73. package/web-application/js/pict-docuserve.min.js +12 -12
  74. package/web-application/js/pict.min.js +2 -2
  75. package/web-application/js/pict.min.js.map +1 -1
  76. package/web-application/retold-remote.js +6596 -1784
  77. package/web-application/retold-remote.js.map +1 -1
  78. package/web-application/retold-remote.min.js +75 -23
  79. package/web-application/retold-remote.min.js.map +1 -1
@@ -159,16 +159,17 @@ function _buildPipelineOperation(pConfig)
159
159
  Address: '{~D:Record.Operation.' + pConfig.AddressParam + '~}'
160
160
  },
161
161
  ['Address'],
162
- ['URL', 'Filename', 'BeaconName']),
162
+ ['URL', 'Filename', 'BeaconName', 'LocalPath', 'Strategy']),
163
163
 
164
164
  _taskNode(tmpTransferHash, 'file-transfer', 'File Transfer', 490, 180,
165
165
  {
166
166
  SourceURL: '{~D:Record.TaskOutputs.' + tmpResolveHash + '.URL~}',
167
+ SourceLocalPath: '{~D:Record.TaskOutputs.' + tmpResolveHash + '.LocalPath~}',
167
168
  Filename: '{~D:Record.TaskOutputs.' + tmpResolveHash + '.Filename~}',
168
169
  TimeoutMs: pConfig.TransferTimeoutMs || 300000
169
170
  },
170
- ['SourceURL', 'Filename', 'TimeoutMs'],
171
- ['LocalPath', 'BytesTransferred', 'DurationMs']),
171
+ ['SourceURL', 'SourceLocalPath', 'Filename', 'TimeoutMs'],
172
+ ['LocalPath', 'BytesTransferred', 'DurationMs', 'Strategy']),
172
173
 
173
174
  _taskNode(tmpProcessHash, pConfig.ProcessType, pConfig.ProcessTitle, 760, 180,
174
175
  pConfig.ProcessData,
@@ -207,6 +208,16 @@ function _buildPipelineOperation(pConfig)
207
208
  Data: {}
208
209
  });
209
210
 
211
+ // Resolve LocalPath → transfer SourceLocalPath (shared-fs zero-copy fast path)
212
+ tmpConnections.push({
213
+ Hash: tmpHash + '-s4',
214
+ SourceNodeHash: tmpResolveHash,
215
+ SourcePortHash: tmpResolveHash + '-so-LocalPath',
216
+ TargetNodeHash: tmpTransferHash,
217
+ TargetPortHash: tmpTransferHash + '-si-SourceLocalPath',
218
+ Data: {}
219
+ });
220
+
210
221
  // Transfer LocalPath → process InputFile
211
222
  tmpConnections.push({
212
223
  Hash: tmpHash + '-s3',
@@ -299,14 +310,15 @@ function getOperations()
299
310
  _startNode(tmpVfe, 50, 200),
300
311
  _taskNode(tmpVfe + '-resolve', 'resolve-address', 'Resolve Address', 220, 180,
301
312
  { Address: '{~D:Record.Operation.VideoAddress~}' },
302
- ['Address'], ['URL', 'Filename', 'BeaconName']),
313
+ ['Address'], ['URL', 'Filename', 'BeaconName', 'LocalPath', 'Strategy']),
303
314
  _taskNode(tmpVfe + '-transfer', 'file-transfer', 'File Transfer', 440, 180,
304
315
  {
305
316
  SourceURL: '{~D:Record.TaskOutputs.' + tmpVfe + '-resolve.URL~}',
317
+ SourceLocalPath: '{~D:Record.TaskOutputs.' + tmpVfe + '-resolve.LocalPath~}',
306
318
  Filename: '{~D:Record.TaskOutputs.' + tmpVfe + '-resolve.Filename~}',
307
319
  TimeoutMs: 1800000
308
320
  },
309
- ['SourceURL', 'Filename', 'TimeoutMs'], ['LocalPath', 'BytesTransferred', 'DurationMs']),
321
+ ['SourceURL', 'SourceLocalPath', 'Filename', 'TimeoutMs'], ['LocalPath', 'BytesTransferred', 'DurationMs', 'Strategy']),
310
322
  _taskNode(tmpVfe + '-probe', 'beacon-mediaconversion-mediaprobe', 'Probe Video', 660, 180,
311
323
  {
312
324
  AffinityKey: '{~D:Record.Operation.VideoAddress~}',
@@ -335,6 +347,8 @@ function getOperations()
335
347
  tmpVfeConns.push({ Hash: tmpVfe + '-s2', SourceNodeHash: tmpVfe + '-resolve', SourcePortHash: tmpVfe + '-resolve-so-Filename', TargetNodeHash: tmpVfe + '-transfer', TargetPortHash: tmpVfe + '-transfer-si-Filename', Data: {} });
336
348
  tmpVfeConns.push({ Hash: tmpVfe + '-s3', SourceNodeHash: tmpVfe + '-transfer', SourcePortHash: tmpVfe + '-transfer-so-LocalPath', TargetNodeHash: tmpVfe + '-probe', TargetPortHash: tmpVfe + '-probe-si-InputFile', Data: {} });
337
349
  tmpVfeConns.push({ Hash: tmpVfe + '-s4', SourceNodeHash: tmpVfe + '-transfer', SourcePortHash: tmpVfe + '-transfer-so-LocalPath', TargetNodeHash: tmpVfe + '-extract', TargetPortHash: tmpVfe + '-extract-si-InputFile', Data: {} });
350
+ // Resolve LocalPath → transfer SourceLocalPath (shared-fs zero-copy fast path)
351
+ tmpVfeConns.push({ Hash: tmpVfe + '-s5', SourceNodeHash: tmpVfe + '-resolve', SourcePortHash: tmpVfe + '-resolve-so-LocalPath', TargetNodeHash: tmpVfe + '-transfer', TargetPortHash: tmpVfe + '-transfer-si-SourceLocalPath', Data: {} });
338
352
  tmpOperations.push({
339
353
  Hash: tmpVfe,
340
354
  Name: 'Video Frame Extraction',
@@ -348,6 +362,72 @@ function getOperations()
348
362
  InitialOperationState: {}
349
363
  });
350
364
 
365
+ // ── 3b. Video Frames (BATCH) ────────────────────────────────
366
+ // Single dispatch that resolves the address once, transfers the file
367
+ // once (with shared-fs zero-copy when available), and extracts ALL N
368
+ // frames in a single beacon work item. Used by the video explorer to
369
+ // avoid the previous N×operation-graph dispatch storm where N frames
370
+ // produced 21 separate file transfers (1 probe + N extracts).
371
+ //
372
+ // Trigger Parameters:
373
+ // { VideoAddress, OutputDir, Frames, Width }
374
+ // OutputDir — absolute writable directory where frames will land. Set
375
+ // to retold-remote's per-video cache directory; works in
376
+ // stack mode (shared filesystem) and on multi-host setups
377
+ // that bind-mount the same content tree.
378
+ // Frames — JSON-encoded [{ Timestamp, Filename }, ...]
379
+ //
380
+ // Returns (no send-result; read TaskOutputs[<extract-node>].Result):
381
+ // JSON string with { FrameCount, SuccessCount, OutputDir, Frames: [...] }
382
+ let tmpVfb = 'rr-video-frames-batch';
383
+ let tmpVfbNodes = [
384
+ _startNode(tmpVfb, 50, 200),
385
+ _taskNode(tmpVfb + '-resolve', 'resolve-address', 'Resolve Address', 220, 180,
386
+ { Address: '{~D:Record.Operation.VideoAddress~}' },
387
+ ['Address'], ['URL', 'Filename', 'BeaconName', 'LocalPath', 'Strategy']),
388
+ _taskNode(tmpVfb + '-transfer', 'file-transfer', 'File Transfer', 440, 180,
389
+ {
390
+ SourceURL: '{~D:Record.TaskOutputs.' + tmpVfb + '-resolve.URL~}',
391
+ SourceLocalPath: '{~D:Record.TaskOutputs.' + tmpVfb + '-resolve.LocalPath~}',
392
+ Filename: '{~D:Record.TaskOutputs.' + tmpVfb + '-resolve.Filename~}',
393
+ TimeoutMs: 1800000
394
+ },
395
+ ['SourceURL', 'SourceLocalPath', 'Filename', 'TimeoutMs'],
396
+ ['LocalPath', 'BytesTransferred', 'DurationMs', 'Strategy']),
397
+ _taskNode(tmpVfb + '-extract', 'beacon-mediaconversion-videoextractframes', 'Extract Frames Batch', 660, 180,
398
+ {
399
+ OutputDir: '{~D:Record.Operation.OutputDir~}',
400
+ Frames: '{~D:Record.Operation.Frames~}',
401
+ Width: '{~D:Record.Operation.Width~}',
402
+ AffinityKey: '{~D:Record.Operation.VideoAddress~}',
403
+ TimeoutMs: 1800000
404
+ },
405
+ ['InputFile', 'OutputDir', 'Frames', 'Width'],
406
+ ['Result', 'StdOut']),
407
+ _endNode(tmpVfb, 880, 260)
408
+ ];
409
+ let tmpVfbConns = _chainConnections(tmpVfb,
410
+ [tmpVfb + '-start', tmpVfb + '-resolve', tmpVfb + '-transfer', tmpVfb + '-extract'],
411
+ tmpVfb + '-end');
412
+ // State wires
413
+ tmpVfbConns.push({ Hash: tmpVfb + '-s1', SourceNodeHash: tmpVfb + '-resolve', SourcePortHash: tmpVfb + '-resolve-so-URL', TargetNodeHash: tmpVfb + '-transfer', TargetPortHash: tmpVfb + '-transfer-si-SourceURL', Data: {} });
414
+ tmpVfbConns.push({ Hash: tmpVfb + '-s2', SourceNodeHash: tmpVfb + '-resolve', SourcePortHash: tmpVfb + '-resolve-so-Filename', TargetNodeHash: tmpVfb + '-transfer', TargetPortHash: tmpVfb + '-transfer-si-Filename', Data: {} });
415
+ tmpVfbConns.push({ Hash: tmpVfb + '-s3', SourceNodeHash: tmpVfb + '-transfer', SourcePortHash: tmpVfb + '-transfer-so-LocalPath', TargetNodeHash: tmpVfb + '-extract', TargetPortHash: tmpVfb + '-extract-si-InputFile', Data: {} });
416
+ // Resolve LocalPath → transfer SourceLocalPath (shared-fs zero-copy fast path)
417
+ tmpVfbConns.push({ Hash: tmpVfb + '-s4', SourceNodeHash: tmpVfb + '-resolve', SourcePortHash: tmpVfb + '-resolve-so-LocalPath', TargetNodeHash: tmpVfb + '-transfer', TargetPortHash: tmpVfb + '-transfer-si-SourceLocalPath', Data: {} });
418
+ tmpOperations.push({
419
+ Hash: tmpVfb,
420
+ Name: 'Video Frames Batch',
421
+ Description: 'Single-dispatch batch frame extraction. resolve → transfer → extract-many. Trigger with Parameters: { VideoAddress, OutputDir, Frames (JSON), Width }. Result is JSON metadata only — frames are written to OutputDir and read directly by the dispatcher.',
422
+ Tags: ['retold-remote', 'media', 'video', 'frames', 'batch'],
423
+ Author: 'retold-remote',
424
+ Version: '3.0.0',
425
+ Graph: { Nodes: tmpVfbNodes, Connections: tmpVfbConns, ViewState: { PanX: 0, PanY: 0, Zoom: 1, SelectedNodeHash: null, SelectedConnectionHash: null } },
426
+ SavedLayouts: [],
427
+ InitialGlobalState: {},
428
+ InitialOperationState: {}
429
+ });
430
+
351
431
  // ── 4. Audio Waveform ───────────────────────────────────────
352
432
  tmpOperations.push(_buildPipelineOperation(
353
433
  {
@@ -461,23 +541,56 @@ function getOperations()
461
541
  }));
462
542
 
463
543
  // ── 9. Media Probe ─────────────────────────────────────────
464
- tmpOperations.push(_buildPipelineOperation(
465
- {
466
- Hash: 'rr-media-probe',
544
+ // Custom graph (NOT _buildPipelineOperation) because MediaProbe returns
545
+ // its result as a JSON string in the work item's Outputs.Result field —
546
+ // it does NOT write a file to staging. The pipeline helper would tack on
547
+ // a send-result node looking for `probe.json` and log a noisy error every
548
+ // time the operation runs. Skip send-result entirely; the dispatcher
549
+ // reads the JSON directly from `pResult.TaskOutputs['rr-media-probe-process'].Result`.
550
+ let tmpMp = 'rr-media-probe';
551
+ let tmpMpNodes = [
552
+ _startNode(tmpMp, 50, 200),
553
+ _taskNode(tmpMp + '-resolve', 'resolve-address', 'Resolve Address', 220, 180,
554
+ { Address: '{~D:Record.Operation.MediaAddress~}' },
555
+ ['Address'], ['URL', 'Filename', 'BeaconName', 'LocalPath', 'Strategy']),
556
+ _taskNode(tmpMp + '-transfer', 'file-transfer', 'File Transfer', 440, 180,
557
+ {
558
+ SourceURL: '{~D:Record.TaskOutputs.' + tmpMp + '-resolve.URL~}',
559
+ SourceLocalPath: '{~D:Record.TaskOutputs.' + tmpMp + '-resolve.LocalPath~}',
560
+ Filename: '{~D:Record.TaskOutputs.' + tmpMp + '-resolve.Filename~}',
561
+ TimeoutMs: 300000
562
+ },
563
+ ['SourceURL', 'SourceLocalPath', 'Filename', 'TimeoutMs'],
564
+ ['LocalPath', 'BytesTransferred', 'DurationMs', 'Strategy']),
565
+ _taskNode(tmpMp + '-process', 'beacon-mediaconversion-mediaprobe', 'Probe Metadata', 660, 180,
566
+ {
567
+ AffinityKey: '{~D:Record.Operation.MediaAddress~}',
568
+ TimeoutMs: 300000
569
+ },
570
+ ['InputFile'],
571
+ ['Result', 'StdOut']),
572
+ _endNode(tmpMp, 880, 260)
573
+ ];
574
+ let tmpMpConns = _chainConnections(tmpMp,
575
+ [tmpMp + '-start', tmpMp + '-resolve', tmpMp + '-transfer', tmpMp + '-process'],
576
+ tmpMp + '-end');
577
+ tmpMpConns.push({ Hash: tmpMp + '-s1', SourceNodeHash: tmpMp + '-resolve', SourcePortHash: tmpMp + '-resolve-so-URL', TargetNodeHash: tmpMp + '-transfer', TargetPortHash: tmpMp + '-transfer-si-SourceURL', Data: {} });
578
+ tmpMpConns.push({ Hash: tmpMp + '-s2', SourceNodeHash: tmpMp + '-resolve', SourcePortHash: tmpMp + '-resolve-so-Filename', TargetNodeHash: tmpMp + '-transfer', TargetPortHash: tmpMp + '-transfer-si-Filename', Data: {} });
579
+ tmpMpConns.push({ Hash: tmpMp + '-s3', SourceNodeHash: tmpMp + '-transfer', SourcePortHash: tmpMp + '-transfer-so-LocalPath', TargetNodeHash: tmpMp + '-process', TargetPortHash: tmpMp + '-process-si-InputFile', Data: {} });
580
+ // Resolve LocalPath → transfer SourceLocalPath (shared-fs zero-copy fast path)
581
+ tmpMpConns.push({ Hash: tmpMp + '-s4', SourceNodeHash: tmpMp + '-resolve', SourcePortHash: tmpMp + '-resolve-so-LocalPath', TargetNodeHash: tmpMp + '-transfer', TargetPortHash: tmpMp + '-transfer-si-SourceLocalPath', Data: {} });
582
+ tmpOperations.push({
583
+ Hash: tmpMp,
467
584
  Name: 'Media Probe',
468
- Description: 'Extract metadata from a media file via ffprobe. Pipeline: resolve → download → probe → send result. Trigger with Parameters: { MediaAddress }.',
585
+ Description: 'Extract metadata from a media file via ffprobe. Custom graph: resolve → transfer → probe → end. Result is JSON metadata only (no send-result; read TaskOutputs[<process>].Result). Trigger with Parameters: { MediaAddress }.',
469
586
  Tags: ['media', 'probe', 'metadata'],
470
- AddressParam: 'MediaAddress',
471
- ProcessType: 'beacon-mediaconversion-mediaprobe',
472
- ProcessTitle: 'Probe Metadata',
473
- ProcessData:
474
- {
475
- TimeoutMs: 300000
476
- },
477
- ProcessSettings: ['InputFile'],
478
- ProcessOutputs: ['Result', 'StdOut'],
479
- OutputFile: 'probe.json'
480
- }));
587
+ Author: 'retold-remote',
588
+ Version: '3.0.0',
589
+ Graph: { Nodes: tmpMpNodes, Connections: tmpMpConns, ViewState: { PanX: 0, PanY: 0, Zoom: 1, SelectedNodeHash: null, SelectedConnectionHash: null } },
590
+ SavedLayouts: [],
591
+ InitialGlobalState: {},
592
+ InitialOperationState: {}
593
+ });
481
594
 
482
595
  return tmpOperations;
483
596
  }
@@ -54,6 +54,25 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
54
54
  // Ultravisor dispatcher — set via setDispatcher()
55
55
  this._dispatcher = null;
56
56
 
57
+ // Operation broadcaster — set via setBroadcaster()
58
+ this._broadcaster = null;
59
+
60
+ // One-time local ffprobe detection. If ffprobe is on this machine we
61
+ // always prefer to use it directly — running ffprobe locally is a
62
+ // metadata-only call (microseconds for an indexed file) and avoids the
63
+ // entire resolve→transfer→probe→result Ultravisor pipeline. Only when
64
+ // ffprobe is missing locally do we fall back to dispatching the probe
65
+ // to a beacon.
66
+ this._ffprobeLocalAvailable = this._detectLocalFfprobe();
67
+ if (this._ffprobeLocalAvailable)
68
+ {
69
+ this.fable.log.info('Video Frame Service: local ffprobe detected — probes will run in-process (no dispatch).');
70
+ }
71
+ else
72
+ {
73
+ this.fable.log.info('Video Frame Service: local ffprobe NOT found — will dispatch probes to a beacon.');
74
+ }
75
+
57
76
  // Apply explorer state persistence mixin (initializeState,
58
77
  // _buildExplorerStateKey, loadExplorerState, saveExplorerState)
59
78
  libExplorerStateMixin.apply(this, EXPLORER_STATE_SOURCE, 'video-explorer');
@@ -75,6 +94,42 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
75
94
  this._dispatcher = pDispatcher;
76
95
  }
77
96
 
97
+ /**
98
+ * Set the operation broadcaster for progress events and cancellation.
99
+ *
100
+ * @param {object} pBroadcaster - RetoldRemoteOperationBroadcaster instance
101
+ */
102
+ setBroadcaster(pBroadcaster)
103
+ {
104
+ this._broadcaster = pBroadcaster;
105
+ }
106
+
107
+ /**
108
+ * Helper: emit a progress event if a broadcaster is attached and the
109
+ * caller supplied an operation id. Safe to call without either.
110
+ *
111
+ * @param {string} pOperationId - Opaque client-supplied operation id
112
+ * @param {object} pPayload - { Phase, Current, Total, Message }
113
+ */
114
+ _emitProgress(pOperationId, pPayload)
115
+ {
116
+ if (this._broadcaster && pOperationId)
117
+ {
118
+ this._broadcaster.broadcastProgress(pOperationId, pPayload);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Helper: check whether the current operation has been cancelled.
124
+ *
125
+ * @param {string} pOperationId
126
+ * @returns {boolean}
127
+ */
128
+ _isCancelled(pOperationId)
129
+ {
130
+ return !!(this._broadcaster && pOperationId && this._broadcaster.isCancelled(pOperationId));
131
+ }
132
+
78
133
  /**
79
134
  * Install video-specific explorer state methods that need read-merge-write
80
135
  * behavior (merging custom frames, preserving selection across saves).
@@ -229,9 +284,40 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
229
284
  return this.fable.ParimeBinaryStorage.resolvePath('video-frames', tmpHash);
230
285
  }
231
286
 
287
+ /**
288
+ * One-time check for local ffprobe at constructor time. Synchronous because
289
+ * the constructor is, and we want the cached answer ready before the first
290
+ * probe call. The check itself is cheap (~50ms) and only runs once per
291
+ * service lifetime.
292
+ *
293
+ * @returns {boolean} true if `ffprobe -version` returns 0 on this host
294
+ */
295
+ _detectLocalFfprobe()
296
+ {
297
+ try
298
+ {
299
+ libChildProcess.execSync('ffprobe -version', { stdio: 'ignore', timeout: 5000 });
300
+ return true;
301
+ }
302
+ catch (pError)
303
+ {
304
+ return false;
305
+ }
306
+ }
307
+
232
308
  /**
233
309
  * Probe a video file with ffprobe to get its duration.
234
- * Tries Ultravisor dispatch first, falls back to local execution.
310
+ *
311
+ * Strategy ordering (most efficient first):
312
+ * 1. LOCAL ffprobe — when this process has the binary on PATH. ffprobe
313
+ * reads the container index and returns metadata in milliseconds; no
314
+ * Ultravisor pipeline involvement, no file copies, no shared-fs
315
+ * negotiation. This is the right call for stack-mode deployments
316
+ * where retold-remote and orator-conversion live in the same image.
317
+ * 2. DISPATCHED probe — only when the local binary is missing. Goes
318
+ * through the rr-media-probe operation graph (resolve → transfer →
319
+ * probe → result). With shared-fs the file isn't actually copied,
320
+ * but the operation graph still runs.
235
321
  *
236
322
  * @param {string} pAbsPath - Absolute path to the video
237
323
  * @param {Function} fCallback - Callback(pError, { duration, width, height, codec })
@@ -240,7 +326,18 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
240
326
  {
241
327
  let tmpSelf = this;
242
328
 
243
- // Try Ultravisor operation trigger first
329
+ // Local-first: if ffprobe is on this host, just run it. The previous
330
+ // version did this BACKWARDS and dispatched even when local was
331
+ // available, which made every video frame extraction pay an extra
332
+ // Ultravisor round trip just to read the duration.
333
+ if (this._ffprobeLocalAvailable)
334
+ {
335
+ return this._probeVideoLocal(pAbsPath, fCallback);
336
+ }
337
+
338
+ // No local ffprobe — fall back to dispatching the probe through the
339
+ // Ultravisor mesh. This requires the dispatcher to be available and
340
+ // the file to be addressable through the File context.
244
341
  if (this._dispatcher && this._dispatcher.isAvailable())
245
342
  {
246
343
  let tmpRelPath;
@@ -270,13 +367,15 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
270
367
  {
271
368
  let tmpData = JSON.parse(tmpProcessOutput.Result);
272
369
  let tmpParsed = tmpSelf._parseProbeData(tmpData);
273
- tmpSelf.fable.log.info(`ffprobe via operation trigger for ${tmpRelPath}`);
370
+ tmpSelf.fable.log.info(`ffprobe via operation trigger for ${tmpRelPath} (no local ffprobe)`);
274
371
  return fCallback(null, tmpParsed);
275
372
  }
276
373
  }
277
374
  catch (pParseError)
278
375
  {
279
- // Fall through to local
376
+ // Fall through to a final local attempt — even though
377
+ // the constructor said ffprobe was missing, the binary
378
+ // could have been installed since startup.
280
379
  }
281
380
  }
282
381
 
@@ -286,6 +385,8 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
286
385
  }
287
386
  }
288
387
 
388
+ // Last resort: try local even though detection said it was missing.
389
+ // Returns the original ENOENT or similar if ffprobe really isn't there.
289
390
  return this._probeVideoLocal(pAbsPath, fCallback);
290
391
  }
291
392
 
@@ -543,6 +644,9 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
543
644
  let tmpHeight = parseInt(pOptions.height, 10) || this.options.DefaultFrameHeight;
544
645
  let tmpFormat = pOptions.format || this.options.DefaultFrameFormat;
545
646
 
647
+ // Operation tracking (optional — passed by caller via X-Op-Id)
648
+ let tmpOpId = pOptions.OperationId || null;
649
+
546
650
  // Clamp values
547
651
  tmpCount = Math.min(Math.max(tmpCount, 1), 100);
548
652
  tmpWidth = Math.min(Math.max(tmpWidth, 64), 1920);
@@ -575,6 +679,7 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
575
679
  {
576
680
  let tmpManifest = JSON.parse(libFs.readFileSync(tmpManifestPath, 'utf8'));
577
681
  this.fable.log.info(`Video frames cache hit for ${pRelPath}`);
682
+ this._emitProgress(tmpOpId, { Phase: 'cached', Message: 'Cached frames available' });
578
683
  return fCallback(null, tmpManifest);
579
684
  }
580
685
  catch (pError)
@@ -584,6 +689,7 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
584
689
  }
585
690
 
586
691
  // Probe the video for duration
692
+ this._emitProgress(tmpOpId, { Phase: 'probing', Message: 'Probing video metadata' });
587
693
  this._probeVideo(pAbsPath,
588
694
  (pError, pVideoInfo) =>
589
695
  {
@@ -592,6 +698,11 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
592
698
  return fCallback(new Error('Could not probe video. ffprobe may not be available.'));
593
699
  }
594
700
 
701
+ if (tmpSelf._isCancelled(tmpOpId))
702
+ {
703
+ return fCallback(new Error('Cancelled'));
704
+ }
705
+
595
706
  let tmpDuration = pVideoInfo.duration;
596
707
 
597
708
  // Calculate timestamps
@@ -609,9 +720,15 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
609
720
 
610
721
  tmpSelf.fable.log.info(`Extracting ${tmpTimestamps.length} frames from ${pRelPath} (${tmpDuration.toFixed(1)}s)`);
611
722
 
612
- // Use async serial extraction when dispatcher is available,
613
- // otherwise use synchronous loop for backward compatibility
614
- let tmpUseAsync = !!(tmpSelf._dispatcher && tmpSelf._dispatcher.isAvailable());
723
+ // Three execution paths, in order of preference:
724
+ // 1. BATCH dispatch (dispatcher available) single Ultravisor
725
+ // operation that resolves the address once, transfers/short-
726
+ // circuits the file once, and extracts all N frames in one
727
+ // beacon work item. Replaces the previous N-trigger storm.
728
+ // 2. Local async loop (dispatcher unavailable, ffmpeg local) —
729
+ // sequential extracts in-process, no Ultravisor.
730
+ // 3. Local sync loop (final fallback for ancient code paths)
731
+ let tmpUseBatch = !!(tmpSelf._dispatcher && tmpSelf._dispatcher.isAvailable());
615
732
 
616
733
  let _finishExtraction = () =>
617
734
  {
@@ -653,9 +770,20 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
653
770
  return fCallback(null, tmpResult);
654
771
  };
655
772
 
656
- if (tmpUseAsync)
773
+ // Emit initial progress before starting extraction
774
+ tmpSelf._emitProgress(tmpOpId,
775
+ {
776
+ Phase: 'extracting',
777
+ Current: 0,
778
+ Total: tmpTimestamps.length,
779
+ Message: 'Extracting 0 of ' + tmpTimestamps.length + ' frames',
780
+ Cancelable: true
781
+ });
782
+
783
+ // Local serial-async fallback used by both the "no batch" path
784
+ // and the "batch failed" recovery path.
785
+ let _runLocalAsync = () =>
657
786
  {
658
- // Serial async extraction
659
787
  let tmpFrameIndex = 0;
660
788
 
661
789
  let _extractNext = () =>
@@ -665,6 +793,13 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
665
793
  return _finishExtraction();
666
794
  }
667
795
 
796
+ // Cooperative cancellation check before each frame
797
+ if (tmpSelf._isCancelled(tmpOpId))
798
+ {
799
+ tmpSelf.fable.log.info('[VideoFrameService] cancelled mid-extraction for ' + pRelPath);
800
+ return fCallback(new Error('Cancelled'));
801
+ }
802
+
668
803
  let tmpI = tmpFrameIndex;
669
804
  let tmpTimestamp = tmpTimestamps[tmpI];
670
805
  let tmpFrameFilename = `frame_${String(tmpI).padStart(4, '0')}.${tmpFormat}`;
@@ -695,17 +830,166 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
695
830
  // Frame file disappeared — skip
696
831
  }
697
832
  }
833
+ tmpSelf._emitProgress(tmpOpId,
834
+ {
835
+ Phase: 'extracting',
836
+ Current: tmpExtractedCount,
837
+ Total: tmpTimestamps.length,
838
+ Message: 'Extracting ' + tmpExtractedCount + ' of ' + tmpTimestamps.length + ' frames',
839
+ Cancelable: true
840
+ });
698
841
  _extractNext();
699
842
  });
700
843
  };
701
844
 
702
845
  _extractNext();
846
+ };
847
+
848
+ if (tmpUseBatch)
849
+ {
850
+ // ── BATCH path ──────────────────────────────────────────
851
+ // Build the per-frame spec list and dispatch a single batch
852
+ // operation. The orator-conversion beacon writes all frame
853
+ // files directly into our cache directory because we share
854
+ // the filesystem. After the operation completes we just stat
855
+ // each frame file and build our normal manifest.
856
+ let tmpRelPath;
857
+ try
858
+ {
859
+ tmpRelPath = libPath.relative(tmpSelf.contentPath, pAbsPath);
860
+ }
861
+ catch (pRelErr)
862
+ {
863
+ tmpRelPath = null;
864
+ }
865
+
866
+ if (!tmpRelPath || tmpRelPath.startsWith('..'))
867
+ {
868
+ tmpSelf.fable.log.warn(`[VideoFrameService] could not derive content-relative path for ${pAbsPath}; falling back to local extraction`);
869
+ return _runLocalAsync();
870
+ }
871
+
872
+ let tmpFrameSpecs = [];
873
+ let tmpFilenameToMeta = {};
874
+ for (let i = 0; i < tmpTimestamps.length; i++)
875
+ {
876
+ let tmpTimestamp = tmpTimestamps[i];
877
+ let tmpFrameFilename = `frame_${String(i).padStart(4, '0')}.${tmpFormat}`;
878
+ let tmpTimeStr = tmpSelf._formatFfmpegTimestamp(tmpTimestamp);
879
+ tmpFrameSpecs.push({ Timestamp: tmpTimeStr, Filename: tmpFrameFilename });
880
+ tmpFilenameToMeta[tmpFrameFilename] =
881
+ {
882
+ Index: i,
883
+ Timestamp: tmpTimestamp,
884
+ TimestampFormatted: tmpSelf._formatTimestamp(tmpTimestamp)
885
+ };
886
+ }
887
+
888
+ if (tmpSelf._isCancelled(tmpOpId))
889
+ {
890
+ return fCallback(new Error('Cancelled'));
891
+ }
892
+
893
+ tmpSelf.fable.log.info(`[VideoFrameService] dispatching batch extraction (${tmpFrameSpecs.length} frames) for ${pRelPath}`);
894
+
895
+ tmpSelf._dispatcher.triggerOperation('rr-video-frames-batch',
896
+ {
897
+ VideoAddress: '>retold-remote/File/' + tmpRelPath,
898
+ OutputDir: tmpCacheDir,
899
+ Frames: JSON.stringify(tmpFrameSpecs),
900
+ Width: tmpWidth,
901
+ TimeoutMs: 1800000
902
+ },
903
+ (pBatchError, pBatchResult) =>
904
+ {
905
+ if (pBatchError || !pBatchResult)
906
+ {
907
+ tmpSelf.fable.log.warn(`[VideoFrameService] batch dispatch failed: ${pBatchError ? pBatchError.message : 'no result'}; falling back to local`);
908
+ return _runLocalAsync();
909
+ }
910
+
911
+ // Pull the manifest out of the extract task's Result
912
+ let tmpManifest = null;
913
+ try
914
+ {
915
+ let tmpExtractOutputs = pBatchResult.TaskOutputs && pBatchResult.TaskOutputs['rr-video-frames-batch-extract'];
916
+ if (tmpExtractOutputs && tmpExtractOutputs.Result)
917
+ {
918
+ tmpManifest = JSON.parse(tmpExtractOutputs.Result);
919
+ }
920
+ }
921
+ catch (pParseError)
922
+ {
923
+ tmpSelf.fable.log.warn(`[VideoFrameService] could not parse batch result manifest: ${pParseError.message}`);
924
+ }
925
+
926
+ if (!tmpManifest || !Array.isArray(tmpManifest.Frames))
927
+ {
928
+ tmpSelf.fable.log.warn('[VideoFrameService] batch result missing manifest; falling back to local');
929
+ return _runLocalAsync();
930
+ }
931
+
932
+ // Walk the manifest, stat each output file, build our
933
+ // frames array. Skip any that didn't actually land on
934
+ // disk (filesystem races, partial failures, etc).
935
+ for (let i = 0; i < tmpManifest.Frames.length; i++)
936
+ {
937
+ let tmpEntry = tmpManifest.Frames[i];
938
+ if (!tmpEntry || !tmpEntry.Success || !tmpEntry.Filename)
939
+ {
940
+ continue;
941
+ }
942
+ let tmpFramePath = libPath.join(tmpCacheDir, tmpEntry.Filename);
943
+ try
944
+ {
945
+ let tmpFrameStat = libFs.statSync(tmpFramePath);
946
+ let tmpMeta = tmpFilenameToMeta[tmpEntry.Filename] || {};
947
+ tmpFrames.push(
948
+ {
949
+ Index: tmpMeta.Index !== undefined ? tmpMeta.Index : i,
950
+ Timestamp: tmpMeta.Timestamp,
951
+ TimestampFormatted: tmpMeta.TimestampFormatted,
952
+ Filename: tmpEntry.Filename,
953
+ Size: tmpFrameStat.size
954
+ });
955
+ tmpExtractedCount++;
956
+ }
957
+ catch (pStatErr)
958
+ {
959
+ // File missing — skip
960
+ }
961
+ }
962
+
963
+ tmpSelf._emitProgress(tmpOpId,
964
+ {
965
+ Phase: 'extracting',
966
+ Current: tmpExtractedCount,
967
+ Total: tmpTimestamps.length,
968
+ Message: 'Extracting ' + tmpExtractedCount + ' of ' + tmpTimestamps.length + ' frames',
969
+ Cancelable: true
970
+ });
971
+
972
+ if (tmpExtractedCount === 0)
973
+ {
974
+ tmpSelf.fable.log.warn('[VideoFrameService] batch returned 0 successful frames; falling back to local');
975
+ return _runLocalAsync();
976
+ }
977
+
978
+ return _finishExtraction();
979
+ });
703
980
  }
704
981
  else
705
982
  {
706
983
  // Synchronous extraction (original behavior)
707
984
  for (let i = 0; i < tmpTimestamps.length; i++)
708
985
  {
986
+ // Cooperative cancellation check before each frame
987
+ if (tmpSelf._isCancelled(tmpOpId))
988
+ {
989
+ tmpSelf.fable.log.info('[VideoFrameService] cancelled mid-extraction for ' + pRelPath);
990
+ return fCallback(new Error('Cancelled'));
991
+ }
992
+
709
993
  let tmpTimestamp = tmpTimestamps[i];
710
994
  let tmpFrameFilename = `frame_${String(i).padStart(4, '0')}.${tmpFormat}`;
711
995
  let tmpFramePath = libPath.join(tmpCacheDir, tmpFrameFilename);
@@ -726,6 +1010,15 @@ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
726
1010
  });
727
1011
  tmpExtractedCount++;
728
1012
  }
1013
+
1014
+ tmpSelf._emitProgress(tmpOpId,
1015
+ {
1016
+ Phase: 'extracting',
1017
+ Current: tmpExtractedCount,
1018
+ Total: tmpTimestamps.length,
1019
+ Message: 'Extracting ' + tmpExtractedCount + ' of ' + tmpTimestamps.length + ' frames',
1020
+ Cancelable: true
1021
+ });
729
1022
  }
730
1023
 
731
1024
  _finishExtraction();