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.
- package/html/index.html +2 -0
- package/package.json +20 -14
- package/source/Pict-Application-RetoldRemote.js +46 -5
- package/source/cli/RetoldRemote-CLI-Run.js +0 -0
- package/source/cli/RetoldRemote-Server-Setup.js +790 -8
- package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
- package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
- package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
- package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
- package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
- package/source/server/RetoldRemote-ArchiveService.js +830 -0
- package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
- package/source/server/RetoldRemote-EbookService.js +242 -0
- package/source/server/RetoldRemote-MediaService.js +1 -1
- package/source/server/RetoldRemote-ToolDetector.js +31 -1
- package/source/server/RetoldRemote-VideoFrameService.js +486 -0
- package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
- package/source/views/PictView-Remote-Gallery.js +141 -2
- package/source/views/PictView-Remote-Layout.js +18 -27
- package/source/views/PictView-Remote-MediaViewer.js +638 -39
- package/source/views/PictView-Remote-SettingsPanel.js +23 -0
- package/source/views/PictView-Remote-TopBar.js +121 -0
- package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
- package/web-application/index.html +2 -0
- package/web-application/js/epub.min.js +1 -0
- package/web-application/retold-remote.js +7030 -1244
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +13 -44
- package/web-application/retold-remote.min.js.map +1 -1
- package/web-application/retold-remote.compatible.js +0 -5764
- package/web-application/retold-remote.compatible.js.map +0 -1
- package/web-application/retold-remote.compatible.min.js +0 -120
- 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;
|