@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.
- package/package.json +1 -1
- package/src/AuthorizationClient.js +2 -1
- package/src/ContentObjectAudit.js +4 -1
- package/src/ElvClient.js +8 -15
- package/src/HttpClient.js +17 -1
- package/src/NetworkUrls.js +9 -0
- package/src/client/ContentAccess.js +9 -22
- package/utilities/CompositionCreate.js +517 -0
- package/utilities/LibraryDownloadMp4.js +417 -0
- package/utilities/LibraryDownloadMp4Parallel.js +544 -0
- package/utilities/MezDownloadMp4.js +177 -0
- package/utilities/lib/DownloadFile.js +88 -0
- package/utilities/lib/FrameAccurateVideo.js +431 -0
- package/utilities/lib/concerns/Client.js +5 -0
- package/utilities/lib/concerns/Metadata.js +2 -1
|
@@ -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
|
};
|