retold-remote 0.0.1 → 0.0.2

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 (33) hide show
  1. package/html/index.html +2 -0
  2. package/package.json +20 -14
  3. package/source/Pict-Application-RetoldRemote.js +46 -5
  4. package/source/cli/RetoldRemote-CLI-Run.js +0 -0
  5. package/source/cli/RetoldRemote-Server-Setup.js +790 -8
  6. package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
  7. package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
  8. package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
  9. package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
  10. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
  11. package/source/server/RetoldRemote-ArchiveService.js +830 -0
  12. package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
  13. package/source/server/RetoldRemote-EbookService.js +242 -0
  14. package/source/server/RetoldRemote-MediaService.js +1 -1
  15. package/source/server/RetoldRemote-ToolDetector.js +31 -1
  16. package/source/server/RetoldRemote-VideoFrameService.js +486 -0
  17. package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
  18. package/source/views/PictView-Remote-Gallery.js +141 -2
  19. package/source/views/PictView-Remote-Layout.js +18 -27
  20. package/source/views/PictView-Remote-MediaViewer.js +638 -39
  21. package/source/views/PictView-Remote-SettingsPanel.js +23 -0
  22. package/source/views/PictView-Remote-TopBar.js +121 -0
  23. package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
  24. package/web-application/index.html +2 -0
  25. package/web-application/js/epub.min.js +1 -0
  26. package/web-application/retold-remote.js +7030 -1244
  27. package/web-application/retold-remote.js.map +1 -1
  28. package/web-application/retold-remote.min.js +13 -44
  29. package/web-application/retold-remote.min.js.map +1 -1
  30. package/web-application/retold-remote.compatible.js +0 -5764
  31. package/web-application/retold-remote.compatible.js.map +0 -1
  32. package/web-application/retold-remote.compatible.min.js +0 -120
  33. package/web-application/retold-remote.compatible.min.js.map +0 -1
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Retold Remote -- Video Frame Extraction Service
3
+ *
4
+ * Extracts evenly-spaced frames from a video using ffmpeg/ffprobe.
5
+ * Frames are cached so repeated requests are instant.
6
+ *
7
+ * API:
8
+ * extractFrames(pAbsPath, pRelPath, pOptions, fCallback)
9
+ * -> { Frames: [{ Index, Timestamp, TimestampFormatted, Path }], Duration, ... }
10
+ *
11
+ * @license MIT
12
+ */
13
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
14
+ const libFs = require('fs');
15
+ const libPath = require('path');
16
+ const libCrypto = require('crypto');
17
+ const libChildProcess = require('child_process');
18
+
19
+ const _DefaultServiceConfiguration =
20
+ {
21
+ "ContentPath": ".",
22
+ "CachePath": null,
23
+ "DefaultFrameCount": 20,
24
+ "DefaultFrameWidth": 640,
25
+ "DefaultFrameHeight": 360,
26
+ "DefaultFrameFormat": "jpg",
27
+ "SkipSeconds": 1,
28
+ "MinFrameInterval": 2
29
+ };
30
+
31
+ class RetoldRemoteVideoFrameService extends libFableServiceProviderBase
32
+ {
33
+ constructor(pFable, pOptions, pServiceHash)
34
+ {
35
+ super(pFable, pOptions, pServiceHash);
36
+
37
+ this.serviceType = 'RetoldRemoteVideoFrameService';
38
+
39
+ // Merge with defaults
40
+ for (let tmpKey in _DefaultServiceConfiguration)
41
+ {
42
+ if (!(tmpKey in this.options))
43
+ {
44
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
45
+ }
46
+ }
47
+
48
+ this.contentPath = libPath.resolve(this.options.ContentPath);
49
+
50
+ this.cachePath = this.options.CachePath
51
+ || libPath.join(process.cwd(), 'dist', 'retold-cache', 'video-frames');
52
+
53
+ // Ensure cache directory exists
54
+ if (!libFs.existsSync(this.cachePath))
55
+ {
56
+ libFs.mkdirSync(this.cachePath, { recursive: true });
57
+ }
58
+
59
+ this.fable.log.info(`Video Frame Service: cache at ${this.cachePath}`);
60
+ }
61
+
62
+ /**
63
+ * Get the cache directory for a specific video file.
64
+ * The key is based on the absolute path and modification time,
65
+ * so cache is automatically invalidated when the file changes.
66
+ *
67
+ * @param {string} pAbsPath - Absolute path to the video
68
+ * @param {number} pMtimeMs - Modification time in ms
69
+ * @param {number} pFrameCount - Number of frames requested
70
+ * @param {number} pWidth - Frame width
71
+ * @param {number} pHeight - Frame height
72
+ * @returns {string} Absolute path to the cache directory for this video
73
+ */
74
+ _getCacheDir(pAbsPath, pMtimeMs, pFrameCount, pWidth, pHeight)
75
+ {
76
+ let tmpInput = `${pAbsPath}:${pMtimeMs}:${pFrameCount}:${pWidth}x${pHeight}`;
77
+ let tmpHash = libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
78
+ return libPath.join(this.cachePath, tmpHash);
79
+ }
80
+
81
+ /**
82
+ * Probe a video file with ffprobe to get its duration.
83
+ *
84
+ * @param {string} pAbsPath - Absolute path to the video
85
+ * @param {Function} fCallback - Callback(pError, { duration, width, height, codec })
86
+ */
87
+ _probeVideo(pAbsPath, fCallback)
88
+ {
89
+ try
90
+ {
91
+ let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pAbsPath}"`;
92
+ let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 15000 });
93
+ let tmpData = JSON.parse(tmpOutput.toString());
94
+
95
+ let tmpResult = {};
96
+
97
+ if (tmpData.format)
98
+ {
99
+ tmpResult.duration = parseFloat(tmpData.format.duration) || null;
100
+ tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
101
+ tmpResult.size = parseInt(tmpData.format.size, 10) || null;
102
+ }
103
+
104
+ if (tmpData.streams)
105
+ {
106
+ for (let i = 0; i < tmpData.streams.length; i++)
107
+ {
108
+ let tmpStream = tmpData.streams[i];
109
+ if (tmpStream.codec_type === 'video')
110
+ {
111
+ tmpResult.width = tmpStream.width;
112
+ tmpResult.height = tmpStream.height;
113
+ tmpResult.codec = tmpStream.codec_name;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ return fCallback(null, tmpResult);
120
+ }
121
+ catch (pError)
122
+ {
123
+ return fCallback(pError);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Extract a single frame from a video at a given timestamp.
129
+ *
130
+ * @param {string} pAbsPath - Absolute path to the video
131
+ * @param {number} pTimestamp - Timestamp in seconds
132
+ * @param {string} pOutputPath - Absolute path for the output image
133
+ * @param {number} pWidth - Target width
134
+ * @param {number} pHeight - Target height
135
+ * @param {string} pFormat - Output format (jpg, png, webp)
136
+ * @returns {boolean} True if extraction succeeded
137
+ */
138
+ _extractFrame(pAbsPath, pTimestamp, pOutputPath, pWidth, pHeight, pFormat)
139
+ {
140
+ try
141
+ {
142
+ // Format timestamp as HH:MM:SS.mmm for ffmpeg
143
+ let tmpHours = Math.floor(pTimestamp / 3600);
144
+ let tmpMinutes = Math.floor((pTimestamp % 3600) / 60);
145
+ let tmpSeconds = pTimestamp % 60;
146
+ let tmpTimeStr = `${String(tmpHours).padStart(2, '0')}:${String(tmpMinutes).padStart(2, '0')}:${tmpSeconds.toFixed(3).padStart(6, '0')}`;
147
+
148
+ let tmpCodec = (pFormat === 'png') ? 'png' : (pFormat === 'webp') ? 'webp' : 'mjpeg';
149
+
150
+ let tmpCmd = `ffmpeg -ss ${tmpTimeStr} -i "${pAbsPath}" -vframes 1 -vf "scale=${pWidth}:${pHeight}:force_original_aspect_ratio=decrease" -c:v ${tmpCodec} -y "${pOutputPath}"`;
151
+ libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 30000 });
152
+ return libFs.existsSync(pOutputPath);
153
+ }
154
+ catch (pError)
155
+ {
156
+ this.fable.log.warn(`Frame extraction failed at ${pTimestamp}s: ${pError.message}`);
157
+ return false;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Format a timestamp in seconds to a human-readable string.
163
+ *
164
+ * @param {number} pSeconds - Timestamp in seconds
165
+ * @returns {string} Formatted string like "1:23:45" or "12:34"
166
+ */
167
+ _formatTimestamp(pSeconds)
168
+ {
169
+ let tmpHours = Math.floor(pSeconds / 3600);
170
+ let tmpMinutes = Math.floor((pSeconds % 3600) / 60);
171
+ let tmpSecs = Math.floor(pSeconds % 60);
172
+
173
+ if (tmpHours > 0)
174
+ {
175
+ return `${tmpHours}:${String(tmpMinutes).padStart(2, '0')}:${String(tmpSecs).padStart(2, '0')}`;
176
+ }
177
+ return `${tmpMinutes}:${String(tmpSecs).padStart(2, '0')}`;
178
+ }
179
+
180
+ /**
181
+ * Calculate evenly-spaced timestamps for frame extraction.
182
+ * Skips the first and last few seconds (configurable).
183
+ *
184
+ * @param {number} pDuration - Total video duration in seconds
185
+ * @param {number} pFrameCount - Desired number of frames
186
+ * @param {number} pSkipSeconds - Seconds to skip at start and end
187
+ * @returns {number[]} Array of timestamps in seconds
188
+ */
189
+ _calculateTimestamps(pDuration, pFrameCount, pSkipSeconds)
190
+ {
191
+ let tmpStart = Math.min(pSkipSeconds, pDuration * 0.05);
192
+ let tmpEnd = pDuration - Math.min(pSkipSeconds, pDuration * 0.05);
193
+
194
+ // If the video is very short, just grab what we can
195
+ if (tmpEnd <= tmpStart)
196
+ {
197
+ tmpStart = 0;
198
+ tmpEnd = pDuration;
199
+ }
200
+
201
+ let tmpUsableDuration = tmpEnd - tmpStart;
202
+
203
+ // Don't extract more frames than we have seconds for
204
+ let tmpMinInterval = this.options.MinFrameInterval;
205
+ let tmpMaxFrames = Math.max(1, Math.floor(tmpUsableDuration / tmpMinInterval));
206
+ let tmpActualFrameCount = Math.min(pFrameCount, tmpMaxFrames);
207
+
208
+ if (tmpActualFrameCount <= 1)
209
+ {
210
+ return [tmpStart + tmpUsableDuration / 2];
211
+ }
212
+
213
+ let tmpTimestamps = [];
214
+ let tmpInterval = tmpUsableDuration / (tmpActualFrameCount - 1);
215
+
216
+ for (let i = 0; i < tmpActualFrameCount; i++)
217
+ {
218
+ tmpTimestamps.push(tmpStart + (i * tmpInterval));
219
+ }
220
+
221
+ return tmpTimestamps;
222
+ }
223
+
224
+ /**
225
+ * Extract evenly-spaced frames from a video.
226
+ * Results are cached for fast repeated access.
227
+ *
228
+ * @param {string} pAbsPath - Absolute path to the video file
229
+ * @param {string} pRelPath - Relative path (for the response)
230
+ * @param {object} pOptions - { count, width, height, format }
231
+ * @param {Function} fCallback - Callback(pError, pResult)
232
+ */
233
+ extractFrames(pAbsPath, pRelPath, pOptions, fCallback)
234
+ {
235
+ let tmpSelf = this;
236
+ let tmpCount = parseInt(pOptions.count, 10) || this.options.DefaultFrameCount;
237
+ let tmpWidth = parseInt(pOptions.width, 10) || this.options.DefaultFrameWidth;
238
+ let tmpHeight = parseInt(pOptions.height, 10) || this.options.DefaultFrameHeight;
239
+ let tmpFormat = pOptions.format || this.options.DefaultFrameFormat;
240
+
241
+ // Clamp values
242
+ tmpCount = Math.min(Math.max(tmpCount, 1), 100);
243
+ tmpWidth = Math.min(Math.max(tmpWidth, 64), 1920);
244
+ tmpHeight = Math.min(Math.max(tmpHeight, 64), 1080);
245
+
246
+ // Validate format
247
+ if (!{ 'jpg': true, 'png': true, 'webp': true }[tmpFormat])
248
+ {
249
+ tmpFormat = 'jpg';
250
+ }
251
+
252
+ // Get file stats for cache key
253
+ let tmpStat;
254
+ try
255
+ {
256
+ tmpStat = libFs.statSync(pAbsPath);
257
+ }
258
+ catch (pError)
259
+ {
260
+ return fCallback(new Error('File not found.'));
261
+ }
262
+
263
+ let tmpCacheDir = this._getCacheDir(pAbsPath, tmpStat.mtimeMs, tmpCount, tmpWidth, tmpHeight);
264
+
265
+ // Check if we have a cached manifest
266
+ let tmpManifestPath = libPath.join(tmpCacheDir, 'manifest.json');
267
+ if (libFs.existsSync(tmpManifestPath))
268
+ {
269
+ try
270
+ {
271
+ let tmpManifest = JSON.parse(libFs.readFileSync(tmpManifestPath, 'utf8'));
272
+ this.fable.log.info(`Video frames cache hit for ${pRelPath}`);
273
+ return fCallback(null, tmpManifest);
274
+ }
275
+ catch (pError)
276
+ {
277
+ // Corrupted manifest, regenerate
278
+ }
279
+ }
280
+
281
+ // Probe the video for duration
282
+ this._probeVideo(pAbsPath,
283
+ (pError, pVideoInfo) =>
284
+ {
285
+ if (pError || !pVideoInfo || !pVideoInfo.duration)
286
+ {
287
+ return fCallback(new Error('Could not probe video. ffprobe may not be available.'));
288
+ }
289
+
290
+ let tmpDuration = pVideoInfo.duration;
291
+
292
+ // Calculate timestamps
293
+ let tmpTimestamps = tmpSelf._calculateTimestamps(
294
+ tmpDuration, tmpCount, tmpSelf.options.SkipSeconds);
295
+
296
+ // Ensure cache directory exists
297
+ if (!libFs.existsSync(tmpCacheDir))
298
+ {
299
+ libFs.mkdirSync(tmpCacheDir, { recursive: true });
300
+ }
301
+
302
+ let tmpFrames = [];
303
+ let tmpExtractedCount = 0;
304
+
305
+ tmpSelf.fable.log.info(`Extracting ${tmpTimestamps.length} frames from ${pRelPath} (${tmpDuration.toFixed(1)}s)`);
306
+
307
+ for (let i = 0; i < tmpTimestamps.length; i++)
308
+ {
309
+ let tmpTimestamp = tmpTimestamps[i];
310
+ let tmpFrameFilename = `frame_${String(i).padStart(4, '0')}.${tmpFormat}`;
311
+ let tmpFramePath = libPath.join(tmpCacheDir, tmpFrameFilename);
312
+
313
+ let tmpSuccess = tmpSelf._extractFrame(
314
+ pAbsPath, tmpTimestamp, tmpFramePath, tmpWidth, tmpHeight, tmpFormat);
315
+
316
+ if (tmpSuccess)
317
+ {
318
+ let tmpFrameStat = libFs.statSync(tmpFramePath);
319
+ tmpFrames.push(
320
+ {
321
+ Index: i,
322
+ Timestamp: tmpTimestamp,
323
+ TimestampFormatted: tmpSelf._formatTimestamp(tmpTimestamp),
324
+ Filename: tmpFrameFilename,
325
+ Size: tmpFrameStat.size
326
+ });
327
+ tmpExtractedCount++;
328
+ }
329
+ }
330
+
331
+ if (tmpExtractedCount === 0)
332
+ {
333
+ return fCallback(new Error('Failed to extract any frames from the video.'));
334
+ }
335
+
336
+ let tmpResult =
337
+ {
338
+ Success: true,
339
+ Path: pRelPath,
340
+ Duration: tmpDuration,
341
+ DurationFormatted: tmpSelf._formatTimestamp(tmpDuration),
342
+ VideoWidth: pVideoInfo.width,
343
+ VideoHeight: pVideoInfo.height,
344
+ Codec: pVideoInfo.codec,
345
+ Bitrate: pVideoInfo.bitrate,
346
+ FileSize: pVideoInfo.size || tmpStat.size,
347
+ FrameCount: tmpExtractedCount,
348
+ FrameWidth: tmpWidth,
349
+ FrameHeight: tmpHeight,
350
+ FrameFormat: tmpFormat,
351
+ CacheKey: libPath.basename(tmpCacheDir),
352
+ Frames: tmpFrames
353
+ };
354
+
355
+ // Write manifest to cache
356
+ try
357
+ {
358
+ libFs.writeFileSync(tmpManifestPath, JSON.stringify(tmpResult, null, '\t'));
359
+ }
360
+ catch (pWriteError)
361
+ {
362
+ tmpSelf.fable.log.warn(`Could not write frame manifest: ${pWriteError.message}`);
363
+ }
364
+
365
+ tmpSelf.fable.log.info(`Extracted ${tmpExtractedCount} frames for ${pRelPath}`);
366
+ return fCallback(null, tmpResult);
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Extract a single frame at an arbitrary timestamp and save it into
372
+ * an existing cache directory. Returns the frame metadata on success.
373
+ *
374
+ * @param {string} pAbsPath - Absolute path to the video file
375
+ * @param {string} pCacheKey - Existing cache directory name (from extractFrames)
376
+ * @param {number} pTimestamp - Timestamp in seconds
377
+ * @param {object} pOptions - { width, height, format }
378
+ * @param {Function} fCallback - Callback(pError, pFrameInfo)
379
+ */
380
+ extractSingleFrame(pAbsPath, pCacheKey, pTimestamp, pOptions, fCallback)
381
+ {
382
+ let tmpWidth = parseInt(pOptions.width, 10) || this.options.DefaultFrameWidth;
383
+ let tmpHeight = parseInt(pOptions.height, 10) || this.options.DefaultFrameHeight;
384
+ let tmpFormat = pOptions.format || this.options.DefaultFrameFormat;
385
+
386
+ // Clamp
387
+ tmpWidth = Math.min(Math.max(tmpWidth, 64), 1920);
388
+ tmpHeight = Math.min(Math.max(tmpHeight, 64), 1080);
389
+ if (!{ 'jpg': true, 'png': true, 'webp': true }[tmpFormat])
390
+ {
391
+ tmpFormat = 'jpg';
392
+ }
393
+
394
+ // Sanitize cache key
395
+ if (!pCacheKey || pCacheKey.includes('..') || pCacheKey.includes('/') || pCacheKey.includes('\\'))
396
+ {
397
+ return fCallback(new Error('Invalid cache key.'));
398
+ }
399
+
400
+ let tmpCacheDir = libPath.join(this.cachePath, pCacheKey);
401
+ if (!libFs.existsSync(tmpCacheDir))
402
+ {
403
+ return fCallback(new Error('Cache directory not found. Extract frames first.'));
404
+ }
405
+
406
+ // Generate a unique filename based on timestamp to avoid collisions
407
+ let tmpTimestampStr = pTimestamp.toFixed(3).replace('.', '_');
408
+ let tmpFilename = `frame_custom_${tmpTimestampStr}.${tmpFormat}`;
409
+ let tmpOutputPath = libPath.join(tmpCacheDir, tmpFilename);
410
+
411
+ // If already extracted, return immediately
412
+ if (libFs.existsSync(tmpOutputPath))
413
+ {
414
+ let tmpStat = libFs.statSync(tmpOutputPath);
415
+ return fCallback(null,
416
+ {
417
+ Success: true,
418
+ Timestamp: pTimestamp,
419
+ TimestampFormatted: this._formatTimestamp(pTimestamp),
420
+ Filename: tmpFilename,
421
+ Size: tmpStat.size
422
+ });
423
+ }
424
+
425
+ this.fable.log.info(`Extracting single frame at ${pTimestamp.toFixed(2)}s from ${pAbsPath}`);
426
+
427
+ let tmpSuccess = this._extractFrame(pAbsPath, pTimestamp, tmpOutputPath, tmpWidth, tmpHeight, tmpFormat);
428
+
429
+ if (!tmpSuccess)
430
+ {
431
+ return fCallback(new Error('Failed to extract frame at ' + pTimestamp.toFixed(2) + 's'));
432
+ }
433
+
434
+ let tmpStat = libFs.statSync(tmpOutputPath);
435
+ return fCallback(null,
436
+ {
437
+ Success: true,
438
+ Timestamp: pTimestamp,
439
+ TimestampFormatted: this._formatTimestamp(pTimestamp),
440
+ Filename: tmpFilename,
441
+ Size: tmpStat.size
442
+ });
443
+ }
444
+
445
+ /**
446
+ * Get the absolute path to a cached frame image.
447
+ *
448
+ * @param {string} pCacheKey - The cache key (directory name)
449
+ * @param {string} pFilename - The frame filename
450
+ * @returns {string|null} Absolute path or null if not found
451
+ */
452
+ getFramePath(pCacheKey, pFilename)
453
+ {
454
+ // Sanitize inputs to prevent directory traversal
455
+ if (!pCacheKey || !pFilename)
456
+ {
457
+ return null;
458
+ }
459
+ if (pCacheKey.includes('..') || pCacheKey.includes('/') || pCacheKey.includes('\\'))
460
+ {
461
+ return null;
462
+ }
463
+ if (pFilename.includes('..') || pFilename.includes('/') || pFilename.includes('\\'))
464
+ {
465
+ return null;
466
+ }
467
+
468
+ let tmpPath = libPath.join(this.cachePath, pCacheKey, pFilename);
469
+
470
+ // Double-check it's under our cache dir
471
+ let tmpResolved = libPath.resolve(tmpPath);
472
+ if (!tmpResolved.startsWith(this.cachePath))
473
+ {
474
+ return null;
475
+ }
476
+
477
+ if (libFs.existsSync(tmpPath))
478
+ {
479
+ return tmpPath;
480
+ }
481
+
482
+ return null;
483
+ }
484
+ }
485
+
486
+ module.exports = RetoldRemoteVideoFrameService;