@eluvio/elv-client-js 4.2.14 → 4.2.16

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.
@@ -0,0 +1,177 @@
1
+ // Utility that accepts an object ID and generates a download URL
2
+
3
+ const { NewOpt } = require("./lib/options");
4
+ const Utility = require("./lib/Utility");
5
+ const path = require("path");
6
+ const fs = require("fs");
7
+ const https = require("https");
8
+
9
+ const ArgOutfile = require("./lib/concerns/ArgOutfile");
10
+ const ExistObj = require("./lib/concerns/ExistObj");
11
+ const FabricObject = require("./lib/concerns/FabricObject");
12
+ const DownloadFile = require("./lib/downloadFile");
13
+
14
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
15
+ const sanitizeFilename = (name, fallback) => {
16
+ if (!name) return fallback;
17
+ return name
18
+ .replace(/\s+/g, "_")
19
+ .replace(/\//g, "_")
20
+ .replace(/ - /g, "-")
21
+ };
22
+
23
+ class MezDownloadMp4 extends Utility {
24
+ blueprint() {
25
+ return {
26
+ concerns: [ArgOutfile, ExistObj, FabricObject],
27
+ options: [
28
+ NewOpt("versionHash", {
29
+ descTemplate: "specific versionHash to download (optional)",
30
+ type: "string"
31
+ }),
32
+ NewOpt("offering", {
33
+ descTemplate: "Offering name to use, defaults to 'default'",
34
+ type: "string"
35
+ }),
36
+ NewOpt("format", {
37
+ descTemplate: "Format to request (default mp4)",
38
+ type: "string"
39
+ }),
40
+ NewOpt("downloadDir", {
41
+ descTemplate: "Directory to save downloaded file",
42
+ type: "string"
43
+ })
44
+ ]
45
+ };
46
+ }
47
+
48
+ header() {
49
+ return `Download file for object ${this.args.objectId}`;
50
+ }
51
+
52
+ async body() {
53
+ const { libraryId, objectId } = await this.concerns.ExistObj.argsProc();
54
+ const client = await this.concerns.Client.get();
55
+
56
+ // -----------------------
57
+ // Determine versionHash
58
+ // -----------------------
59
+ const versionHash = await this.concerns.FabricObject.latestVersionHash({
60
+ libraryId,
61
+ objectId
62
+ });
63
+
64
+ const offeringName = this.args.offering || "default";
65
+ const format = this.args.format || "mp4";
66
+
67
+ try {
68
+ // Start media download job
69
+ const response = await client.MakeFileServiceRequest({
70
+ versionHash,
71
+ path: "/call/media/files",
72
+ method: "POST",
73
+ body: {
74
+ format,
75
+ offering: offeringName
76
+ }
77
+ });
78
+
79
+ const jobId = response.job_id;
80
+
81
+ // Poll job progress
82
+ let status;
83
+ do {
84
+ await sleep(2000);
85
+ status = await client.MakeFileServiceRequest({
86
+ versionHash,
87
+ path: `/call/media/files/${jobId}`
88
+ });
89
+
90
+ this.logger.log(`${(status?.progress || 0).toFixed(1)} / 100`);
91
+ } while (status?.status !== "completed");
92
+
93
+ // Clean filename
94
+ const filename = sanitizeFilename(status.filename, "file_download");
95
+
96
+ // Build download URL
97
+ const downloadUrl = await client.FabricUrl({
98
+ versionHash,
99
+ call: `/media/files/${jobId}/download`,
100
+ service: "files",
101
+ queryParams: {
102
+ "header-x_set_content_disposition": `attachment; filename=${filename}`
103
+ }
104
+ });
105
+
106
+ const output = {
107
+ libraryId,
108
+ objectId,
109
+ versionHash,
110
+ jobId,
111
+ filename,
112
+ downloadUrl
113
+ };
114
+
115
+ // Display
116
+ this.logger.log("\n=== Download Info ===\n");
117
+ this.logger.logTable([{
118
+ "Version Hash": output.versionHash,
119
+ "Filename": output.filename
120
+ }]);
121
+ this.logger.log("Download URL:\n", output.downloadUrl, "\n");
122
+
123
+ // -----------------------
124
+ // HTTPS download with progress bar
125
+ // -----------------------
126
+ if (downloadUrl) {
127
+
128
+ const targetDir = this.args.downloadDir
129
+ ? path.resolve(this.args.downloadDir)
130
+ : process.cwd();
131
+
132
+ if (!fs.existsSync(targetDir)) {
133
+ fs.mkdirSync(targetDir, { recursive: true });
134
+ }
135
+
136
+ const outputFile = path.join(targetDir, output.filename || "download.mp4");
137
+
138
+ this.logger.log(`Downloading via https → ${outputFile}\n`);
139
+
140
+ try {
141
+ await DownloadFile({
142
+ url: downloadUrl,
143
+ dest: outputFile,
144
+ logger: this.logger,
145
+ maxRedirects: 5,
146
+ });
147
+
148
+ this.logger.log(`\nDownload complete: ${outputFile}`);
149
+ } catch (err) {
150
+ this.logger.error("\nHTTPS download failed:", err.message);
151
+ }
152
+ }
153
+
154
+ // -----------------------
155
+ // Outfile support
156
+ // -----------------------
157
+ if (this.args.outfile) {
158
+ if (this.args.json) {
159
+ this.concerns.ArgOutfile.writeJson({ obj: output });
160
+ } else {
161
+ this.concerns.ArgOutfile.writeTable({ list: [output] });
162
+ }
163
+ }
164
+
165
+ } catch (error) {
166
+ this.logger.error(error);
167
+ this.logger.error(JSON.stringify(error, null, 2));
168
+ throw error;
169
+ }
170
+ }
171
+ }
172
+
173
+ if (require.main === module) {
174
+ Utility.cmdLineInvoke(MezDownloadMp4);
175
+ } else {
176
+ module.exports = MezDownloadMp4;
177
+ }
@@ -0,0 +1,88 @@
1
+ const fs = require("fs");
2
+ const https = require("https");
3
+
4
+ /**
5
+ * Download a file over HTTPS with redirect handling and progress reporting.
6
+ *
7
+ * @param {Object} params
8
+ * @param {string} params.url - Initial download URL
9
+ * @param {string} params.dest - Destination file path
10
+ * @param {Object} [params.logger=console] - Logger with log/error methods
11
+ * @param {number} [params.maxRedirects=5] - Maximum redirect depth
12
+ * @returns {Promise<void>}
13
+ */
14
+ const DownloadFile = ({
15
+ url,
16
+ dest,
17
+ logger = console,
18
+ maxRedirects = 5
19
+ }) => {
20
+ const download = (currentUrl, redirects = 0) =>
21
+ new Promise((resolve, reject) => {
22
+ if (redirects > maxRedirects) {
23
+ return reject(new Error("Too many redirects"));
24
+ }
25
+
26
+ https.get(currentUrl, res => {
27
+
28
+ // Handle redirects
29
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
30
+ const nextUrl = res.headers.location.startsWith("http")
31
+ ? res.headers.location
32
+ : new URL(res.headers.location, currentUrl).href;
33
+
34
+ logger.log(`Redirected → ${nextUrl}`);
35
+ return resolve(download(nextUrl, redirects + 1));
36
+ }
37
+
38
+ if (res.statusCode !== 200) {
39
+ return reject(new Error(`Download failed (HTTP ${res.statusCode})`));
40
+ }
41
+
42
+ const totalSize = parseInt(res.headers["content-length"] || "0", 10);
43
+ let downloaded = 0;
44
+
45
+ const writeStream = fs.createWriteStream(dest);
46
+
47
+ // Progress reporting
48
+ res.on("data", chunk => {
49
+ downloaded += chunk.length;
50
+
51
+ if (totalSize > 0) {
52
+ const percent = (downloaded / totalSize) * 100;
53
+ const mbDownloaded = (downloaded / (1024 * 1024)).toFixed(2);
54
+ const mbTotal = (totalSize / (1024 * 1024)).toFixed(2);
55
+
56
+ const barLength = 30;
57
+ const filled = Math.round((percent / 100) * barLength);
58
+ const bar = "█".repeat(filled) + "░".repeat(barLength - filled);
59
+
60
+ process.stdout.write(
61
+ `\r${bar} ${percent.toFixed(1)}% (${mbDownloaded} MB / ${mbTotal} MB)`
62
+ );
63
+ } else {
64
+ process.stdout.write(
65
+ `\rDownloaded ${(downloaded / (1024 * 1024)).toFixed(2)} MB`
66
+ );
67
+ }
68
+ });
69
+
70
+ res.on("end", () => process.stdout.write("\n"));
71
+
72
+ res.pipe(writeStream);
73
+
74
+ writeStream.on("finish", () => {
75
+ writeStream.close(resolve);
76
+ });
77
+
78
+ writeStream.on("error", err => {
79
+ fs.unlink(dest, () => reject(err));
80
+ });
81
+
82
+ }).on("error", reject);
83
+ });
84
+
85
+ return download(url);
86
+ };
87
+
88
+ module.exports = DownloadFile;
@@ -0,0 +1,431 @@
1
+ // copied from
2
+ // https://github.com/eluv-io/elv-video-editor/blob/4408dd5a98536a13d16f80d2bca943077289765c/src/utils/FrameAccurateVideo.js
3
+ // (no changes made)
4
+
5
+ // import Fraction from "fraction.js";
6
+ const Fraction = require("fraction.js");
7
+
8
+ const FrameRateNumerator = {
9
+ NTSC: 30000,
10
+ NTSCFilm: 24000,
11
+ NTSCHD: 60000,
12
+ PAL: 25,
13
+ PALHD: 50,
14
+ Film: 24,
15
+ Web: 30,
16
+ High: 60
17
+ };
18
+
19
+ const FrameRateDenominator = {
20
+ NTSC: 1001,
21
+ NTSCFilm: 1001,
22
+ NTSCHD: 1001,
23
+ PAL: 1,
24
+ PALHD: 1,
25
+ Film: 1,
26
+ Web: 1,
27
+ High: 1
28
+ };
29
+
30
+ const FrameRates = {
31
+ NTSC: Fraction(FrameRateNumerator["NTSC"]).div(FrameRateDenominator["NTSC"]),
32
+ NTSCFilm: Fraction(FrameRateNumerator["NTSCFilm"]).div(FrameRateDenominator["NTSC"]),
33
+ NTSCHD: Fraction(FrameRateNumerator["NTSCHD"]).div(FrameRateDenominator["NTSCHD"]),
34
+ PAL: Fraction(25),
35
+ PALHD: Fraction(50),
36
+ Film: Fraction(24),
37
+ Web: Fraction(30),
38
+ High: Fraction(60)
39
+ };
40
+
41
+ /**
42
+ * FrameAccurateVideo constructor.
43
+ *
44
+ * Initializes a video wrapper that supports frame-accurate timing based on a
45
+ * specified frame rate. Handles drop-frame logic automatically for NTSC-based
46
+ * frame rates and optionally registers a time-update callback on the video.
47
+ *
48
+ * @param {Object} params
49
+ * @param {HTMLVideoElement} params.video - Video element to track.
50
+ * @param {number} params.frameRate - Nominal frame rate (e.g. 29.97, 30).
51
+ * @param {Object} params.frameRateRat - Rational frame rate representation.
52
+ * @param {boolean} [params.dropFrame=false] - Enable drop-frame timecode (only valid for NTSC rates).
53
+ * @param {Function} params.callback - Callback invoked on frame/time updates.
54
+ */
55
+ class FrameAccurateVideo {
56
+ constructor({video, frameRate, frameRateRat, dropFrame=false, callback}) {
57
+ this.video = video;
58
+ this.SetFrameRate({rate: frameRate, rateRat: frameRateRat});
59
+ this.callback = callback;
60
+
61
+ // Only set drop frame if appropriate based on frame rate
62
+ const frameRateKey = FrameAccurateVideo.FractionToRateKey(`${this.frameRateNumerator}/${this.frameRateDenominator}`);
63
+ this.dropFrame = dropFrame && ["NTSC", "NTSCFilm", "NTSCHD"].includes(frameRateKey);
64
+
65
+ if(this.video && callback) {
66
+ this.RegisterCallback();
67
+ }
68
+
69
+ this.TimeToString = FrameAccurateVideo.TimeToString.bind(this);
70
+ this.Update = this.Update.bind(this);
71
+ }
72
+
73
+ static FractionToRateKey(input) {
74
+ let rate = input;
75
+ if(typeof input === "string") {
76
+ if(input.includes("/")) {
77
+ rate = input.split("/");
78
+ rate = rate[0] / rate[1];
79
+ } else {
80
+ rate = parseFloat(input);
81
+ }
82
+ }
83
+
84
+ switch(rate) {
85
+ case 24:
86
+ return "Film";
87
+ case 25:
88
+ return "PAL";
89
+ case 30:
90
+ return "Web";
91
+ case 50:
92
+ return "PALHD";
93
+ case 60:
94
+ return "High";
95
+ default:
96
+ if(Math.abs(24 - rate) < 0.1) {
97
+ return "NTSCFilm";
98
+ } else if(Math.abs(30 - rate) < 0.1) {
99
+ return "NTSC";
100
+ } else if(Math.abs(60 - rate) < 0.1) {
101
+ return "NTSCHD";
102
+ }
103
+
104
+ console.error(`Unknown playback rate: ${input}`);
105
+
106
+ FrameRateNumerator.Unknown = parseInt(input.split("/")[0]);
107
+ FrameRateDenominator.Unknown = parseInt(input.split("/")[1]);
108
+ FrameRates.Unknown = rate;
109
+ return "Unknown";
110
+ }
111
+ }
112
+
113
+ static TimeToString({time, includeFractionalSeconds, format="description"}) {
114
+ let seconds = Fraction(time);
115
+ const hours = seconds.div(3600).floor().valueOf();
116
+ const minutes = seconds.div(60).mod(60).floor(0).valueOf();
117
+ seconds = seconds.mod(60);
118
+
119
+ let string = "";
120
+ if(format === "smpte") {
121
+ string = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
122
+ string += `:${seconds.floor().valueOf().toString().padStart(2, "0")}`;
123
+
124
+ if(includeFractionalSeconds && seconds.floor().valueOf() !== seconds.valueOf()) {
125
+ string += `.${parseFloat(seconds.valueOf().toFixed(4).toString().split(".")[1])}`;
126
+ }
127
+
128
+ return string;
129
+ }
130
+
131
+ if(hours > 0) {
132
+ string += `${hours}h `;
133
+ }
134
+
135
+ if(minutes > 0) {
136
+ string += `${minutes}m `;
137
+ }
138
+
139
+ if(!string || seconds > 0) {
140
+ if(includeFractionalSeconds) {
141
+ string += `${parseFloat(seconds.valueOf().toFixed(4))}s`;
142
+ } else {
143
+ string += `${seconds.floor().valueOf()}s`;
144
+ }
145
+ }
146
+
147
+ return string.trim();
148
+ }
149
+
150
+ /* Conversion utility methods */
151
+
152
+ static ParseRat(str) {
153
+ if(str.includes("/")) {
154
+ const num = parseInt(str.split("/")[0]);
155
+ const denom = parseInt(str.split("/")[1]);
156
+
157
+ return Number((num / denom).toFixed(3));
158
+ } else {
159
+ return parseInt(str);
160
+ }
161
+ }
162
+
163
+ SetFrameRate({rate, rateRat}) {
164
+ let num, denom;
165
+ if(rateRat) {
166
+ if(rateRat.includes("/")) {
167
+ num = parseInt(rateRat.split("/")[0]);
168
+ denom = parseInt(rateRat.split("/")[1]);
169
+ } else {
170
+ num = parseInt(rateRat);
171
+ denom = 1;
172
+ }
173
+
174
+ rate = Fraction(num).div(denom);
175
+ } else {
176
+ const rateKey = FrameAccurateVideo.FractionToRateKey(this.frameRate);
177
+ num = FrameRateNumerator[rateKey];
178
+ denom = FrameRateDenominator[rateKey];
179
+ }
180
+
181
+ this.frameRate = Fraction(rate);
182
+ this.frameRateNumerator = num;
183
+ this.frameRateDenominator = denom;
184
+ }
185
+
186
+ RatToFrame(rat) {
187
+ const [numerator, denominator] = rat.split("/");
188
+ const time = denominator ? Fraction(numerator).div(denominator) : numerator;
189
+
190
+ return this.TimeToFrame(time);
191
+ }
192
+
193
+ FrameToRat(frame) {
194
+ return `${frame * this.frameRateDenominator}/${this.frameRateNumerator}`;
195
+ }
196
+
197
+ FrameToTime(frame) {
198
+ if(frame === 0) { return 0; }
199
+
200
+ return Fraction(frame).div(this.frameRate).valueOf();
201
+ }
202
+
203
+ FrameToSMPTE(frame) {
204
+ return this.SMPTE(frame);
205
+ }
206
+
207
+ ProgressToTime(progress, duration=0) {
208
+ return Fraction(progress).mul(duration).valueOf();
209
+ }
210
+
211
+ ProgressToSMPTE(progress, duration=0) {
212
+ return this.TimeToSMPTE(Fraction(progress).mul(duration));
213
+ }
214
+
215
+ TimeToFrame(time, round=false) {
216
+ const frame = Fraction(time || 0).mul(this.frameRate);
217
+
218
+ if(round) {
219
+ return frame.round().valueOf();
220
+ } else {
221
+ return frame.floor().valueOf();
222
+ }
223
+ }
224
+
225
+ TimeToSMPTE(time) {
226
+ return this.SMPTE(this.TimeToFrame(time));
227
+ }
228
+
229
+ SMPTEToFrame(smpte) {
230
+ const components = smpte.split(/[:;]/).reverse();
231
+
232
+ const frames = Fraction(components[0]);
233
+ const seconds = Fraction(components[1]);
234
+ const minutes = Fraction(components[2]);
235
+ const hours = Fraction(components[3] || 0);
236
+
237
+ let skippedFrames = 0;
238
+ if(this.dropFrame) {
239
+ const skippedFramesPerMinute = this.frameRate.equals(FrameRates.NTSCHD) ? Fraction(4) : Fraction(2);
240
+ const totalMinutes = minutes.add(hours.mul(60));
241
+ const tenMinutes = totalMinutes.div(10).floor();
242
+ skippedFrames = totalMinutes.mul(skippedFramesPerMinute)
243
+ .sub(tenMinutes.mul(skippedFramesPerMinute));
244
+ }
245
+
246
+ return frames
247
+ .add(seconds.mul(this.frameRate.round()))
248
+ .add(minutes.mul(this.frameRate.round()).mul(60))
249
+ .add(hours.mul(this.frameRate.round()).mul(60).mul(60))
250
+ .sub(skippedFrames)
251
+ .valueOf();
252
+ }
253
+
254
+ SMPTEToTime(smpte) {
255
+ const frame = this.SMPTEToFrame(smpte);
256
+
257
+ return Fraction(frame).div(this.frameRate).valueOf();
258
+ }
259
+
260
+ /* Time Calculations */
261
+
262
+ Frame() {
263
+ return this.TimeToFrame(this.video?.currentTime || 0);
264
+ }
265
+
266
+ Pad(fraction) {
267
+ fraction = fraction.valueOf();
268
+ return fraction < 10 ? `0${fraction}` : fraction;
269
+ }
270
+
271
+ SMPTE(f) {
272
+ let frame = (typeof f !== "undefined" ? Fraction(f) : Fraction(this.Frame())).floor();
273
+ const frameRate = this.frameRate.round();
274
+
275
+ if(this.dropFrame) {
276
+ const framesPerMinute = this.frameRate.equals(FrameRates.NTSCHD) ? Fraction(4) : Fraction(2);
277
+ const tenMinutes = Fraction("17982").mul(framesPerMinute).div(2);
278
+ const oneMinute = Fraction("1798").mul(framesPerMinute).div(2);
279
+
280
+ const tenMinuteIntervals = frame.div(tenMinutes).floor();
281
+ let framesSinceLastInterval = frame.mod(tenMinutes);
282
+
283
+ // If framesSinceLastInterval < framesPerMinute
284
+ if(framesSinceLastInterval.compare(framesPerMinute) < 0) {
285
+ // This is where the jump from :59:29 -> :00:02 or :59:59 -> :00:04 happens
286
+ framesSinceLastInterval = framesSinceLastInterval.add(framesPerMinute);
287
+ }
288
+
289
+ frame = frame.add(
290
+ framesPerMinute.mul(tenMinuteIntervals).mul("9").add(
291
+ framesPerMinute.mul((framesSinceLastInterval.sub(framesPerMinute)).div(oneMinute).floor())
292
+ )
293
+ );
294
+ }
295
+
296
+ const hours = frame.div(frameRate.mul(3600)).mod(24).floor();
297
+ const minutes = frame.div(frameRate.mul(60)).mod(60).floor();
298
+ const seconds = frame.div(frameRate).mod(60).floor();
299
+ const frames = frame.mod(frameRate).floor();
300
+
301
+ const lastColon = this.dropFrame ? ";" : ":";
302
+
303
+ return `${this.Pad(hours)}:${this.Pad(minutes)}:${this.Pad(seconds)}${lastColon}${this.Pad(frames)}`;
304
+ }
305
+
306
+ TotalFrames(duration=0) {
307
+ return Math.floor(Fraction(duration).mul(this.frameRate).valueOf());
308
+ }
309
+
310
+ FrameToString({frame, includeFractionalSeconds}) {
311
+ let [hours, minutes, seconds, frames] = this.SMPTE(frame).split(":").map(e => parseInt(e));
312
+
313
+ if(includeFractionalSeconds) {
314
+ seconds = Fraction(frames).div(this.frameRate).add(seconds).round(4);
315
+ }
316
+
317
+ let string = "";
318
+ if(hours > 0) {
319
+ string += `${hours}h `;
320
+ }
321
+
322
+ if(minutes > 0) {
323
+ string += `${minutes}m `;
324
+ }
325
+
326
+ if(seconds > 0 || !string) {
327
+ string += `${seconds}s`;
328
+ }
329
+
330
+ return string.trim();
331
+ }
332
+
333
+ /* Controls */
334
+
335
+ SeekForward(frames=1) {
336
+ const frame = this.Frame();
337
+ this.Seek(frame.add(frames));
338
+ }
339
+
340
+ SeekBackward(frames=1) {
341
+ const frame = Fraction(this.Frame());
342
+ this.Seek(frame.sub(frames));
343
+ }
344
+
345
+ SeekPercentage(percent, duration) {
346
+ this.Seek(Fraction(this.TotalFrames(duration)).mul(percent));
347
+ }
348
+
349
+ Seek(frame) {
350
+ if(!this.video) { return; }
351
+
352
+ // Whenever seeking, stop comfortably in the middle of a frame
353
+ frame = Fraction(frame).floor().add(0.5);
354
+
355
+ this.video.currentTime = frame.div(this.frameRate).valueOf().toFixed(3);
356
+ }
357
+
358
+ /* Callbacks */
359
+
360
+ Update() {
361
+ if(this.callback) {
362
+ this.callback({
363
+ frame: this.Frame().valueOf(),
364
+ smpte: this.SMPTE()
365
+ });
366
+ }
367
+ }
368
+
369
+ RegisterCallback() {
370
+ if(!this.video) { return; }
371
+
372
+ this.Update();
373
+
374
+ this.video.onseeked = (event) => this.Update(event);
375
+ this.video.onseeking = (event) => this.Update(event);
376
+ this.video.onplay = () => this.AddListener();
377
+ this.video.onpause = () => {
378
+ // On pause, seek to the nearest frame
379
+ this.Seek(this.Frame() + (this.frameRate.valueOf() > 30 ? 2 : 1));
380
+ this.RemoveListener();
381
+ };
382
+
383
+ this.video.onended = () => this.RemoveListener();
384
+ this.video.onratechange = () => {
385
+ // Update listener rate
386
+ if(this.listener) {
387
+ this.RemoveListener();
388
+ this.AddListener();
389
+ }
390
+ };
391
+ }
392
+
393
+ RemoveCallback() {
394
+ if(!this.video) { return; }
395
+
396
+ this.video.onseeked = undefined;
397
+ this.video.onseeking = undefined;
398
+ this.video.onplay = undefined;
399
+ this.video.onpause = undefined;
400
+ this.video.onended = undefined;
401
+ this.video.onratechange = undefined;
402
+ }
403
+
404
+ AddListener() {
405
+ if(!this.video) { return; }
406
+
407
+ // Call once per frame - possible range 10hz - 50hz Prevent division by zero
408
+ const fps = Fraction(this.video.playbackRate).mul(this.frameRate).add(Fraction("0.00001"));
409
+ const interval = Math.min(Math.max(Fraction(1000).div(fps).valueOf(), 20), 100);
410
+
411
+ this.listener = setInterval(() => {
412
+ if(this.video.paused || this.video.ended) {
413
+ return;
414
+ }
415
+
416
+ this.Update();
417
+ }, interval);
418
+ }
419
+
420
+ RemoveListener() {
421
+ if(this.listener) {
422
+ clearInterval(this.listener);
423
+ }
424
+
425
+ this.Update();
426
+ }
427
+ }
428
+
429
+ module.exports = {
430
+ FrameAccurateVideo
431
+ };
@@ -31,6 +31,11 @@ const blueprint = {
31
31
  descTemplate: "Geographic region for the fabric nodes.",
32
32
  group: "API",
33
33
  type: "string"
34
+ }),
35
+ NewOpt("node", {
36
+ descTemplate: "Pin all fabric and file-service requests to a specific node hostname or URL (e.g. host-76-74-28-240.contentfabric.io). Overrides the nodes returned by the config URL.",
37
+ group: "API",
38
+ type: "string"
34
39
  })
35
40
  ]
36
41
  };