retold-remote 0.0.15 → 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.
- package/package.json +6 -6
- package/source/server/RetoldRemote-ImageService.js +110 -2
- package/source/server/RetoldRemote-MediaService.js +71 -8
- package/source/server/RetoldRemote-UltravisorDispatcher.js +311 -0
- package/web-application/js/pict.min.js +2 -2
- package/web-application/js/pict.min.js.map +1 -1
- package/web-application/retold-remote.js +4721 -4232
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +16 -16
- package/web-application/retold-remote.min.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retold-remote",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "Retold Remote - NAS media browser with gallery views and keyboard navigation",
|
|
5
5
|
"main": "source/Pict-RetoldRemote-Bundle.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,22 +34,22 @@
|
|
|
34
34
|
"dcraw": "^1.0.3",
|
|
35
35
|
"epubjs": "^0.3.93",
|
|
36
36
|
"exifr": "^7.1.3",
|
|
37
|
-
"fable": "^3.1.
|
|
37
|
+
"fable": "^3.1.67",
|
|
38
38
|
"fable-serviceproviderbase": "^3.0.19",
|
|
39
39
|
"node-unrar-js": "^2.0.2",
|
|
40
40
|
"orator": "^6.0.4",
|
|
41
41
|
"orator-serviceserver-restify": "^2.0.9",
|
|
42
42
|
"parime": "^1.0.3",
|
|
43
43
|
"pdf-parse": "^1.1.1",
|
|
44
|
-
"pict": "^1.0.
|
|
44
|
+
"pict": "^1.0.359",
|
|
45
45
|
"pict-application": "^1.0.33",
|
|
46
46
|
"pict-docuserve": "^0.0.32",
|
|
47
47
|
"pict-provider": "^1.0.12",
|
|
48
|
-
"pict-section-code": "^1.0.
|
|
48
|
+
"pict-section-code": "^1.0.4",
|
|
49
49
|
"pict-section-filebrowser": "^0.0.2",
|
|
50
50
|
"pict-service-commandlineutility": "^1.0.19",
|
|
51
51
|
"pict-view": "^1.0.67",
|
|
52
|
-
"retold-content-system": "^1.0.
|
|
52
|
+
"retold-content-system": "^1.0.12",
|
|
53
53
|
"yauzl": "^3.2.0"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"puppeteer": "^24.0.0",
|
|
61
|
-
"quackage": "^1.0.
|
|
61
|
+
"quackage": "^1.0.65"
|
|
62
62
|
},
|
|
63
63
|
"copyFilesSettings": {
|
|
64
64
|
"whenFileExists": "overwrite"
|
|
@@ -752,7 +752,11 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
|
|
|
752
752
|
|
|
753
753
|
if (!this._sharp && !this._capabilities.imagemagick)
|
|
754
754
|
{
|
|
755
|
-
|
|
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
|
+
}
|
|
756
760
|
}
|
|
757
761
|
|
|
758
762
|
let tmpMaxDim = pMaxDimension || this.options.DefaultMaxPreviewDimension;
|
|
@@ -826,11 +830,20 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
|
|
|
826
830
|
{
|
|
827
831
|
this._doGeneratePreview(pAbsPath, pAbsPath, pRelPath, tmpMaxDim, tmpCacheKey, tmpOutputFilename, tmpCacheDir, tmpManifestPath, tmpOutputPath, tmpStat, false, fCallback);
|
|
828
832
|
}
|
|
829
|
-
else
|
|
833
|
+
else if (this._capabilities.imagemagick)
|
|
830
834
|
{
|
|
831
835
|
// No Sharp available — use ImageMagick for standard images too
|
|
832
836
|
this._doGeneratePreviewWithImageMagick(pAbsPath, pRelPath, tmpMaxDim, tmpCacheKey, tmpOutputFilename, tmpManifestPath, tmpOutputPath, tmpStat, false, fCallback);
|
|
833
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
|
+
}
|
|
834
847
|
}
|
|
835
848
|
|
|
836
849
|
/**
|
|
@@ -1087,6 +1100,101 @@ class RetoldRemoteImageService extends libFableServiceProviderBase
|
|
|
1087
1100
|
}
|
|
1088
1101
|
}
|
|
1089
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
|
+
|
|
1090
1198
|
// ---------------------------------------------------------------
|
|
1091
1199
|
// DZI tile generation
|
|
1092
1200
|
// ---------------------------------------------------------------
|
|
@@ -496,7 +496,9 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
|
|
|
496
496
|
}
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
-
// Try Ultravisor dispatch as last resort for image thumbnails
|
|
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.
|
|
500
502
|
if (this._dispatcher && this._dispatcher.isAvailable())
|
|
501
503
|
{
|
|
502
504
|
let tmpRelPath;
|
|
@@ -513,15 +515,19 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
|
|
|
513
515
|
{
|
|
514
516
|
let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'jpeg';
|
|
515
517
|
let tmpOutputFilename = `thumbnail.${pFormat === 'webp' ? 'webp' : 'jpg'}`;
|
|
516
|
-
let tmpCommand = `convert "{SourcePath}" -thumbnail ${pWidth}x${pHeight} -auto-orient ${tmpOutputFormat}:"{OutputPath}"`;
|
|
517
518
|
|
|
518
|
-
this._dispatcher.
|
|
519
|
+
this._dispatcher.dispatchConversion(
|
|
519
520
|
{
|
|
520
|
-
|
|
521
|
+
Action: 'ImageResize',
|
|
521
522
|
InputPath: tmpRelPath,
|
|
522
523
|
OutputFilename: tmpOutputFilename,
|
|
524
|
+
Width: pWidth,
|
|
525
|
+
Height: pHeight,
|
|
526
|
+
Format: tmpOutputFormat,
|
|
527
|
+
Quality: 80,
|
|
523
528
|
AffinityKey: tmpRelPath,
|
|
524
|
-
TimeoutMs: 30000
|
|
529
|
+
TimeoutMs: 30000,
|
|
530
|
+
FallbackCommand: `convert "{SourcePath}" -thumbnail ${pWidth}x${pHeight} -auto-orient ${tmpOutputFormat}:"{OutputPath}"`
|
|
525
531
|
},
|
|
526
532
|
(pDispatchError, pResult) =>
|
|
527
533
|
{
|
|
@@ -677,10 +683,11 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
|
|
|
677
683
|
}
|
|
678
684
|
|
|
679
685
|
/**
|
|
680
|
-
* Fallback raw thumbnail generation: ImageMagick → exifr embedded preview.
|
|
686
|
+
* Fallback raw thumbnail generation: ImageMagick → Ultravisor MediaConversion → exifr embedded preview.
|
|
681
687
|
*/
|
|
682
688
|
_generateRawThumbnailFallback(pFullPath, pWidth, pHeight, pFormat, fCallback)
|
|
683
689
|
{
|
|
690
|
+
let tmpSelf = this;
|
|
684
691
|
let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'jpeg';
|
|
685
692
|
|
|
686
693
|
// Strategy 2: ImageMagick (may have dcraw delegate)
|
|
@@ -694,11 +701,67 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
|
|
|
694
701
|
}
|
|
695
702
|
catch (pError)
|
|
696
703
|
{
|
|
697
|
-
// Fall through to
|
|
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;
|
|
698
751
|
}
|
|
699
752
|
}
|
|
700
753
|
|
|
701
|
-
|
|
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
|
+
|
|
702
765
|
if (this.capabilities.sharp)
|
|
703
766
|
{
|
|
704
767
|
let tmpSharp = this.capabilities.sharpModule;
|
|
@@ -198,6 +198,317 @@ class RetoldRemoteUltravisorDispatcher extends libFableServiceProviderBase
|
|
|
198
198
|
});
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Check if a specific capability is available on any connected beacon.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} pCapability - The capability name (e.g. 'MediaConversion', 'Shell')
|
|
205
|
+
* @returns {boolean} True if at least one beacon has this capability
|
|
206
|
+
*/
|
|
207
|
+
hasCapability(pCapability)
|
|
208
|
+
{
|
|
209
|
+
return this._Available && this._Capabilities.indexOf(pCapability) >= 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Dispatch a structured media conversion to Ultravisor using the
|
|
214
|
+
* MediaConversion capability (orator-conversion beacon provider).
|
|
215
|
+
*
|
|
216
|
+
* Falls back to dispatchMediaCommand() (Shell) if MediaConversion
|
|
217
|
+
* is not available.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} pOptions - Conversion options
|
|
220
|
+
* @param {string} pOptions.Action - Conversion action (e.g. 'ImageResize', 'PdfPageToPng')
|
|
221
|
+
* @param {string} [pOptions.InputPath] - Relative path to source file (from content root)
|
|
222
|
+
* @param {string} [pOptions.InputFilename] - Filename for the downloaded source
|
|
223
|
+
* @param {string} [pOptions.OutputFilename] - Name of the output file
|
|
224
|
+
* @param {number} [pOptions.Width] - Width for ImageResize
|
|
225
|
+
* @param {number} [pOptions.Height] - Height for ImageResize
|
|
226
|
+
* @param {string} [pOptions.Format] - Output format for ImageResize
|
|
227
|
+
* @param {number} [pOptions.Quality] - Quality for lossy formats
|
|
228
|
+
* @param {number} [pOptions.Page] - PDF page number (1-based)
|
|
229
|
+
* @param {number} [pOptions.LongSidePixels] - Max dimension for sized PDF renders
|
|
230
|
+
* @param {string} [pOptions.AffinityKey] - Affinity routing key
|
|
231
|
+
* @param {number} [pOptions.TimeoutMs] - Timeout in ms (default 300000)
|
|
232
|
+
* @param {function} fCallback - function(pError, pResult)
|
|
233
|
+
*/
|
|
234
|
+
dispatchConversion(pOptions, fCallback)
|
|
235
|
+
{
|
|
236
|
+
if (!this.hasCapability('MediaConversion'))
|
|
237
|
+
{
|
|
238
|
+
// Fall back to shell dispatch if caller provides a Command
|
|
239
|
+
if (pOptions.FallbackCommand)
|
|
240
|
+
{
|
|
241
|
+
return this.dispatchMediaCommand({
|
|
242
|
+
Command: pOptions.FallbackCommand,
|
|
243
|
+
InputPath: pOptions.InputPath,
|
|
244
|
+
InputFilename: pOptions.InputFilename,
|
|
245
|
+
OutputFilename: pOptions.OutputFilename,
|
|
246
|
+
AffinityKey: pOptions.AffinityKey,
|
|
247
|
+
TimeoutMs: pOptions.TimeoutMs
|
|
248
|
+
}, fCallback);
|
|
249
|
+
}
|
|
250
|
+
return fCallback(new Error('MediaConversion capability not available and no fallback command provided'));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let tmpSettings = {
|
|
254
|
+
InputFile: pOptions.InputFilename ||
|
|
255
|
+
(pOptions.InputPath ? pOptions.InputPath.split('/').pop() : 'source_file'),
|
|
256
|
+
OutputFile: pOptions.OutputFilename || 'output'
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Pass through action-specific settings
|
|
260
|
+
if (pOptions.Width) tmpSettings.Width = pOptions.Width;
|
|
261
|
+
if (pOptions.Height) tmpSettings.Height = pOptions.Height;
|
|
262
|
+
if (pOptions.Format) tmpSettings.Format = pOptions.Format;
|
|
263
|
+
if (pOptions.Quality) tmpSettings.Quality = pOptions.Quality;
|
|
264
|
+
if (pOptions.Page) tmpSettings.Page = pOptions.Page;
|
|
265
|
+
if (pOptions.LongSidePixels) tmpSettings.LongSidePixels = pOptions.LongSidePixels;
|
|
266
|
+
|
|
267
|
+
// Set up source file download
|
|
268
|
+
if (pOptions.InputPath && this._ContentAPIURL)
|
|
269
|
+
{
|
|
270
|
+
let tmpEncodedPath = pOptions.InputPath.split('/').map(encodeURIComponent).join('/');
|
|
271
|
+
tmpSettings.SourceURL = this._ContentAPIURL + '/content/' + tmpEncodedPath;
|
|
272
|
+
tmpSettings.SourceFilename = tmpSettings.InputFile;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Set up output file collection
|
|
276
|
+
if (pOptions.OutputFilename)
|
|
277
|
+
{
|
|
278
|
+
tmpSettings.ReturnOutputAsBase64 = true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let tmpWorkItem = {
|
|
282
|
+
Capability: 'MediaConversion',
|
|
283
|
+
Action: pOptions.Action || 'ImageResize',
|
|
284
|
+
Settings: tmpSettings,
|
|
285
|
+
AffinityKey: pOptions.AffinityKey || '',
|
|
286
|
+
TimeoutMs: pOptions.TimeoutMs || 300000
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
this.dispatch(tmpWorkItem,
|
|
290
|
+
(pError, pResult) =>
|
|
291
|
+
{
|
|
292
|
+
if (pError)
|
|
293
|
+
{
|
|
294
|
+
return fCallback(pError);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// If we have base64 output data, decode it to a Buffer
|
|
298
|
+
if (pResult.Outputs && pResult.Outputs.OutputData)
|
|
299
|
+
{
|
|
300
|
+
try
|
|
301
|
+
{
|
|
302
|
+
pResult.OutputBuffer = Buffer.from(pResult.Outputs.OutputData, 'base64');
|
|
303
|
+
}
|
|
304
|
+
catch (pDecodeError)
|
|
305
|
+
{
|
|
306
|
+
return fCallback(new Error('Failed to decode output data: ' + pDecodeError.message));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return fCallback(null, pResult);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ================================================================
|
|
315
|
+
// Streaming Dispatch (binary-framed)
|
|
316
|
+
// ================================================================
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Dispatch a work item with binary-framed streaming.
|
|
320
|
+
*
|
|
321
|
+
* Uses the /Beacon/Work/DispatchStream endpoint which returns a
|
|
322
|
+
* binary frame stream instead of a single JSON response. This
|
|
323
|
+
* enables real-time progress updates and efficient binary output
|
|
324
|
+
* transfer without base64 re-encoding overhead.
|
|
325
|
+
*
|
|
326
|
+
* Frame protocol (binary-frames-v1):
|
|
327
|
+
* [1 byte type][4 bytes payload length (uint32 big-endian)][payload]
|
|
328
|
+
* Type 0x01: Progress (JSON: { Percent, Message, Step, TotalSteps })
|
|
329
|
+
* Type 0x02: Intermediate (raw binary: e.g. thumbnail preview)
|
|
330
|
+
* Type 0x03: Final output (raw binary: completed file)
|
|
331
|
+
* Type 0x04: Result (JSON: { Success, Outputs, Log })
|
|
332
|
+
* Type 0x05: Error (JSON: { Error })
|
|
333
|
+
*
|
|
334
|
+
* @param {object} pWorkItem - Work item details (same as dispatch())
|
|
335
|
+
* @param {object} pCallbacks - Event callbacks:
|
|
336
|
+
* {
|
|
337
|
+
* onProgress: function({ Percent, Message, Step, TotalSteps }) — optional
|
|
338
|
+
* onBinaryData: function(Buffer) — optional, intermediate binary data
|
|
339
|
+
* onError: function({ Error }) — optional, non-fatal error notification
|
|
340
|
+
* }
|
|
341
|
+
* @param {function} fCallback - function(pError, pResult) called on completion.
|
|
342
|
+
* pResult includes OutputBuffer (Buffer) if final binary output was streamed.
|
|
343
|
+
*/
|
|
344
|
+
dispatchStream(pWorkItem, pCallbacks, fCallback)
|
|
345
|
+
{
|
|
346
|
+
if (!this._UltravisorURL)
|
|
347
|
+
{
|
|
348
|
+
return fCallback(new Error('Ultravisor Dispatcher: not configured'));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let tmpParsedURL;
|
|
352
|
+
try
|
|
353
|
+
{
|
|
354
|
+
tmpParsedURL = new URL(this._UltravisorURL);
|
|
355
|
+
}
|
|
356
|
+
catch (pError)
|
|
357
|
+
{
|
|
358
|
+
return fCallback(new Error('Invalid UltravisorURL: ' + this._UltravisorURL));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let tmpLib = tmpParsedURL.protocol === 'https:' ? libHTTPS : libHTTP;
|
|
362
|
+
|
|
363
|
+
let tmpOptions = {
|
|
364
|
+
hostname: tmpParsedURL.hostname,
|
|
365
|
+
port: tmpParsedURL.port || (tmpParsedURL.protocol === 'https:' ? 443 : 80),
|
|
366
|
+
path: '/Beacon/Work/DispatchStream',
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: {
|
|
369
|
+
'Content-Type': 'application/json',
|
|
370
|
+
'Connection': 'keep-alive'
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
let tmpCallbackFired = false;
|
|
375
|
+
let tmpComplete = (pError, pResult) =>
|
|
376
|
+
{
|
|
377
|
+
if (tmpCallbackFired) { return; }
|
|
378
|
+
tmpCallbackFired = true;
|
|
379
|
+
fCallback(pError, pResult);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
let tmpReq = tmpLib.request(tmpOptions, (pResponse) =>
|
|
383
|
+
{
|
|
384
|
+
// Non-streaming error response (4xx/5xx before stream starts)
|
|
385
|
+
if (pResponse.statusCode >= 400)
|
|
386
|
+
{
|
|
387
|
+
let tmpData = '';
|
|
388
|
+
pResponse.on('data', (pChunk) => { tmpData += pChunk; });
|
|
389
|
+
pResponse.on('end', () =>
|
|
390
|
+
{
|
|
391
|
+
try
|
|
392
|
+
{
|
|
393
|
+
let tmpParsed = JSON.parse(tmpData);
|
|
394
|
+
tmpComplete(new Error(tmpParsed.Error || `HTTP ${pResponse.statusCode}`));
|
|
395
|
+
}
|
|
396
|
+
catch (pParseError)
|
|
397
|
+
{
|
|
398
|
+
tmpComplete(new Error(`HTTP ${pResponse.statusCode}: ${tmpData.substring(0, 200)}`));
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
pResponse.on('error', tmpComplete);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Binary frame stream parser
|
|
406
|
+
let tmpBuffer = Buffer.alloc(0);
|
|
407
|
+
let tmpLastResult = null;
|
|
408
|
+
let tmpBinaryChunks = [];
|
|
409
|
+
|
|
410
|
+
pResponse.on('data', (pChunk) =>
|
|
411
|
+
{
|
|
412
|
+
tmpBuffer = Buffer.concat([tmpBuffer, pChunk]);
|
|
413
|
+
|
|
414
|
+
// Parse complete frames from the buffer
|
|
415
|
+
while (tmpBuffer.length >= 5)
|
|
416
|
+
{
|
|
417
|
+
let tmpPayloadLen = tmpBuffer.readUInt32BE(1);
|
|
418
|
+
|
|
419
|
+
if (tmpBuffer.length < 5 + tmpPayloadLen)
|
|
420
|
+
{
|
|
421
|
+
break; // Need more data for this frame
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let tmpType = tmpBuffer.readUInt8(0);
|
|
425
|
+
let tmpPayload = tmpBuffer.slice(5, 5 + tmpPayloadLen);
|
|
426
|
+
tmpBuffer = tmpBuffer.slice(5 + tmpPayloadLen);
|
|
427
|
+
|
|
428
|
+
switch (tmpType)
|
|
429
|
+
{
|
|
430
|
+
case 0x01: // Progress
|
|
431
|
+
if (pCallbacks && pCallbacks.onProgress)
|
|
432
|
+
{
|
|
433
|
+
try
|
|
434
|
+
{
|
|
435
|
+
pCallbacks.onProgress(JSON.parse(tmpPayload.toString()));
|
|
436
|
+
}
|
|
437
|
+
catch (pParseError)
|
|
438
|
+
{
|
|
439
|
+
// Ignore malformed progress frames
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
|
|
444
|
+
case 0x02: // Intermediate binary data
|
|
445
|
+
if (pCallbacks && pCallbacks.onBinaryData)
|
|
446
|
+
{
|
|
447
|
+
pCallbacks.onBinaryData(Buffer.from(tmpPayload));
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
|
|
451
|
+
case 0x03: // Final binary output
|
|
452
|
+
tmpBinaryChunks.push(Buffer.from(tmpPayload));
|
|
453
|
+
break;
|
|
454
|
+
|
|
455
|
+
case 0x04: // Result metadata
|
|
456
|
+
try
|
|
457
|
+
{
|
|
458
|
+
tmpLastResult = JSON.parse(tmpPayload.toString());
|
|
459
|
+
}
|
|
460
|
+
catch (pParseError)
|
|
461
|
+
{
|
|
462
|
+
// Ignore malformed result frames
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
|
|
466
|
+
case 0x05: // Error
|
|
467
|
+
if (pCallbacks && pCallbacks.onError)
|
|
468
|
+
{
|
|
469
|
+
try
|
|
470
|
+
{
|
|
471
|
+
pCallbacks.onError(JSON.parse(tmpPayload.toString()));
|
|
472
|
+
}
|
|
473
|
+
catch (pParseError)
|
|
474
|
+
{
|
|
475
|
+
// Ignore malformed error frames
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
pResponse.on('end', () =>
|
|
484
|
+
{
|
|
485
|
+
if (tmpLastResult)
|
|
486
|
+
{
|
|
487
|
+
// Attach final binary output as a Buffer
|
|
488
|
+
if (tmpBinaryChunks.length > 0)
|
|
489
|
+
{
|
|
490
|
+
tmpLastResult.OutputBuffer = Buffer.concat(tmpBinaryChunks);
|
|
491
|
+
}
|
|
492
|
+
tmpComplete(null, tmpLastResult);
|
|
493
|
+
}
|
|
494
|
+
else
|
|
495
|
+
{
|
|
496
|
+
tmpComplete(new Error('Stream ended without result frame'));
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
pResponse.on('error', tmpComplete);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
tmpReq.on('error', tmpComplete);
|
|
504
|
+
|
|
505
|
+
// Disable socket timeout for long-running streaming dispatch
|
|
506
|
+
tmpReq.setTimeout(0);
|
|
507
|
+
|
|
508
|
+
tmpReq.write(JSON.stringify(pWorkItem));
|
|
509
|
+
tmpReq.end();
|
|
510
|
+
}
|
|
511
|
+
|
|
201
512
|
// ================================================================
|
|
202
513
|
// HTTP Transport
|
|
203
514
|
// ================================================================
|