@capturly/capture-sdk 2.10.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/CHANGELOG.md +1 -0
- package/LICENSE +23 -0
- package/README.md +144 -0
- package/dist/CaptureClient.d.ts +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4556 -0
- package/dist/transport.d.ts +40 -0
- package/dist/types.d.ts +76 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4556 @@
|
|
|
1
|
+
var MP4_MIME = "video/mp4;codecs=avc1.42E01E,mp4a.40.2";
|
|
2
|
+
function pickMuxStrategy() {
|
|
3
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(MP4_MIME)) {
|
|
4
|
+
return "mediarecorder-mp4";
|
|
5
|
+
}
|
|
6
|
+
if (typeof globalThis.VideoEncoder !== "undefined" && typeof globalThis.AudioEncoder !== "undefined" && "MediaStreamTrackProcessor" in globalThis) {
|
|
7
|
+
return "webcodecs-mp4";
|
|
8
|
+
}
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function detectCapabilities() {
|
|
12
|
+
const hasMediaRecorder = typeof MediaRecorder !== "undefined";
|
|
13
|
+
const hasWebCodecs = typeof globalThis.VideoEncoder !== "undefined" && typeof globalThis.AudioEncoder !== "undefined";
|
|
14
|
+
const hasMediaDevices = typeof navigator !== "undefined" && typeof navigator.mediaDevices !== "undefined";
|
|
15
|
+
const hasWebRTC = typeof RTCPeerConnection !== "undefined";
|
|
16
|
+
const hasOpfs = typeof navigator !== "undefined" && typeof navigator.storage !== "undefined" && typeof navigator.storage.getDirectory === "function";
|
|
17
|
+
return {
|
|
18
|
+
mux: pickMuxStrategy(),
|
|
19
|
+
opfs: hasOpfs,
|
|
20
|
+
webcodecs: hasWebCodecs,
|
|
21
|
+
mediaRecorder: hasMediaRecorder,
|
|
22
|
+
mediaDevices: hasMediaDevices,
|
|
23
|
+
webRTC: hasWebRTC
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var RecordingEngineError = class _RecordingEngineError extends Error {
|
|
28
|
+
code;
|
|
29
|
+
details;
|
|
30
|
+
constructor(code, message, details, options) {
|
|
31
|
+
super(message, options);
|
|
32
|
+
this.name = "RecordingEngineError";
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.details = details;
|
|
35
|
+
}
|
|
36
|
+
static capability(missing, message) {
|
|
37
|
+
return new _RecordingEngineError("capability", message ?? `Required capability missing: ${missing}`, { missing });
|
|
38
|
+
}
|
|
39
|
+
static state(attempted, current) {
|
|
40
|
+
return new _RecordingEngineError("state", `Cannot ${attempted}: current state is ${current}`, {
|
|
41
|
+
current,
|
|
42
|
+
attempted
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
static invalidArg(argName, message) {
|
|
46
|
+
return new _RecordingEngineError("invalid-arg", message, { argName });
|
|
47
|
+
}
|
|
48
|
+
static transferHttp(status, action, detail = "") {
|
|
49
|
+
return new _RecordingEngineError("transfer-http", `Failed to ${action}: HTTP ${status}${detail ? ` \u2014 ${detail}` : ""}`, { status, action, detail });
|
|
50
|
+
}
|
|
51
|
+
static transferNetwork(action, opts) {
|
|
52
|
+
return new _RecordingEngineError("transfer-network", `Failed to ${action}: ${opts?.kind ?? "network"} error`, { action, ...opts?.kind ? { kind: opts.kind } : {} }, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
53
|
+
}
|
|
54
|
+
static emptyBlob() {
|
|
55
|
+
return new _RecordingEngineError("transfer-empty-blob", "Cannot upload an empty blob", {});
|
|
56
|
+
}
|
|
57
|
+
static muxWrite(position, expected) {
|
|
58
|
+
return new _RecordingEngineError("mux-write", `Non-sequential muxer write at position ${position} (expected ${expected})`, { position, expected });
|
|
59
|
+
}
|
|
60
|
+
static internal(message, cause) {
|
|
61
|
+
return new _RecordingEngineError("internal", message, {}, cause ? { cause } : void 0);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
66
|
+
function isSecureTransportUrl(value) {
|
|
67
|
+
if (typeof value !== "string")
|
|
68
|
+
return false;
|
|
69
|
+
let url2;
|
|
70
|
+
try {
|
|
71
|
+
url2 = new URL(value);
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (url2.protocol === "https:")
|
|
76
|
+
return true;
|
|
77
|
+
if (url2.protocol === "http:")
|
|
78
|
+
return LOOPBACK_HOSTS.has(url2.hostname);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
var CHUNK_ALIGNMENT = 256 * 1024;
|
|
83
|
+
var RESUMABLE_CHUNK_BYTES = 32 * CHUNK_ALIGNMENT;
|
|
84
|
+
var CHUNK_TIMEOUT_MS = 4 * 60 * 1e3;
|
|
85
|
+
function parseRangeHeader(value) {
|
|
86
|
+
if (!value)
|
|
87
|
+
return null;
|
|
88
|
+
const match = /^bytes=0-(\d+)$/.exec(value.trim());
|
|
89
|
+
if (!match?.[1])
|
|
90
|
+
return null;
|
|
91
|
+
const lastByte = Number(match[1]);
|
|
92
|
+
if (!Number.isFinite(lastByte) || lastByte < 0)
|
|
93
|
+
return null;
|
|
94
|
+
return lastByte + 1;
|
|
95
|
+
}
|
|
96
|
+
function isRetriableStatus(status) {
|
|
97
|
+
return status >= 500 || status === 408 || status === 429;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
var FIRST_FLUSH_BYTES = 1 * 1024 * 1024;
|
|
101
|
+
var FLUSH_BYTES = 8 * 1024 * 1024;
|
|
102
|
+
var FLUSH_INTERVAL_MS = 1e4;
|
|
103
|
+
var HARD_BACK_PRESSURE_BYTES = 64 * 1024 * 1024;
|
|
104
|
+
var MAX_PUT_BYTES = 8 * 1024 * 1024;
|
|
105
|
+
var PUT_TIMEOUT_MS = 3e4;
|
|
106
|
+
var PUT_MIN_BYTES_PER_SEC = 128 * 1024;
|
|
107
|
+
var CLOSE_TIMEOUT_MS = 6e4;
|
|
108
|
+
var MAX_PUT_ATTEMPTS = 4;
|
|
109
|
+
var RETRY_BASE_MS = 1e3;
|
|
110
|
+
var SPILL_RETRY_MS = FLUSH_INTERVAL_MS;
|
|
111
|
+
var SPILL_PACE_MS = 1e3;
|
|
112
|
+
var SPILL_PACE_PUT_BYTES = 2 * 1024 * 1024;
|
|
113
|
+
var PermanentUploadError = class extends Error {
|
|
114
|
+
};
|
|
115
|
+
function defaultScheduler() {
|
|
116
|
+
return {
|
|
117
|
+
schedule(cb, ms) {
|
|
118
|
+
const id = setTimeout(cb, ms);
|
|
119
|
+
return { cancel: () => clearTimeout(id) };
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
var CloudFileSinkHandle = class {
|
|
124
|
+
path;
|
|
125
|
+
state = "idle";
|
|
126
|
+
queue = [];
|
|
127
|
+
queueBytes = 0;
|
|
128
|
+
// Bytes confirmed uploaded to GCS (advances on each successful PUT).
|
|
129
|
+
globalOffset = 0;
|
|
130
|
+
// Bytes seen via write(). Used as the final TOTAL in Content-Range on close.
|
|
131
|
+
totalWrittenBytes = 0;
|
|
132
|
+
sessionUrl = null;
|
|
133
|
+
// Worker promise so concurrent kicks coalesce — only one drain loop at a time.
|
|
134
|
+
worker = Promise.resolve();
|
|
135
|
+
flushTimer = null;
|
|
136
|
+
hasFlushedFirst = false;
|
|
137
|
+
closing = false;
|
|
138
|
+
fetchImpl;
|
|
139
|
+
scheduler;
|
|
140
|
+
// In-flight request controller; abort()/markFailed() trigger it so a dead
|
|
141
|
+
// network can't pin teardown behind retry budgets.
|
|
142
|
+
inflightAbort = null;
|
|
143
|
+
callbacks;
|
|
144
|
+
recordingId;
|
|
145
|
+
fileId;
|
|
146
|
+
initUrl;
|
|
147
|
+
contentType;
|
|
148
|
+
// True once the memory queue was abandoned for the durable local file.
|
|
149
|
+
// One-way: the file always has the full stream, so there's no reason to
|
|
150
|
+
// switch back to queueing once we're here.
|
|
151
|
+
spilling = false;
|
|
152
|
+
readSource;
|
|
153
|
+
spillRetryTimer = null;
|
|
154
|
+
constructor(config) {
|
|
155
|
+
this.path = `cloud:${config.recordingId}/${config.fileId}`;
|
|
156
|
+
this.initUrl = config.initUrl;
|
|
157
|
+
this.contentType = config.contentType;
|
|
158
|
+
this.recordingId = config.recordingId;
|
|
159
|
+
this.fileId = config.fileId;
|
|
160
|
+
this.callbacks = config.callbacks ?? {};
|
|
161
|
+
this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
162
|
+
this.readSource = config.readSource ?? null;
|
|
163
|
+
const scheduleFn = config.scheduleFn;
|
|
164
|
+
this.scheduler = scheduleFn ? { schedule: (cb, ms) => scheduleFn(cb, ms) } : defaultScheduler();
|
|
165
|
+
}
|
|
166
|
+
getState() {
|
|
167
|
+
return this.state;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Externally fail the backup (e.g. the macOS cloud-tail poller died) so
|
|
171
|
+
* close() can't seal a truncated object as 'completed'. Idempotent.
|
|
172
|
+
*/
|
|
173
|
+
fail(reason) {
|
|
174
|
+
this.markFailed(reason);
|
|
175
|
+
}
|
|
176
|
+
async write(chunk) {
|
|
177
|
+
if (this.isTerminal()) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (chunk.byteLength === 0)
|
|
181
|
+
return;
|
|
182
|
+
this.totalWrittenBytes += chunk.byteLength;
|
|
183
|
+
if (this.spilling) {
|
|
184
|
+
if (!this.spillRetryTimer)
|
|
185
|
+
this.armFlushTimer();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.queue.push(chunk);
|
|
189
|
+
this.queueBytes += chunk.byteLength;
|
|
190
|
+
if (this.queueBytes > HARD_BACK_PRESSURE_BYTES) {
|
|
191
|
+
if (!this.enterSpillMode("back-pressure-exceeded")) {
|
|
192
|
+
this.markFailed("back-pressure-exceeded");
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const threshold = this.hasFlushedFirst ? FLUSH_BYTES : FIRST_FLUSH_BYTES;
|
|
197
|
+
if (this.queueBytes >= threshold) {
|
|
198
|
+
this.cancelFlushTimer();
|
|
199
|
+
this.kickWorker();
|
|
200
|
+
} else {
|
|
201
|
+
this.armFlushTimer();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async close() {
|
|
205
|
+
if (this.isTerminal())
|
|
206
|
+
return;
|
|
207
|
+
this.closing = true;
|
|
208
|
+
this.transitionTo("flushing");
|
|
209
|
+
this.cancelFlushTimer();
|
|
210
|
+
this.cancelSpillRetry();
|
|
211
|
+
this.kickWorker();
|
|
212
|
+
const timerHolder = { current: null };
|
|
213
|
+
const timeout = new Promise((resolve) => {
|
|
214
|
+
timerHolder.current = this.scheduler.schedule(() => {
|
|
215
|
+
this.markFailed("close-timeout");
|
|
216
|
+
resolve();
|
|
217
|
+
}, CLOSE_TIMEOUT_MS);
|
|
218
|
+
});
|
|
219
|
+
await Promise.race([this.worker.catch(() => void 0), timeout]);
|
|
220
|
+
timerHolder.current?.cancel();
|
|
221
|
+
}
|
|
222
|
+
async abort() {
|
|
223
|
+
if (this.state === "aborted" || this.state === "completed")
|
|
224
|
+
return;
|
|
225
|
+
this.transitionTo("aborted");
|
|
226
|
+
this.cancelFlushTimer();
|
|
227
|
+
this.cancelSpillRetry();
|
|
228
|
+
this.queue = [];
|
|
229
|
+
this.queueBytes = 0;
|
|
230
|
+
this.inflightAbort?.abort();
|
|
231
|
+
await this.worker.catch(() => void 0);
|
|
232
|
+
if (this.sessionUrl) {
|
|
233
|
+
const sessionUrl = this.sessionUrl;
|
|
234
|
+
this.sessionUrl = null;
|
|
235
|
+
await this.terminateSession(sessionUrl).catch((err) => {
|
|
236
|
+
this.notifyCleanupError("terminate-session", err);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async discard() {
|
|
241
|
+
await this.abort();
|
|
242
|
+
}
|
|
243
|
+
isTerminal() {
|
|
244
|
+
return this.state === "completed" || this.state === "failed" || this.state === "aborted";
|
|
245
|
+
}
|
|
246
|
+
transitionTo(next, info) {
|
|
247
|
+
if (this.state === next)
|
|
248
|
+
return;
|
|
249
|
+
if (this.isTerminal())
|
|
250
|
+
return;
|
|
251
|
+
this.state = next;
|
|
252
|
+
try {
|
|
253
|
+
this.callbacks.onState?.(next, info);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
markFailed(reason) {
|
|
258
|
+
if (this.isTerminal())
|
|
259
|
+
return;
|
|
260
|
+
this.state = "failed";
|
|
261
|
+
this.queue = [];
|
|
262
|
+
this.queueBytes = 0;
|
|
263
|
+
this.cancelFlushTimer();
|
|
264
|
+
this.cancelSpillRetry();
|
|
265
|
+
this.inflightAbort?.abort();
|
|
266
|
+
try {
|
|
267
|
+
this.callbacks.onState?.("failed", { reason });
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
notifyCleanupError(context, err) {
|
|
272
|
+
try {
|
|
273
|
+
this.callbacks.onCleanupError?.(context, err);
|
|
274
|
+
} catch {
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Abandon the memory queue and drain from the durable local file instead.
|
|
279
|
+
* Returns false when no readSource is wired (caller falls back to the
|
|
280
|
+
* terminal path). Everything up to `globalOffset` is committed in GCS;
|
|
281
|
+
* everything after is in the local file, so dropping the queue loses
|
|
282
|
+
* nothing.
|
|
283
|
+
*/
|
|
284
|
+
enterSpillMode(reason) {
|
|
285
|
+
if (!this.readSource || this.isTerminal())
|
|
286
|
+
return false;
|
|
287
|
+
const first = !this.spilling;
|
|
288
|
+
this.spilling = true;
|
|
289
|
+
this.queue = [];
|
|
290
|
+
this.queueBytes = 0;
|
|
291
|
+
if (first) {
|
|
292
|
+
try {
|
|
293
|
+
this.callbacks.onLagging?.(reason);
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
/** Schedule a spill-drain retry after a transient upload failure. */
|
|
300
|
+
armSpillRetry() {
|
|
301
|
+
if (this.spillRetryTimer || this.isTerminal())
|
|
302
|
+
return;
|
|
303
|
+
this.spillRetryTimer = this.scheduler.schedule(() => {
|
|
304
|
+
this.spillRetryTimer = null;
|
|
305
|
+
this.kickWorker();
|
|
306
|
+
}, SPILL_RETRY_MS);
|
|
307
|
+
}
|
|
308
|
+
/** Schedule the next paced spill PUT (mid-call yield, not a failure). */
|
|
309
|
+
armSpillPace() {
|
|
310
|
+
if (this.spillRetryTimer || this.isTerminal())
|
|
311
|
+
return;
|
|
312
|
+
this.spillRetryTimer = this.scheduler.schedule(() => {
|
|
313
|
+
this.spillRetryTimer = null;
|
|
314
|
+
this.kickWorker();
|
|
315
|
+
}, SPILL_PACE_MS);
|
|
316
|
+
}
|
|
317
|
+
cancelSpillRetry() {
|
|
318
|
+
if (this.spillRetryTimer) {
|
|
319
|
+
this.spillRetryTimer.cancel();
|
|
320
|
+
this.spillRetryTimer = null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
armFlushTimer() {
|
|
324
|
+
if (this.flushTimer)
|
|
325
|
+
return;
|
|
326
|
+
this.flushTimer = this.scheduler.schedule(() => {
|
|
327
|
+
this.flushTimer = null;
|
|
328
|
+
this.kickWorker();
|
|
329
|
+
}, FLUSH_INTERVAL_MS);
|
|
330
|
+
}
|
|
331
|
+
cancelFlushTimer() {
|
|
332
|
+
if (this.flushTimer) {
|
|
333
|
+
this.flushTimer.cancel();
|
|
334
|
+
this.flushTimer = null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
kickWorker() {
|
|
338
|
+
this.worker = this.worker.then(() => this.drainOnce()).catch(() => void 0);
|
|
339
|
+
}
|
|
340
|
+
async drainOnce() {
|
|
341
|
+
if (this.isTerminal())
|
|
342
|
+
return;
|
|
343
|
+
const spillHasBytes = this.spilling && this.totalWrittenBytes > this.globalOffset;
|
|
344
|
+
if (this.queue.length === 0 && !this.sessionUrl && !spillHasBytes) {
|
|
345
|
+
if (this.closing)
|
|
346
|
+
this.markFailed("no-bytes");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!this.sessionUrl) {
|
|
350
|
+
try {
|
|
351
|
+
this.transitionTo("initializing");
|
|
352
|
+
this.sessionUrl = await this.initSession();
|
|
353
|
+
try {
|
|
354
|
+
this.callbacks.onSessionAcquired?.(this.sessionUrl);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
this.transitionTo("uploading");
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (!(err instanceof PermanentUploadError) && this.enterSpillMode("init-retry")) {
|
|
360
|
+
this.armSpillRetry();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.markFailed(`init-failed (recording=${this.recordingId} file=${this.fileId}): ${describeErr(err)}`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (this.spilling) {
|
|
368
|
+
await this.drainFromSource();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
while (this.queue.length > 0 && !this.isTerminal()) {
|
|
372
|
+
const isFinal = this.closing && this.queueBytes <= MAX_PUT_BYTES;
|
|
373
|
+
let takeBytes;
|
|
374
|
+
if (isFinal) {
|
|
375
|
+
takeBytes = this.queueBytes;
|
|
376
|
+
} else {
|
|
377
|
+
takeBytes = Math.min(Math.floor(this.queueBytes / CHUNK_ALIGNMENT) * CHUNK_ALIGNMENT, MAX_PUT_BYTES);
|
|
378
|
+
if (takeBytes === 0) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const toSend = takeFromQueue(this.queue, takeBytes);
|
|
383
|
+
this.queueBytes -= takeBytes;
|
|
384
|
+
try {
|
|
385
|
+
await this.putChunk(toSend, isFinal);
|
|
386
|
+
this.globalOffset += toSend.byteLength;
|
|
387
|
+
this.hasFlushedFirst = true;
|
|
388
|
+
try {
|
|
389
|
+
this.callbacks.onProgress?.(this.globalOffset);
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
if (isFinal) {
|
|
393
|
+
this.transitionTo("completed");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (!(err instanceof PermanentUploadError) && this.enterSpillMode("put-retry-exhausted")) {
|
|
398
|
+
this.armSpillRetry();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.markFailed(`put-failed (recording=${this.recordingId} file=${this.fileId} offset=${this.globalOffset}): ${describeErr(err)}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (this.closing && !this.isTerminal() && this.sessionUrl && this.globalOffset > 0) {
|
|
406
|
+
try {
|
|
407
|
+
await this.finalizeEmpty();
|
|
408
|
+
this.transitionTo("completed");
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (!(err instanceof PermanentUploadError) && this.enterSpillMode("finalize-retry")) {
|
|
411
|
+
this.armSpillRetry();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.markFailed(`finalize-failed (recording=${this.recordingId} file=${this.fileId}): ${describeErr(err)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Spill-mode drain: upload the gap between what GCS has committed and what
|
|
420
|
+
* the caller has written, reading it back from the durable local file.
|
|
421
|
+
* Transient failures arm a retry instead of failing the backup; only a
|
|
422
|
+
* permanent 4xx (or a missing/short local read that never heals before
|
|
423
|
+
* close-timeout) ends the cloud copy.
|
|
424
|
+
*/
|
|
425
|
+
async drainFromSource() {
|
|
426
|
+
const read = this.readSource;
|
|
427
|
+
if (!read)
|
|
428
|
+
return;
|
|
429
|
+
while (!this.isTerminal() && this.globalOffset < this.totalWrittenBytes) {
|
|
430
|
+
const remaining = this.totalWrittenBytes - this.globalOffset;
|
|
431
|
+
const isFinal = this.closing && remaining <= MAX_PUT_BYTES;
|
|
432
|
+
const maxPut = this.closing ? MAX_PUT_BYTES : SPILL_PACE_PUT_BYTES;
|
|
433
|
+
const takeBytes = isFinal ? remaining : Math.min(Math.floor(remaining / CHUNK_ALIGNMENT) * CHUNK_ALIGNMENT, maxPut);
|
|
434
|
+
if (takeBytes === 0) {
|
|
435
|
+
this.armFlushTimer();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let chunk;
|
|
439
|
+
try {
|
|
440
|
+
chunk = await read(this.globalOffset, takeBytes);
|
|
441
|
+
} catch {
|
|
442
|
+
this.armSpillRetry();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (chunk.byteLength < takeBytes) {
|
|
446
|
+
this.armSpillRetry();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
await this.putChunk(chunk, isFinal);
|
|
451
|
+
this.globalOffset += chunk.byteLength;
|
|
452
|
+
this.hasFlushedFirst = true;
|
|
453
|
+
try {
|
|
454
|
+
this.callbacks.onProgress?.(this.globalOffset);
|
|
455
|
+
} catch {
|
|
456
|
+
}
|
|
457
|
+
if (isFinal) {
|
|
458
|
+
this.transitionTo("completed");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!this.closing) {
|
|
462
|
+
this.armSpillPace();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
if (err instanceof PermanentUploadError) {
|
|
467
|
+
this.markFailed(`put-failed (recording=${this.recordingId} file=${this.fileId} offset=${this.globalOffset}): ${describeErr(err)}`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.armSpillRetry();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (this.closing && !this.isTerminal() && this.sessionUrl && this.globalOffset > 0) {
|
|
475
|
+
try {
|
|
476
|
+
await this.finalizeEmpty();
|
|
477
|
+
this.transitionTo("completed");
|
|
478
|
+
} catch (err) {
|
|
479
|
+
if (err instanceof PermanentUploadError) {
|
|
480
|
+
this.markFailed(`finalize-failed (recording=${this.recordingId} file=${this.fileId}): ${describeErr(err)}`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.armSpillRetry();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async finalizeEmpty() {
|
|
488
|
+
if (!this.sessionUrl)
|
|
489
|
+
throw new Error("no active GCS session");
|
|
490
|
+
const contentRange = `bytes */${this.totalWrittenBytes}`;
|
|
491
|
+
const res = await this.requestWithRetry(() => this.fetchWithTimeout(this.sessionUrl, {
|
|
492
|
+
method: "PUT",
|
|
493
|
+
headers: {
|
|
494
|
+
"content-range": contentRange,
|
|
495
|
+
"content-length": "0"
|
|
496
|
+
}
|
|
497
|
+
}));
|
|
498
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
499
|
+
throw new PermanentUploadError(`finalize PUT returned HTTP ${res.status}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// -- HTTP layer ---------------------------------------------------------
|
|
503
|
+
async initSession() {
|
|
504
|
+
const res = await this.requestWithRetry(() => this.fetchWithTimeout(this.initUrl, {
|
|
505
|
+
method: "POST",
|
|
506
|
+
headers: {
|
|
507
|
+
// The signed URL was signed with this header, so it must appear
|
|
508
|
+
// verbatim in the request — GCS will reject the signature otherwise.
|
|
509
|
+
"x-goog-resumable": "start",
|
|
510
|
+
"content-type": this.contentType
|
|
511
|
+
}
|
|
512
|
+
}));
|
|
513
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
514
|
+
throw new PermanentUploadError(`GCS init returned HTTP ${res.status}`);
|
|
515
|
+
}
|
|
516
|
+
const location = res.headers.get("location");
|
|
517
|
+
if (!location) {
|
|
518
|
+
throw new PermanentUploadError("GCS init missing Location header");
|
|
519
|
+
}
|
|
520
|
+
return location;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Upload one chunk, retrying with GCS offset reconciliation. A timed-out
|
|
524
|
+
* or failed PUT often persisted some of its bytes anyway (GCS commits in
|
|
525
|
+
* 256 KB increments), so before each retry we ask the server what landed
|
|
526
|
+
* and resend only the tail — blindly resending from the original offset
|
|
527
|
+
* would desync from the server and turn every transient timeout into a
|
|
528
|
+
* permanent failure. Resolves only once ALL bytes are committed, so the
|
|
529
|
+
* caller's `globalOffset += bytes.byteLength` accounting stays exact.
|
|
530
|
+
*/
|
|
531
|
+
async putChunk(bytes2, isFinal) {
|
|
532
|
+
if (!this.sessionUrl)
|
|
533
|
+
throw new Error("no active GCS session");
|
|
534
|
+
let start = this.globalOffset;
|
|
535
|
+
let remaining = bytes2;
|
|
536
|
+
let failures = 0;
|
|
537
|
+
let lastErr = null;
|
|
538
|
+
while (failures < MAX_PUT_ATTEMPTS) {
|
|
539
|
+
if (this.isTerminal()) {
|
|
540
|
+
throw lastErr ?? new Error("sink reached a terminal state mid-upload");
|
|
541
|
+
}
|
|
542
|
+
let res = null;
|
|
543
|
+
try {
|
|
544
|
+
res = await this.sendPut(start, remaining, isFinal);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
lastErr = err;
|
|
547
|
+
}
|
|
548
|
+
if (res) {
|
|
549
|
+
if (isFinal && (res.status === 200 || res.status === 201))
|
|
550
|
+
return;
|
|
551
|
+
if (res.status === 308) {
|
|
552
|
+
const committed = parseRangeHeader(res.headers.get("range")) ?? 0;
|
|
553
|
+
const expected = start + remaining.byteLength;
|
|
554
|
+
if (!isFinal && committed >= expected)
|
|
555
|
+
return;
|
|
556
|
+
if (committed > start) {
|
|
557
|
+
remaining = remaining.subarray(committed - start);
|
|
558
|
+
start = committed;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
lastErr = new Error(`308 with no persisted progress (range=${committed})`);
|
|
562
|
+
} else if (isRetriableStatus(res.status)) {
|
|
563
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
564
|
+
await res.body?.cancel().catch(() => void 0);
|
|
565
|
+
} else {
|
|
566
|
+
throw new PermanentUploadError(`${isFinal ? "final" : "non-final"} PUT returned HTTP ${res.status}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
failures++;
|
|
570
|
+
let probe = null;
|
|
571
|
+
try {
|
|
572
|
+
probe = await this.probeCommitted();
|
|
573
|
+
} catch {
|
|
574
|
+
}
|
|
575
|
+
if (probe) {
|
|
576
|
+
const expected = start + remaining.byteLength;
|
|
577
|
+
if (probe.finalized) {
|
|
578
|
+
if (isFinal)
|
|
579
|
+
return;
|
|
580
|
+
throw new PermanentUploadError("GCS session finalized unexpectedly during a non-final PUT");
|
|
581
|
+
}
|
|
582
|
+
if (probe.committedBytes >= expected) {
|
|
583
|
+
if (!isFinal)
|
|
584
|
+
return;
|
|
585
|
+
remaining = remaining.subarray(remaining.byteLength);
|
|
586
|
+
start = expected;
|
|
587
|
+
failures = 0;
|
|
588
|
+
} else if (probe.committedBytes > start) {
|
|
589
|
+
remaining = remaining.subarray(probe.committedBytes - start);
|
|
590
|
+
start = probe.committedBytes;
|
|
591
|
+
failures = 0;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (failures > 0 && failures < MAX_PUT_ATTEMPTS) {
|
|
595
|
+
if (this.isTerminal())
|
|
596
|
+
throw lastErr ?? new Error("sink terminated mid-upload");
|
|
597
|
+
await this.delay(RETRY_BASE_MS * Math.pow(2, failures - 1));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
throw lastErr ?? new Error("PUT failed after retries");
|
|
601
|
+
}
|
|
602
|
+
async sendPut(start, body, isFinal) {
|
|
603
|
+
if (!this.sessionUrl)
|
|
604
|
+
throw new Error("no active GCS session");
|
|
605
|
+
if (isFinal && body.byteLength === 0) {
|
|
606
|
+
return this.fetchWithTimeout(this.sessionUrl, {
|
|
607
|
+
method: "PUT",
|
|
608
|
+
headers: {
|
|
609
|
+
"content-range": `bytes */${this.totalWrittenBytes}`,
|
|
610
|
+
"content-length": "0"
|
|
611
|
+
}
|
|
612
|
+
}, PUT_TIMEOUT_MS);
|
|
613
|
+
}
|
|
614
|
+
const end = start + body.byteLength - 1;
|
|
615
|
+
const total = isFinal ? String(this.totalWrittenBytes) : "*";
|
|
616
|
+
return this.fetchWithTimeout(this.sessionUrl, {
|
|
617
|
+
method: "PUT",
|
|
618
|
+
headers: {
|
|
619
|
+
"content-range": `bytes ${start}-${end}/${total}`,
|
|
620
|
+
"content-length": String(body.byteLength)
|
|
621
|
+
},
|
|
622
|
+
body
|
|
623
|
+
}, putTimeoutMs(body.byteLength));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* GCS resumable status query: zero-body PUT with `Content-Range: bytes * / *`.
|
|
627
|
+
* Using '*' for the total guarantees the probe itself can never finalize
|
|
628
|
+
* the session. 308 + Range header reports the persisted byte count;
|
|
629
|
+
* 200/201 means a previous final PUT actually landed.
|
|
630
|
+
*/
|
|
631
|
+
async probeCommitted() {
|
|
632
|
+
if (!this.sessionUrl)
|
|
633
|
+
throw new Error("no active GCS session");
|
|
634
|
+
const res = await this.fetchWithTimeout(this.sessionUrl, {
|
|
635
|
+
method: "PUT",
|
|
636
|
+
headers: { "content-range": "bytes */*", "content-length": "0" }
|
|
637
|
+
}, PUT_TIMEOUT_MS);
|
|
638
|
+
if (res.status === 200 || res.status === 201) {
|
|
639
|
+
return { finalized: true, committedBytes: this.totalWrittenBytes };
|
|
640
|
+
}
|
|
641
|
+
if (res.status === 308) {
|
|
642
|
+
const committed = parseRangeHeader(res.headers.get("range"));
|
|
643
|
+
return { finalized: false, committedBytes: committed ?? 0 };
|
|
644
|
+
}
|
|
645
|
+
await res.body?.cancel().catch(() => void 0);
|
|
646
|
+
throw new Error(`status probe returned HTTP ${res.status}`);
|
|
647
|
+
}
|
|
648
|
+
async terminateSession(sessionUrl) {
|
|
649
|
+
let lastErr = null;
|
|
650
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
651
|
+
if (attempt > 0)
|
|
652
|
+
await this.delay(RETRY_BASE_MS);
|
|
653
|
+
try {
|
|
654
|
+
const res = await this.fetchWithTimeout(sessionUrl, { method: "DELETE" });
|
|
655
|
+
const cancelled = res.status < 300 || res.status === 499 || res.status === 404 || res.status === 410;
|
|
656
|
+
await res.body?.cancel().catch(() => void 0);
|
|
657
|
+
if (cancelled)
|
|
658
|
+
return;
|
|
659
|
+
lastErr = new Error(`DELETE returned HTTP ${res.status}`);
|
|
660
|
+
if (!isRetriableStatus(res.status))
|
|
661
|
+
break;
|
|
662
|
+
} catch (err) {
|
|
663
|
+
lastErr = err;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
throw lastErr ?? new Error("terminate-session failed");
|
|
667
|
+
}
|
|
668
|
+
async requestWithRetry(call) {
|
|
669
|
+
let lastErr = null;
|
|
670
|
+
for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt++) {
|
|
671
|
+
if (attempt > 0 && this.isTerminal()) {
|
|
672
|
+
throw lastErr ?? new Error("sink reached a terminal state mid-request");
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
const res = await call();
|
|
676
|
+
if (isRetriableStatus(res.status)) {
|
|
677
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
678
|
+
await res.body?.cancel().catch(() => void 0);
|
|
679
|
+
} else {
|
|
680
|
+
return res;
|
|
681
|
+
}
|
|
682
|
+
} catch (err) {
|
|
683
|
+
lastErr = err;
|
|
684
|
+
}
|
|
685
|
+
if (attempt < MAX_PUT_ATTEMPTS - 1) {
|
|
686
|
+
if (this.isTerminal())
|
|
687
|
+
throw lastErr ?? new Error("sink terminated mid-request");
|
|
688
|
+
await this.delay(RETRY_BASE_MS * Math.pow(2, attempt));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
throw lastErr ?? new Error("request failed after retries");
|
|
692
|
+
}
|
|
693
|
+
// Backoff via the injected scheduler so fake timers drive it deterministically.
|
|
694
|
+
delay(ms) {
|
|
695
|
+
return new Promise((resolve) => {
|
|
696
|
+
this.scheduler.schedule(resolve, ms);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
async fetchWithTimeout(input, init, timeoutMs = PUT_TIMEOUT_MS) {
|
|
700
|
+
const controller = new AbortController();
|
|
701
|
+
this.inflightAbort = controller;
|
|
702
|
+
const timer = this.scheduler.schedule(() => controller.abort(), timeoutMs);
|
|
703
|
+
try {
|
|
704
|
+
return await this.fetchImpl(input, { ...init, signal: controller.signal });
|
|
705
|
+
} finally {
|
|
706
|
+
timer.cancel();
|
|
707
|
+
if (this.inflightAbort === controller)
|
|
708
|
+
this.inflightAbort = null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
function putTimeoutMs(byteLength) {
|
|
713
|
+
return PUT_TIMEOUT_MS + Math.ceil(byteLength / PUT_MIN_BYTES_PER_SEC) * 1e3;
|
|
714
|
+
}
|
|
715
|
+
function takeFromQueue(queue, count) {
|
|
716
|
+
const out = new Uint8Array(count);
|
|
717
|
+
let written = 0;
|
|
718
|
+
while (written < count && queue.length > 0) {
|
|
719
|
+
const head = queue[0];
|
|
720
|
+
const need = count - written;
|
|
721
|
+
if (head.byteLength <= need) {
|
|
722
|
+
out.set(head, written);
|
|
723
|
+
written += head.byteLength;
|
|
724
|
+
queue.shift();
|
|
725
|
+
} else {
|
|
726
|
+
out.set(head.subarray(0, need), written);
|
|
727
|
+
queue[0] = head.subarray(need);
|
|
728
|
+
written += need;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
function describeErr(err) {
|
|
734
|
+
return err instanceof Error ? err.message : String(err);
|
|
735
|
+
}
|
|
736
|
+
function createCloudFileSinkHandle(config) {
|
|
737
|
+
if (!config.initUrl) {
|
|
738
|
+
throw RecordingEngineError.invalidArg("initUrl", "CloudFileSinkHandle requires initUrl");
|
|
739
|
+
}
|
|
740
|
+
if (!isSecureTransportUrl(config.initUrl)) {
|
|
741
|
+
throw RecordingEngineError.invalidArg("initUrl", "CloudFileSinkHandle initUrl must be https:// (http:// allowed only for loopback)");
|
|
742
|
+
}
|
|
743
|
+
if (!config.contentType) {
|
|
744
|
+
throw RecordingEngineError.invalidArg("contentType", "CloudFileSinkHandle requires contentType");
|
|
745
|
+
}
|
|
746
|
+
return new CloudFileSinkHandle(config);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
var LOGO_SIZE = 56;
|
|
750
|
+
var PADDING = 20;
|
|
751
|
+
var OPACITY = 0.8;
|
|
752
|
+
var FALLBACK_TEXT = "Powered by Capturly";
|
|
753
|
+
var DRAW_WATCHDOG_MS = 1e3;
|
|
754
|
+
function watermarkSupported() {
|
|
755
|
+
return typeof document !== "undefined" && typeof HTMLCanvasElement !== "undefined" && typeof HTMLCanvasElement.prototype.captureStream === "function" && typeof HTMLVideoElement !== "undefined" && "requestVideoFrameCallback" in HTMLVideoElement.prototype;
|
|
756
|
+
}
|
|
757
|
+
var WatermarkCompositor = class {
|
|
758
|
+
source;
|
|
759
|
+
config;
|
|
760
|
+
video;
|
|
761
|
+
canvas;
|
|
762
|
+
ctx;
|
|
763
|
+
output = null;
|
|
764
|
+
stopped = false;
|
|
765
|
+
watchdog = null;
|
|
766
|
+
lastDrawMs = 0;
|
|
767
|
+
drawFailureLogged = false;
|
|
768
|
+
constructor(source, config) {
|
|
769
|
+
this.source = source;
|
|
770
|
+
this.config = config;
|
|
771
|
+
this.video = document.createElement("video");
|
|
772
|
+
this.canvas = document.createElement("canvas");
|
|
773
|
+
const ctx = this.canvas.getContext("2d");
|
|
774
|
+
if (!ctx)
|
|
775
|
+
throw new Error("watermark: 2d canvas context unavailable");
|
|
776
|
+
this.ctx = ctx;
|
|
777
|
+
}
|
|
778
|
+
/** Returns the composited stream to hand to the mux strategy. */
|
|
779
|
+
async start() {
|
|
780
|
+
const track = this.source.getVideoTracks()[0];
|
|
781
|
+
if (!track)
|
|
782
|
+
throw new Error("watermark: source stream has no video track");
|
|
783
|
+
const settings = track.getSettings();
|
|
784
|
+
this.canvas.width = settings.width ?? 1280;
|
|
785
|
+
this.canvas.height = settings.height ?? 720;
|
|
786
|
+
this.video.muted = true;
|
|
787
|
+
this.video.playsInline = true;
|
|
788
|
+
this.video.srcObject = new MediaStream([track]);
|
|
789
|
+
await this.video.play();
|
|
790
|
+
const drawFrame = () => {
|
|
791
|
+
if (this.stopped)
|
|
792
|
+
return;
|
|
793
|
+
this.draw();
|
|
794
|
+
this.video.requestVideoFrameCallback(drawFrame);
|
|
795
|
+
};
|
|
796
|
+
this.draw();
|
|
797
|
+
this.video.requestVideoFrameCallback(drawFrame);
|
|
798
|
+
this.watchdog = setInterval(() => {
|
|
799
|
+
if (this.stopped)
|
|
800
|
+
return;
|
|
801
|
+
if (Date.now() - this.lastDrawMs >= DRAW_WATCHDOG_MS) {
|
|
802
|
+
try {
|
|
803
|
+
this.draw();
|
|
804
|
+
} catch (err) {
|
|
805
|
+
if (!this.drawFailureLogged) {
|
|
806
|
+
this.drawFailureLogged = true;
|
|
807
|
+
const log = this.config.logError ?? ((component, message, error) => console.error(`${component}: ${message}`, error));
|
|
808
|
+
log("watermark", "draw failed; watchdog keeps ticking", err);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}, DRAW_WATCHDOG_MS);
|
|
813
|
+
const canvasStream = this.canvas.captureStream();
|
|
814
|
+
this.output = new MediaStream([
|
|
815
|
+
...canvasStream.getVideoTracks(),
|
|
816
|
+
...this.source.getAudioTracks()
|
|
817
|
+
]);
|
|
818
|
+
return this.output;
|
|
819
|
+
}
|
|
820
|
+
draw() {
|
|
821
|
+
this.lastDrawMs = Date.now();
|
|
822
|
+
const { canvas, ctx, video } = this;
|
|
823
|
+
if (video.videoWidth > 0 && (video.videoWidth !== canvas.width || video.videoHeight !== canvas.height)) {
|
|
824
|
+
canvas.width = video.videoWidth;
|
|
825
|
+
canvas.height = video.videoHeight;
|
|
826
|
+
}
|
|
827
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
828
|
+
ctx.save();
|
|
829
|
+
ctx.globalAlpha = OPACITY;
|
|
830
|
+
if (this.config.image) {
|
|
831
|
+
ctx.drawImage(this.config.image, canvas.width - LOGO_SIZE - PADDING, canvas.height - LOGO_SIZE - PADDING, LOGO_SIZE, LOGO_SIZE);
|
|
832
|
+
} else {
|
|
833
|
+
ctx.font = "20px system-ui, sans-serif";
|
|
834
|
+
ctx.fillStyle = "white";
|
|
835
|
+
ctx.textAlign = "right";
|
|
836
|
+
ctx.textBaseline = "bottom";
|
|
837
|
+
ctx.fillText(FALLBACK_TEXT, canvas.width - PADDING, canvas.height - PADDING);
|
|
838
|
+
}
|
|
839
|
+
ctx.restore();
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Idempotent; never throws. Stops only the canvas track it created —
|
|
843
|
+
* source-track ownership stays with CaptureSession's streamOwned logic.
|
|
844
|
+
*/
|
|
845
|
+
stop() {
|
|
846
|
+
if (this.stopped)
|
|
847
|
+
return;
|
|
848
|
+
this.stopped = true;
|
|
849
|
+
if (this.watchdog !== null) {
|
|
850
|
+
clearInterval(this.watchdog);
|
|
851
|
+
this.watchdog = null;
|
|
852
|
+
}
|
|
853
|
+
for (const track of this.output?.getVideoTracks() ?? []) {
|
|
854
|
+
try {
|
|
855
|
+
track.stop();
|
|
856
|
+
} catch {
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
this.video.pause();
|
|
861
|
+
} catch {
|
|
862
|
+
}
|
|
863
|
+
this.video.srcObject = null;
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
async function getOpfsRoot() {
|
|
868
|
+
if (typeof navigator === "undefined" || typeof navigator.storage === "undefined" || typeof navigator.storage.getDirectory !== "function") {
|
|
869
|
+
throw RecordingEngineError.capability("OPFS");
|
|
870
|
+
}
|
|
871
|
+
return await navigator.storage.getDirectory();
|
|
872
|
+
}
|
|
873
|
+
async function resolveDir(root, segments) {
|
|
874
|
+
let dir = root;
|
|
875
|
+
for (const segment of segments) {
|
|
876
|
+
if (!segment)
|
|
877
|
+
continue;
|
|
878
|
+
dir = await dir.getDirectoryHandle(segment, { create: true });
|
|
879
|
+
}
|
|
880
|
+
return dir;
|
|
881
|
+
}
|
|
882
|
+
function assertSafeRelativePath(relativePath) {
|
|
883
|
+
const invalid = () => {
|
|
884
|
+
throw RecordingEngineError.invalidArg("relativePath", `Unsafe file path: '${relativePath}'`);
|
|
885
|
+
};
|
|
886
|
+
if (typeof relativePath !== "string" || relativePath.length === 0)
|
|
887
|
+
invalid();
|
|
888
|
+
if (relativePath.includes("\\") || relativePath.includes("\0"))
|
|
889
|
+
invalid();
|
|
890
|
+
if (relativePath.startsWith("/") || /^[a-zA-Z]:/.test(relativePath))
|
|
891
|
+
invalid();
|
|
892
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
893
|
+
if (segments.length === 0)
|
|
894
|
+
invalid();
|
|
895
|
+
for (const segment of segments) {
|
|
896
|
+
if (segment === "." || segment === "..")
|
|
897
|
+
invalid();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function splitPath(relativePath) {
|
|
901
|
+
assertSafeRelativePath(relativePath);
|
|
902
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
903
|
+
const fileName = parts.pop();
|
|
904
|
+
if (!fileName) {
|
|
905
|
+
throw RecordingEngineError.invalidArg("relativePath", `Invalid file path: '${relativePath}'`);
|
|
906
|
+
}
|
|
907
|
+
return { dirSegments: parts, fileName };
|
|
908
|
+
}
|
|
909
|
+
var OPFS_SYNC_WORKER_SOURCE = `
|
|
910
|
+
let chain = Promise.resolve();
|
|
911
|
+
let handle = null;
|
|
912
|
+
self.onmessage = (event) => {
|
|
913
|
+
const msg = event.data;
|
|
914
|
+
chain = chain.then(async () => {
|
|
915
|
+
try {
|
|
916
|
+
if (msg.op === 'open') {
|
|
917
|
+
let dir = await navigator.storage.getDirectory();
|
|
918
|
+
for (const segment of msg.dirSegments) {
|
|
919
|
+
dir = await dir.getDirectoryHandle(segment, { create: true });
|
|
920
|
+
}
|
|
921
|
+
const fileHandle = await dir.getFileHandle(msg.fileName, { create: true });
|
|
922
|
+
handle = await fileHandle.createSyncAccessHandle();
|
|
923
|
+
handle.truncate(0);
|
|
924
|
+
} else if (msg.op === 'write') {
|
|
925
|
+
handle.write(msg.data, { at: msg.position });
|
|
926
|
+
handle.flush();
|
|
927
|
+
} else if (msg.op === 'read') {
|
|
928
|
+
const out = new Uint8Array(msg.length);
|
|
929
|
+
const n = handle.read(out, { at: msg.position });
|
|
930
|
+
self.postMessage({ id: msg.id, ok: true, data: out.subarray(0, n) }, [out.buffer]);
|
|
931
|
+
return;
|
|
932
|
+
} else if (msg.op === 'close') {
|
|
933
|
+
handle.flush();
|
|
934
|
+
handle.close();
|
|
935
|
+
handle = null;
|
|
936
|
+
} else if (msg.op === 'abort') {
|
|
937
|
+
try { if (handle) handle.close(); } catch (e) {}
|
|
938
|
+
handle = null;
|
|
939
|
+
}
|
|
940
|
+
self.postMessage({ id: msg.id, ok: true });
|
|
941
|
+
} catch (err) {
|
|
942
|
+
self.postMessage({ id: msg.id, ok: false, error: String((err && err.message) || err) });
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
`;
|
|
947
|
+
function defaultWorkerFactory() {
|
|
948
|
+
const blob = new Blob([OPFS_SYNC_WORKER_SOURCE], { type: "text/javascript" });
|
|
949
|
+
const url2 = URL.createObjectURL(blob);
|
|
950
|
+
try {
|
|
951
|
+
const worker = new Worker(url2);
|
|
952
|
+
return {
|
|
953
|
+
worker,
|
|
954
|
+
release: () => {
|
|
955
|
+
worker.terminate();
|
|
956
|
+
URL.revokeObjectURL(url2);
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
} catch (err) {
|
|
960
|
+
URL.revokeObjectURL(url2);
|
|
961
|
+
throw err;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
var WORKER_OPEN_TIMEOUT_MS = 1e4;
|
|
965
|
+
function supportsRangeRead(handle) {
|
|
966
|
+
return typeof handle.readAt === "function";
|
|
967
|
+
}
|
|
968
|
+
var OpfsSyncWorkerFileSinkHandle = class _OpfsSyncWorkerFileSinkHandle {
|
|
969
|
+
path;
|
|
970
|
+
worker;
|
|
971
|
+
releaseWorker;
|
|
972
|
+
removeSelf;
|
|
973
|
+
nextId = 1;
|
|
974
|
+
pending = /* @__PURE__ */ new Map();
|
|
975
|
+
bytesWritten = 0;
|
|
976
|
+
closed = false;
|
|
977
|
+
discarded = false;
|
|
978
|
+
released = false;
|
|
979
|
+
constructor(path, worker, releaseWorker, removeSelf) {
|
|
980
|
+
this.path = path;
|
|
981
|
+
this.worker = worker;
|
|
982
|
+
this.releaseWorker = releaseWorker;
|
|
983
|
+
this.removeSelf = removeSelf;
|
|
984
|
+
worker.onmessage = (event) => {
|
|
985
|
+
const data = event.data;
|
|
986
|
+
if (typeof data?.id !== "number")
|
|
987
|
+
return;
|
|
988
|
+
const entry = this.pending.get(data.id);
|
|
989
|
+
if (!entry)
|
|
990
|
+
return;
|
|
991
|
+
this.pending.delete(data.id);
|
|
992
|
+
if (data.ok)
|
|
993
|
+
entry.resolve(data.data);
|
|
994
|
+
else
|
|
995
|
+
entry.reject(RecordingEngineError.internal(`OPFS worker op failed for ${this.path}: ${data.error ?? "unknown"}`));
|
|
996
|
+
};
|
|
997
|
+
worker.onerror = () => {
|
|
998
|
+
this.failAllPending(RecordingEngineError.internal(`OPFS sync worker crashed (${this.path})`));
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
static async open(relativePath, dirSegments, fileName, factory, removeSelf) {
|
|
1002
|
+
const { worker, release } = factory();
|
|
1003
|
+
const handle = new _OpfsSyncWorkerFileSinkHandle(relativePath, worker, release, removeSelf);
|
|
1004
|
+
const timeout = setTimeout(() => {
|
|
1005
|
+
handle.failAllPending(RecordingEngineError.internal(`OPFS sync worker open timed out (${relativePath})`));
|
|
1006
|
+
}, WORKER_OPEN_TIMEOUT_MS);
|
|
1007
|
+
try {
|
|
1008
|
+
await handle.rpc({ op: "open", dirSegments, fileName });
|
|
1009
|
+
return handle;
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
handle.release();
|
|
1012
|
+
throw err;
|
|
1013
|
+
} finally {
|
|
1014
|
+
clearTimeout(timeout);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async write(chunk) {
|
|
1018
|
+
if (this.closed) {
|
|
1019
|
+
throw RecordingEngineError.state("write", "closed");
|
|
1020
|
+
}
|
|
1021
|
+
const position = this.bytesWritten;
|
|
1022
|
+
this.bytesWritten += chunk.byteLength;
|
|
1023
|
+
await this.rpc({ op: "write", data: chunk, position });
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Ranged read through the write worker. Ordered after every write RPC
|
|
1027
|
+
* posted before it, so a read at any offset the caller has already
|
|
1028
|
+
* written is guaranteed to observe those bytes. Returns fewer bytes than
|
|
1029
|
+
* requested only at end-of-file.
|
|
1030
|
+
*/
|
|
1031
|
+
async readAt(position, length) {
|
|
1032
|
+
if (this.closed) {
|
|
1033
|
+
throw RecordingEngineError.state("readAt", "closed");
|
|
1034
|
+
}
|
|
1035
|
+
const result = await this.rpc({ op: "read", position, length });
|
|
1036
|
+
if (!(result instanceof Uint8Array)) {
|
|
1037
|
+
throw RecordingEngineError.internal(`OPFS worker read returned no data (${this.path})`);
|
|
1038
|
+
}
|
|
1039
|
+
return result;
|
|
1040
|
+
}
|
|
1041
|
+
async close() {
|
|
1042
|
+
if (this.closed)
|
|
1043
|
+
return;
|
|
1044
|
+
this.closed = true;
|
|
1045
|
+
try {
|
|
1046
|
+
await this.rpc({ op: "close" });
|
|
1047
|
+
} finally {
|
|
1048
|
+
this.release();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
async abort() {
|
|
1052
|
+
if (this.closed)
|
|
1053
|
+
return;
|
|
1054
|
+
this.closed = true;
|
|
1055
|
+
try {
|
|
1056
|
+
await this.rpc({ op: "abort" });
|
|
1057
|
+
} finally {
|
|
1058
|
+
this.release();
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
async discard() {
|
|
1062
|
+
if (this.discarded)
|
|
1063
|
+
return;
|
|
1064
|
+
this.discarded = true;
|
|
1065
|
+
if (!this.closed) {
|
|
1066
|
+
try {
|
|
1067
|
+
await this.abort();
|
|
1068
|
+
} catch {
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
await this.removeSelf();
|
|
1073
|
+
} catch {
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
rpc(msg) {
|
|
1077
|
+
return new Promise((resolve, reject) => {
|
|
1078
|
+
if (this.released) {
|
|
1079
|
+
reject(RecordingEngineError.internal(`OPFS sync worker already released (${this.path})`));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const id = this.nextId++;
|
|
1083
|
+
this.pending.set(id, { resolve, reject });
|
|
1084
|
+
this.worker.postMessage({ ...msg, id });
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
failAllPending(err) {
|
|
1088
|
+
const entries = [...this.pending.values()];
|
|
1089
|
+
this.pending.clear();
|
|
1090
|
+
for (const entry of entries)
|
|
1091
|
+
entry.reject(err);
|
|
1092
|
+
}
|
|
1093
|
+
release() {
|
|
1094
|
+
if (this.released)
|
|
1095
|
+
return;
|
|
1096
|
+
this.released = true;
|
|
1097
|
+
this.failAllPending(RecordingEngineError.internal(`OPFS sync worker released (${this.path})`));
|
|
1098
|
+
this.releaseWorker();
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
var OpfsFileSinkHandle = class {
|
|
1102
|
+
path;
|
|
1103
|
+
removeSelf;
|
|
1104
|
+
writable;
|
|
1105
|
+
bytesWritten = 0;
|
|
1106
|
+
closed = false;
|
|
1107
|
+
discarded = false;
|
|
1108
|
+
// Serialize position-based writes. Without this, two concurrent callers
|
|
1109
|
+
// both read `bytesWritten` before either has incremented it, and the
|
|
1110
|
+
// second write clobbers the first at the same offset.
|
|
1111
|
+
writeChain = Promise.resolve();
|
|
1112
|
+
constructor(path, writable, removeSelf) {
|
|
1113
|
+
this.path = path;
|
|
1114
|
+
this.removeSelf = removeSelf;
|
|
1115
|
+
this.writable = writable;
|
|
1116
|
+
}
|
|
1117
|
+
async write(chunk) {
|
|
1118
|
+
if (this.closed || !this.writable) {
|
|
1119
|
+
throw RecordingEngineError.state("write", "closed");
|
|
1120
|
+
}
|
|
1121
|
+
const next = this.writeChain.then(async () => {
|
|
1122
|
+
if (!this.writable) {
|
|
1123
|
+
throw RecordingEngineError.state("write", "closed");
|
|
1124
|
+
}
|
|
1125
|
+
await this.writable.write({ type: "write", data: chunk, position: this.bytesWritten });
|
|
1126
|
+
this.bytesWritten += chunk.byteLength;
|
|
1127
|
+
});
|
|
1128
|
+
this.writeChain = next.catch(() => void 0);
|
|
1129
|
+
return next;
|
|
1130
|
+
}
|
|
1131
|
+
async close() {
|
|
1132
|
+
if (this.closed)
|
|
1133
|
+
return;
|
|
1134
|
+
this.closed = true;
|
|
1135
|
+
await this.writeChain.catch(() => void 0);
|
|
1136
|
+
const w = this.writable;
|
|
1137
|
+
this.writable = null;
|
|
1138
|
+
if (w)
|
|
1139
|
+
await w.close();
|
|
1140
|
+
}
|
|
1141
|
+
async abort() {
|
|
1142
|
+
if (this.closed)
|
|
1143
|
+
return;
|
|
1144
|
+
this.closed = true;
|
|
1145
|
+
const w = this.writable;
|
|
1146
|
+
this.writable = null;
|
|
1147
|
+
if (w)
|
|
1148
|
+
await w.abort("aborted");
|
|
1149
|
+
}
|
|
1150
|
+
async discard() {
|
|
1151
|
+
if (this.discarded)
|
|
1152
|
+
return;
|
|
1153
|
+
this.discarded = true;
|
|
1154
|
+
if (!this.closed) {
|
|
1155
|
+
try {
|
|
1156
|
+
await this.abort();
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
try {
|
|
1161
|
+
await this.removeSelf();
|
|
1162
|
+
} catch {
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
var OpfsFileSink = class {
|
|
1167
|
+
deps;
|
|
1168
|
+
constructor(deps = {}) {
|
|
1169
|
+
this.deps = deps;
|
|
1170
|
+
}
|
|
1171
|
+
async open(relativePath) {
|
|
1172
|
+
const { dirSegments, fileName } = splitPath(relativePath);
|
|
1173
|
+
const factory = this.deps.workerFactory !== void 0 ? this.deps.workerFactory : typeof Worker !== "undefined" ? defaultWorkerFactory : null;
|
|
1174
|
+
if (factory) {
|
|
1175
|
+
try {
|
|
1176
|
+
return await OpfsSyncWorkerFileSinkHandle.open(relativePath, dirSegments, fileName, factory, () => this.remove(relativePath));
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
this.deps.logError?.("opfs-sink", "sync-worker sink unavailable; falling back to createWritable (writes not crash-durable)", err);
|
|
1179
|
+
this.deps.onDurabilityFallback?.();
|
|
1180
|
+
}
|
|
1181
|
+
} else if (this.deps.workerFactory === void 0) {
|
|
1182
|
+
this.deps.logError?.("opfs-sink", "Worker unavailable; falling back to createWritable (writes not crash-durable)");
|
|
1183
|
+
this.deps.onDurabilityFallback?.();
|
|
1184
|
+
}
|
|
1185
|
+
const root = await getOpfsRoot();
|
|
1186
|
+
const dir = await resolveDir(root, dirSegments);
|
|
1187
|
+
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
|
1188
|
+
const writable = await fileHandle.createWritable({ keepExistingData: false });
|
|
1189
|
+
return new OpfsFileSinkHandle(relativePath, writable, () => this.remove(relativePath));
|
|
1190
|
+
}
|
|
1191
|
+
async remove(relativePath) {
|
|
1192
|
+
const root = await getOpfsRoot();
|
|
1193
|
+
const { dirSegments, fileName } = splitPath(relativePath);
|
|
1194
|
+
const dir = await resolveDir(root, dirSegments);
|
|
1195
|
+
await dir.removeEntry(fileName);
|
|
1196
|
+
}
|
|
1197
|
+
async read(relativePath) {
|
|
1198
|
+
const root = await getOpfsRoot();
|
|
1199
|
+
const { dirSegments, fileName } = splitPath(relativePath);
|
|
1200
|
+
const dir = await resolveDir(root, dirSegments);
|
|
1201
|
+
const fileHandle = await dir.getFileHandle(fileName);
|
|
1202
|
+
return await fileHandle.getFile();
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
var AVC_LEVEL_3_0 = { hex: "1E", maxFrameMbs: 1620, maxMbsPerSec: 40500 };
|
|
1207
|
+
var AVC_LEVELS = [
|
|
1208
|
+
AVC_LEVEL_3_0,
|
|
1209
|
+
{ hex: "1F", maxFrameMbs: 3600, maxMbsPerSec: 108e3 },
|
|
1210
|
+
// 3.1 — 720p30
|
|
1211
|
+
{ hex: "20", maxFrameMbs: 5120, maxMbsPerSec: 216e3 },
|
|
1212
|
+
// 3.2 — 720p60
|
|
1213
|
+
{ hex: "28", maxFrameMbs: 8192, maxMbsPerSec: 245760 },
|
|
1214
|
+
// 4.0 — 1080p30
|
|
1215
|
+
{ hex: "2A", maxFrameMbs: 8704, maxMbsPerSec: 522240 },
|
|
1216
|
+
// 4.2 — 1080p60
|
|
1217
|
+
{ hex: "33", maxFrameMbs: 36864, maxMbsPerSec: 983040 }
|
|
1218
|
+
// 5.1 — 4K30
|
|
1219
|
+
];
|
|
1220
|
+
var AVC_TOP_LEVEL = { hex: "34", maxFrameMbs: 36864, maxMbsPerSec: 2073600 };
|
|
1221
|
+
function pickAvcCodecString(width, height, frameRate) {
|
|
1222
|
+
const frameMbs = Math.ceil(width / 16) * Math.ceil(height / 16);
|
|
1223
|
+
const mbsPerSec = frameMbs * frameRate;
|
|
1224
|
+
const level = AVC_LEVELS.find((l) => frameMbs <= l.maxFrameMbs && mbsPerSec <= l.maxMbsPerSec) ?? AVC_TOP_LEVEL;
|
|
1225
|
+
return `avc1.6400${level.hex}`;
|
|
1226
|
+
}
|
|
1227
|
+
function fitsBaselineLevel(width, height, frameRate) {
|
|
1228
|
+
const frameMbs = Math.ceil(width / 16) * Math.ceil(height / 16);
|
|
1229
|
+
return frameMbs <= AVC_LEVEL_3_0.maxFrameMbs && frameMbs * frameRate <= AVC_LEVEL_3_0.maxMbsPerSec;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
var LADDER = [
|
|
1233
|
+
{ minHeight: 2160, bps30: 4e7, bps60: 6e7 },
|
|
1234
|
+
// 4K
|
|
1235
|
+
{ minHeight: 1440, bps30: 16e6, bps60: 24e6 },
|
|
1236
|
+
// 1440p
|
|
1237
|
+
{ minHeight: 1080, bps30: 8e6, bps60: 12e6 },
|
|
1238
|
+
// 1080p
|
|
1239
|
+
{ minHeight: 720, bps30: 5e6, bps60: 75e5 }
|
|
1240
|
+
// 720p
|
|
1241
|
+
];
|
|
1242
|
+
var FLOOR_RUNG = { bps30: 25e5, bps60: 4e6 };
|
|
1243
|
+
var HIGH_FPS_THRESHOLD = 48;
|
|
1244
|
+
function bitrateForResolution(height, frameRate) {
|
|
1245
|
+
const h = Math.max(0, Math.round(height));
|
|
1246
|
+
const rung = LADDER.find((r) => h >= r.minHeight) ?? FLOOR_RUNG;
|
|
1247
|
+
return frameRate >= HIGH_FPS_THRESHOLD ? rung.bps60 : rung.bps30;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
var MP4_BASELINE_MIME = "video/mp4;codecs=avc1.42E01E,mp4a.40.2";
|
|
1251
|
+
var BASELINE_VIDEO_CODEC = "avc1.42E01E";
|
|
1252
|
+
var START_TIMEOUT_MS = 1e4;
|
|
1253
|
+
var STOP_TIMEOUT_MS = 3e4;
|
|
1254
|
+
function withTimeout(promise, ms, message) {
|
|
1255
|
+
return new Promise((resolve, reject) => {
|
|
1256
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
1257
|
+
promise.then((value) => {
|
|
1258
|
+
clearTimeout(timer);
|
|
1259
|
+
resolve(value);
|
|
1260
|
+
}, (err) => {
|
|
1261
|
+
clearTimeout(timer);
|
|
1262
|
+
reject(err);
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
var MediaRecorderMp4Strategy = class {
|
|
1267
|
+
kind = "mediarecorder-mp4";
|
|
1268
|
+
recorder = null;
|
|
1269
|
+
// Set in start() from the negotiated resolution; reported in the MuxResult.
|
|
1270
|
+
videoCodec = BASELINE_VIDEO_CODEC;
|
|
1271
|
+
// Rolling tail of the serialized write chain. Each blob awaits this before
|
|
1272
|
+
// writing, then becomes the new tail — awaiting it awaits the whole chain
|
|
1273
|
+
// without retaining one promise per chunk for the recording's lifetime.
|
|
1274
|
+
writeTail = Promise.resolve();
|
|
1275
|
+
firstError = null;
|
|
1276
|
+
started = false;
|
|
1277
|
+
errorHandler = null;
|
|
1278
|
+
onError(handler) {
|
|
1279
|
+
this.errorHandler = handler;
|
|
1280
|
+
}
|
|
1281
|
+
reportError(cause) {
|
|
1282
|
+
const err = cause instanceof Error ? cause : new Error(String(cause));
|
|
1283
|
+
if (!this.firstError)
|
|
1284
|
+
this.firstError = err;
|
|
1285
|
+
try {
|
|
1286
|
+
if (this.recorder && this.recorder.state !== "inactive") {
|
|
1287
|
+
this.recorder.stop();
|
|
1288
|
+
}
|
|
1289
|
+
} catch {
|
|
1290
|
+
}
|
|
1291
|
+
if (!this.started)
|
|
1292
|
+
return;
|
|
1293
|
+
const handler = this.errorHandler;
|
|
1294
|
+
if (!handler)
|
|
1295
|
+
return;
|
|
1296
|
+
try {
|
|
1297
|
+
handler(err);
|
|
1298
|
+
} catch {
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
async start(stream, sink, opts) {
|
|
1302
|
+
if (this.recorder) {
|
|
1303
|
+
throw RecordingEngineError.state("start", "recording");
|
|
1304
|
+
}
|
|
1305
|
+
const settings = stream.getVideoTracks()[0]?.getSettings();
|
|
1306
|
+
const width = settings?.width ?? 1920;
|
|
1307
|
+
const height = settings?.height ?? 1080;
|
|
1308
|
+
const frameRate = settings?.frameRate ?? 30;
|
|
1309
|
+
const leveledVideoCodec = pickAvcCodecString(width, height, frameRate);
|
|
1310
|
+
const leveledMime = `video/mp4;codecs=${leveledVideoCodec},mp4a.40.2`;
|
|
1311
|
+
let mime;
|
|
1312
|
+
if (MediaRecorder.isTypeSupported(leveledMime)) {
|
|
1313
|
+
mime = leveledMime;
|
|
1314
|
+
this.videoCodec = leveledVideoCodec;
|
|
1315
|
+
} else if (MediaRecorder.isTypeSupported(MP4_BASELINE_MIME) && fitsBaselineLevel(width, height, frameRate)) {
|
|
1316
|
+
mime = MP4_BASELINE_MIME;
|
|
1317
|
+
this.videoCodec = BASELINE_VIDEO_CODEC;
|
|
1318
|
+
} else {
|
|
1319
|
+
throw RecordingEngineError.capability(`MediaRecorder MP4 (${leveledMime})`);
|
|
1320
|
+
}
|
|
1321
|
+
const recorder = new MediaRecorder(stream, {
|
|
1322
|
+
mimeType: mime,
|
|
1323
|
+
videoBitsPerSecond: opts.videoBitsPerSecond ?? bitrateForResolution(height, frameRate),
|
|
1324
|
+
audioBitsPerSecond: opts.audioBitsPerSecond ?? 192e3
|
|
1325
|
+
});
|
|
1326
|
+
recorder.ondataavailable = (event) => {
|
|
1327
|
+
if (event.data.size === 0)
|
|
1328
|
+
return;
|
|
1329
|
+
const previous = this.writeTail;
|
|
1330
|
+
const blob = event.data;
|
|
1331
|
+
const writePromise = (async () => {
|
|
1332
|
+
await previous.catch(() => void 0);
|
|
1333
|
+
const buf = await blob.arrayBuffer();
|
|
1334
|
+
await sink.write(new Uint8Array(buf));
|
|
1335
|
+
})();
|
|
1336
|
+
this.writeTail = writePromise;
|
|
1337
|
+
writePromise.catch((err) => this.reportError(err));
|
|
1338
|
+
};
|
|
1339
|
+
recorder.onerror = (event) => {
|
|
1340
|
+
const cause = event.error;
|
|
1341
|
+
this.reportError(cause ?? new Error("MediaRecorder error"));
|
|
1342
|
+
};
|
|
1343
|
+
this.recorder = recorder;
|
|
1344
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
1345
|
+
recorder.onstart = () => resolve();
|
|
1346
|
+
const errorHandler = () => reject(this.firstError ?? new Error("MediaRecorder start failed"));
|
|
1347
|
+
recorder.addEventListener("error", errorHandler, { once: true });
|
|
1348
|
+
try {
|
|
1349
|
+
recorder.start(1e3);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
reject(err);
|
|
1352
|
+
}
|
|
1353
|
+
}), START_TIMEOUT_MS, `MediaRecorder never fired onstart within ${START_TIMEOUT_MS}ms`);
|
|
1354
|
+
this.started = true;
|
|
1355
|
+
}
|
|
1356
|
+
async stop() {
|
|
1357
|
+
const recorder = this.recorder;
|
|
1358
|
+
if (!recorder) {
|
|
1359
|
+
throw RecordingEngineError.state("stop", "idle");
|
|
1360
|
+
}
|
|
1361
|
+
const stopped = new Promise((resolve, reject) => {
|
|
1362
|
+
recorder.onstop = () => {
|
|
1363
|
+
void (async () => {
|
|
1364
|
+
try {
|
|
1365
|
+
await this.writeTail.catch(() => void 0);
|
|
1366
|
+
if (this.firstError) {
|
|
1367
|
+
reject(this.firstError);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
resolve({
|
|
1371
|
+
videoCodec: this.videoCodec,
|
|
1372
|
+
audioCodec: "mp4a.40.2",
|
|
1373
|
+
containerMime: "video/mp4"
|
|
1374
|
+
});
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
reject(err);
|
|
1377
|
+
}
|
|
1378
|
+
})();
|
|
1379
|
+
};
|
|
1380
|
+
try {
|
|
1381
|
+
if (recorder.state !== "inactive") {
|
|
1382
|
+
recorder.stop();
|
|
1383
|
+
} else {
|
|
1384
|
+
void (async () => {
|
|
1385
|
+
try {
|
|
1386
|
+
await this.writeTail.catch(() => void 0);
|
|
1387
|
+
if (this.firstError) {
|
|
1388
|
+
reject(this.firstError);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
resolve({
|
|
1392
|
+
videoCodec: this.videoCodec,
|
|
1393
|
+
audioCodec: "mp4a.40.2",
|
|
1394
|
+
containerMime: "video/mp4"
|
|
1395
|
+
});
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
reject(err);
|
|
1398
|
+
}
|
|
1399
|
+
})();
|
|
1400
|
+
}
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
reject(err);
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
return await withTimeout(stopped, STOP_TIMEOUT_MS, `MediaRecorder never fired onstop within ${STOP_TIMEOUT_MS}ms`);
|
|
1406
|
+
}
|
|
1407
|
+
async cancel() {
|
|
1408
|
+
const recorder = this.recorder;
|
|
1409
|
+
this.recorder = null;
|
|
1410
|
+
if (!recorder)
|
|
1411
|
+
return;
|
|
1412
|
+
try {
|
|
1413
|
+
if (recorder.state !== "inactive") {
|
|
1414
|
+
recorder.stop();
|
|
1415
|
+
}
|
|
1416
|
+
} catch {
|
|
1417
|
+
}
|
|
1418
|
+
await this.writeTail.catch(() => void 0);
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
var __accessCheck = (obj, member, msg) => {
|
|
1423
|
+
if (!member.has(obj))
|
|
1424
|
+
throw TypeError("Cannot " + msg);
|
|
1425
|
+
};
|
|
1426
|
+
var __privateGet = (obj, member, getter) => {
|
|
1427
|
+
__accessCheck(obj, member, "read from private field");
|
|
1428
|
+
return getter ? getter.call(obj) : member.get(obj);
|
|
1429
|
+
};
|
|
1430
|
+
var __privateAdd = (obj, member, value) => {
|
|
1431
|
+
if (member.has(obj))
|
|
1432
|
+
throw TypeError("Cannot add the same private member more than once");
|
|
1433
|
+
member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
|
1434
|
+
};
|
|
1435
|
+
var __privateSet = (obj, member, value, setter) => {
|
|
1436
|
+
__accessCheck(obj, member, "write to private field");
|
|
1437
|
+
setter ? setter.call(obj, value) : member.set(obj, value);
|
|
1438
|
+
return value;
|
|
1439
|
+
};
|
|
1440
|
+
var __privateWrapper = (obj, member, setter, getter) => ({
|
|
1441
|
+
set _(value) {
|
|
1442
|
+
__privateSet(obj, member, value, setter);
|
|
1443
|
+
},
|
|
1444
|
+
get _() {
|
|
1445
|
+
return __privateGet(obj, member, getter);
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
var __privateMethod = (obj, member, method) => {
|
|
1449
|
+
__accessCheck(obj, member, "access private method");
|
|
1450
|
+
return method;
|
|
1451
|
+
};
|
|
1452
|
+
var bytes = new Uint8Array(8);
|
|
1453
|
+
var view = new DataView(bytes.buffer);
|
|
1454
|
+
var u8 = (value) => {
|
|
1455
|
+
return [(value % 256 + 256) % 256];
|
|
1456
|
+
};
|
|
1457
|
+
var u16 = (value) => {
|
|
1458
|
+
view.setUint16(0, value, false);
|
|
1459
|
+
return [bytes[0], bytes[1]];
|
|
1460
|
+
};
|
|
1461
|
+
var i16 = (value) => {
|
|
1462
|
+
view.setInt16(0, value, false);
|
|
1463
|
+
return [bytes[0], bytes[1]];
|
|
1464
|
+
};
|
|
1465
|
+
var u24 = (value) => {
|
|
1466
|
+
view.setUint32(0, value, false);
|
|
1467
|
+
return [bytes[1], bytes[2], bytes[3]];
|
|
1468
|
+
};
|
|
1469
|
+
var u32 = (value) => {
|
|
1470
|
+
view.setUint32(0, value, false);
|
|
1471
|
+
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
1472
|
+
};
|
|
1473
|
+
var i32 = (value) => {
|
|
1474
|
+
view.setInt32(0, value, false);
|
|
1475
|
+
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
1476
|
+
};
|
|
1477
|
+
var u64 = (value) => {
|
|
1478
|
+
view.setUint32(0, Math.floor(value / 2 ** 32), false);
|
|
1479
|
+
view.setUint32(4, value, false);
|
|
1480
|
+
return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]];
|
|
1481
|
+
};
|
|
1482
|
+
var fixed_8_8 = (value) => {
|
|
1483
|
+
view.setInt16(0, 2 ** 8 * value, false);
|
|
1484
|
+
return [bytes[0], bytes[1]];
|
|
1485
|
+
};
|
|
1486
|
+
var fixed_16_16 = (value) => {
|
|
1487
|
+
view.setInt32(0, 2 ** 16 * value, false);
|
|
1488
|
+
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
1489
|
+
};
|
|
1490
|
+
var fixed_2_30 = (value) => {
|
|
1491
|
+
view.setInt32(0, 2 ** 30 * value, false);
|
|
1492
|
+
return [bytes[0], bytes[1], bytes[2], bytes[3]];
|
|
1493
|
+
};
|
|
1494
|
+
var ascii = (text, nullTerminated = false) => {
|
|
1495
|
+
let bytes2 = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i));
|
|
1496
|
+
if (nullTerminated)
|
|
1497
|
+
bytes2.push(0);
|
|
1498
|
+
return bytes2;
|
|
1499
|
+
};
|
|
1500
|
+
var last = (arr) => {
|
|
1501
|
+
return arr && arr[arr.length - 1];
|
|
1502
|
+
};
|
|
1503
|
+
var lastPresentedSample = (samples) => {
|
|
1504
|
+
let result = void 0;
|
|
1505
|
+
for (let sample of samples) {
|
|
1506
|
+
if (!result || sample.presentationTimestamp > result.presentationTimestamp) {
|
|
1507
|
+
result = sample;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return result;
|
|
1511
|
+
};
|
|
1512
|
+
var intoTimescale = (timeInSeconds, timescale, round = true) => {
|
|
1513
|
+
let value = timeInSeconds * timescale;
|
|
1514
|
+
return round ? Math.round(value) : value;
|
|
1515
|
+
};
|
|
1516
|
+
var rotationMatrix = (rotationInDegrees) => {
|
|
1517
|
+
let theta = rotationInDegrees * (Math.PI / 180);
|
|
1518
|
+
let cosTheta = Math.cos(theta);
|
|
1519
|
+
let sinTheta = Math.sin(theta);
|
|
1520
|
+
return [
|
|
1521
|
+
cosTheta,
|
|
1522
|
+
sinTheta,
|
|
1523
|
+
0,
|
|
1524
|
+
-sinTheta,
|
|
1525
|
+
cosTheta,
|
|
1526
|
+
0,
|
|
1527
|
+
0,
|
|
1528
|
+
0,
|
|
1529
|
+
1
|
|
1530
|
+
];
|
|
1531
|
+
};
|
|
1532
|
+
var IDENTITY_MATRIX = rotationMatrix(0);
|
|
1533
|
+
var matrixToBytes = (matrix) => {
|
|
1534
|
+
return [
|
|
1535
|
+
fixed_16_16(matrix[0]),
|
|
1536
|
+
fixed_16_16(matrix[1]),
|
|
1537
|
+
fixed_2_30(matrix[2]),
|
|
1538
|
+
fixed_16_16(matrix[3]),
|
|
1539
|
+
fixed_16_16(matrix[4]),
|
|
1540
|
+
fixed_2_30(matrix[5]),
|
|
1541
|
+
fixed_16_16(matrix[6]),
|
|
1542
|
+
fixed_16_16(matrix[7]),
|
|
1543
|
+
fixed_2_30(matrix[8])
|
|
1544
|
+
];
|
|
1545
|
+
};
|
|
1546
|
+
var deepClone = (x) => {
|
|
1547
|
+
if (!x)
|
|
1548
|
+
return x;
|
|
1549
|
+
if (typeof x !== "object")
|
|
1550
|
+
return x;
|
|
1551
|
+
if (Array.isArray(x))
|
|
1552
|
+
return x.map(deepClone);
|
|
1553
|
+
return Object.fromEntries(Object.entries(x).map(([key, value]) => [key, deepClone(value)]));
|
|
1554
|
+
};
|
|
1555
|
+
var isU32 = (value) => {
|
|
1556
|
+
return value >= 0 && value < 2 ** 32;
|
|
1557
|
+
};
|
|
1558
|
+
var box = (type, contents, children) => ({
|
|
1559
|
+
type,
|
|
1560
|
+
contents: contents && new Uint8Array(contents.flat(10)),
|
|
1561
|
+
children
|
|
1562
|
+
});
|
|
1563
|
+
var fullBox = (type, version, flags, contents, children) => box(
|
|
1564
|
+
type,
|
|
1565
|
+
[u8(version), u24(flags), contents ?? []],
|
|
1566
|
+
children
|
|
1567
|
+
);
|
|
1568
|
+
var ftyp = (details) => {
|
|
1569
|
+
let minorVersion = 512;
|
|
1570
|
+
if (details.fragmented)
|
|
1571
|
+
return box("ftyp", [
|
|
1572
|
+
ascii("iso5"),
|
|
1573
|
+
// Major brand
|
|
1574
|
+
u32(minorVersion),
|
|
1575
|
+
// Minor version
|
|
1576
|
+
// Compatible brands
|
|
1577
|
+
ascii("iso5"),
|
|
1578
|
+
ascii("iso6"),
|
|
1579
|
+
ascii("mp41")
|
|
1580
|
+
]);
|
|
1581
|
+
return box("ftyp", [
|
|
1582
|
+
ascii("isom"),
|
|
1583
|
+
// Major brand
|
|
1584
|
+
u32(minorVersion),
|
|
1585
|
+
// Minor version
|
|
1586
|
+
// Compatible brands
|
|
1587
|
+
ascii("isom"),
|
|
1588
|
+
details.holdsAvc ? ascii("avc1") : [],
|
|
1589
|
+
ascii("mp41")
|
|
1590
|
+
]);
|
|
1591
|
+
};
|
|
1592
|
+
var mdat = (reserveLargeSize) => ({ type: "mdat", largeSize: reserveLargeSize });
|
|
1593
|
+
var free = (size) => ({ type: "free", size });
|
|
1594
|
+
var moov = (tracks, creationTime, fragmented = false) => box("moov", null, [
|
|
1595
|
+
mvhd(creationTime, tracks),
|
|
1596
|
+
...tracks.map((x) => trak(x, creationTime)),
|
|
1597
|
+
fragmented ? mvex(tracks) : null
|
|
1598
|
+
]);
|
|
1599
|
+
var mvhd = (creationTime, tracks) => {
|
|
1600
|
+
let duration = intoTimescale(Math.max(
|
|
1601
|
+
0,
|
|
1602
|
+
...tracks.filter((x) => x.samples.length > 0).map((x) => {
|
|
1603
|
+
const lastSample = lastPresentedSample(x.samples);
|
|
1604
|
+
return lastSample.presentationTimestamp + lastSample.duration;
|
|
1605
|
+
})
|
|
1606
|
+
), GLOBAL_TIMESCALE);
|
|
1607
|
+
let nextTrackId = Math.max(...tracks.map((x) => x.id)) + 1;
|
|
1608
|
+
let needsU64 = !isU32(creationTime) || !isU32(duration);
|
|
1609
|
+
let u32OrU64 = needsU64 ? u64 : u32;
|
|
1610
|
+
return fullBox("mvhd", +needsU64, 0, [
|
|
1611
|
+
u32OrU64(creationTime),
|
|
1612
|
+
// Creation time
|
|
1613
|
+
u32OrU64(creationTime),
|
|
1614
|
+
// Modification time
|
|
1615
|
+
u32(GLOBAL_TIMESCALE),
|
|
1616
|
+
// Timescale
|
|
1617
|
+
u32OrU64(duration),
|
|
1618
|
+
// Duration
|
|
1619
|
+
fixed_16_16(1),
|
|
1620
|
+
// Preferred rate
|
|
1621
|
+
fixed_8_8(1),
|
|
1622
|
+
// Preferred volume
|
|
1623
|
+
Array(10).fill(0),
|
|
1624
|
+
// Reserved
|
|
1625
|
+
matrixToBytes(IDENTITY_MATRIX),
|
|
1626
|
+
// Matrix
|
|
1627
|
+
Array(24).fill(0),
|
|
1628
|
+
// Pre-defined
|
|
1629
|
+
u32(nextTrackId)
|
|
1630
|
+
// Next track ID
|
|
1631
|
+
]);
|
|
1632
|
+
};
|
|
1633
|
+
var trak = (track, creationTime) => box("trak", null, [
|
|
1634
|
+
tkhd(track, creationTime),
|
|
1635
|
+
mdia(track, creationTime)
|
|
1636
|
+
]);
|
|
1637
|
+
var tkhd = (track, creationTime) => {
|
|
1638
|
+
let lastSample = lastPresentedSample(track.samples);
|
|
1639
|
+
let durationInGlobalTimescale = intoTimescale(
|
|
1640
|
+
lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0,
|
|
1641
|
+
GLOBAL_TIMESCALE
|
|
1642
|
+
);
|
|
1643
|
+
let needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale);
|
|
1644
|
+
let u32OrU64 = needsU64 ? u64 : u32;
|
|
1645
|
+
let matrix;
|
|
1646
|
+
if (track.info.type === "video") {
|
|
1647
|
+
matrix = typeof track.info.rotation === "number" ? rotationMatrix(track.info.rotation) : track.info.rotation;
|
|
1648
|
+
} else {
|
|
1649
|
+
matrix = IDENTITY_MATRIX;
|
|
1650
|
+
}
|
|
1651
|
+
return fullBox("tkhd", +needsU64, 3, [
|
|
1652
|
+
u32OrU64(creationTime),
|
|
1653
|
+
// Creation time
|
|
1654
|
+
u32OrU64(creationTime),
|
|
1655
|
+
// Modification time
|
|
1656
|
+
u32(track.id),
|
|
1657
|
+
// Track ID
|
|
1658
|
+
u32(0),
|
|
1659
|
+
// Reserved
|
|
1660
|
+
u32OrU64(durationInGlobalTimescale),
|
|
1661
|
+
// Duration
|
|
1662
|
+
Array(8).fill(0),
|
|
1663
|
+
// Reserved
|
|
1664
|
+
u16(0),
|
|
1665
|
+
// Layer
|
|
1666
|
+
u16(0),
|
|
1667
|
+
// Alternate group
|
|
1668
|
+
fixed_8_8(track.info.type === "audio" ? 1 : 0),
|
|
1669
|
+
// Volume
|
|
1670
|
+
u16(0),
|
|
1671
|
+
// Reserved
|
|
1672
|
+
matrixToBytes(matrix),
|
|
1673
|
+
// Matrix
|
|
1674
|
+
fixed_16_16(track.info.type === "video" ? track.info.width : 0),
|
|
1675
|
+
// Track width
|
|
1676
|
+
fixed_16_16(track.info.type === "video" ? track.info.height : 0)
|
|
1677
|
+
// Track height
|
|
1678
|
+
]);
|
|
1679
|
+
};
|
|
1680
|
+
var mdia = (track, creationTime) => box("mdia", null, [
|
|
1681
|
+
mdhd(track, creationTime),
|
|
1682
|
+
hdlr(track.info.type === "video" ? "vide" : "soun"),
|
|
1683
|
+
minf(track)
|
|
1684
|
+
]);
|
|
1685
|
+
var mdhd = (track, creationTime) => {
|
|
1686
|
+
let lastSample = lastPresentedSample(track.samples);
|
|
1687
|
+
let localDuration = intoTimescale(
|
|
1688
|
+
lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0,
|
|
1689
|
+
track.timescale
|
|
1690
|
+
);
|
|
1691
|
+
let needsU64 = !isU32(creationTime) || !isU32(localDuration);
|
|
1692
|
+
let u32OrU64 = needsU64 ? u64 : u32;
|
|
1693
|
+
return fullBox("mdhd", +needsU64, 0, [
|
|
1694
|
+
u32OrU64(creationTime),
|
|
1695
|
+
// Creation time
|
|
1696
|
+
u32OrU64(creationTime),
|
|
1697
|
+
// Modification time
|
|
1698
|
+
u32(track.timescale),
|
|
1699
|
+
// Timescale
|
|
1700
|
+
u32OrU64(localDuration),
|
|
1701
|
+
// Duration
|
|
1702
|
+
u16(21956),
|
|
1703
|
+
// Language ("und", undetermined)
|
|
1704
|
+
u16(0)
|
|
1705
|
+
// Quality
|
|
1706
|
+
]);
|
|
1707
|
+
};
|
|
1708
|
+
var hdlr = (componentSubtype) => fullBox("hdlr", 0, 0, [
|
|
1709
|
+
ascii("mhlr"),
|
|
1710
|
+
// Component type
|
|
1711
|
+
ascii(componentSubtype),
|
|
1712
|
+
// Component subtype
|
|
1713
|
+
u32(0),
|
|
1714
|
+
// Component manufacturer
|
|
1715
|
+
u32(0),
|
|
1716
|
+
// Component flags
|
|
1717
|
+
u32(0),
|
|
1718
|
+
// Component flags mask
|
|
1719
|
+
ascii("mp4-muxer-hdlr", true)
|
|
1720
|
+
// Component name
|
|
1721
|
+
]);
|
|
1722
|
+
var minf = (track) => box("minf", null, [
|
|
1723
|
+
track.info.type === "video" ? vmhd() : smhd(),
|
|
1724
|
+
dinf(),
|
|
1725
|
+
stbl(track)
|
|
1726
|
+
]);
|
|
1727
|
+
var vmhd = () => fullBox("vmhd", 0, 1, [
|
|
1728
|
+
u16(0),
|
|
1729
|
+
// Graphics mode
|
|
1730
|
+
u16(0),
|
|
1731
|
+
// Opcolor R
|
|
1732
|
+
u16(0),
|
|
1733
|
+
// Opcolor G
|
|
1734
|
+
u16(0)
|
|
1735
|
+
// Opcolor B
|
|
1736
|
+
]);
|
|
1737
|
+
var smhd = () => fullBox("smhd", 0, 0, [
|
|
1738
|
+
u16(0),
|
|
1739
|
+
// Balance
|
|
1740
|
+
u16(0)
|
|
1741
|
+
// Reserved
|
|
1742
|
+
]);
|
|
1743
|
+
var dinf = () => box("dinf", null, [
|
|
1744
|
+
dref()
|
|
1745
|
+
]);
|
|
1746
|
+
var dref = () => fullBox("dref", 0, 0, [
|
|
1747
|
+
u32(1)
|
|
1748
|
+
// Entry count
|
|
1749
|
+
], [
|
|
1750
|
+
url()
|
|
1751
|
+
]);
|
|
1752
|
+
var url = () => fullBox("url ", 0, 1);
|
|
1753
|
+
var stbl = (track) => {
|
|
1754
|
+
const needsCtts = track.compositionTimeOffsetTable.length > 1 || track.compositionTimeOffsetTable.some((x) => x.sampleCompositionTimeOffset !== 0);
|
|
1755
|
+
return box("stbl", null, [
|
|
1756
|
+
stsd(track),
|
|
1757
|
+
stts(track),
|
|
1758
|
+
stss(track),
|
|
1759
|
+
stsc(track),
|
|
1760
|
+
stsz(track),
|
|
1761
|
+
stco(track),
|
|
1762
|
+
needsCtts ? ctts(track) : null
|
|
1763
|
+
]);
|
|
1764
|
+
};
|
|
1765
|
+
var stsd = (track) => fullBox("stsd", 0, 0, [
|
|
1766
|
+
u32(1)
|
|
1767
|
+
// Entry count
|
|
1768
|
+
], [
|
|
1769
|
+
track.info.type === "video" ? videoSampleDescription(
|
|
1770
|
+
VIDEO_CODEC_TO_BOX_NAME[track.info.codec],
|
|
1771
|
+
track
|
|
1772
|
+
) : soundSampleDescription(
|
|
1773
|
+
AUDIO_CODEC_TO_BOX_NAME[track.info.codec],
|
|
1774
|
+
track
|
|
1775
|
+
)
|
|
1776
|
+
]);
|
|
1777
|
+
var videoSampleDescription = (compressionType, track) => box(compressionType, [
|
|
1778
|
+
Array(6).fill(0),
|
|
1779
|
+
// Reserved
|
|
1780
|
+
u16(1),
|
|
1781
|
+
// Data reference index
|
|
1782
|
+
u16(0),
|
|
1783
|
+
// Pre-defined
|
|
1784
|
+
u16(0),
|
|
1785
|
+
// Reserved
|
|
1786
|
+
Array(12).fill(0),
|
|
1787
|
+
// Pre-defined
|
|
1788
|
+
u16(track.info.width),
|
|
1789
|
+
// Width
|
|
1790
|
+
u16(track.info.height),
|
|
1791
|
+
// Height
|
|
1792
|
+
u32(4718592),
|
|
1793
|
+
// Horizontal resolution
|
|
1794
|
+
u32(4718592),
|
|
1795
|
+
// Vertical resolution
|
|
1796
|
+
u32(0),
|
|
1797
|
+
// Reserved
|
|
1798
|
+
u16(1),
|
|
1799
|
+
// Frame count
|
|
1800
|
+
Array(32).fill(0),
|
|
1801
|
+
// Compressor name
|
|
1802
|
+
u16(24),
|
|
1803
|
+
// Depth
|
|
1804
|
+
i16(65535)
|
|
1805
|
+
// Pre-defined
|
|
1806
|
+
], [
|
|
1807
|
+
VIDEO_CODEC_TO_CONFIGURATION_BOX[track.info.codec](track),
|
|
1808
|
+
track.info.decoderConfig.colorSpace ? colr(track) : null
|
|
1809
|
+
]);
|
|
1810
|
+
var COLOR_PRIMARIES_MAP = {
|
|
1811
|
+
"bt709": 1,
|
|
1812
|
+
// ITU-R BT.709
|
|
1813
|
+
"bt470bg": 5,
|
|
1814
|
+
// ITU-R BT.470BG
|
|
1815
|
+
"smpte170m": 6
|
|
1816
|
+
// ITU-R BT.601 525 - SMPTE 170M
|
|
1817
|
+
};
|
|
1818
|
+
var TRANSFER_CHARACTERISTICS_MAP = {
|
|
1819
|
+
"bt709": 1,
|
|
1820
|
+
// ITU-R BT.709
|
|
1821
|
+
"smpte170m": 6,
|
|
1822
|
+
// SMPTE 170M
|
|
1823
|
+
"iec61966-2-1": 13
|
|
1824
|
+
// IEC 61966-2-1
|
|
1825
|
+
};
|
|
1826
|
+
var MATRIX_COEFFICIENTS_MAP = {
|
|
1827
|
+
"rgb": 0,
|
|
1828
|
+
// Identity
|
|
1829
|
+
"bt709": 1,
|
|
1830
|
+
// ITU-R BT.709
|
|
1831
|
+
"bt470bg": 5,
|
|
1832
|
+
// ITU-R BT.470BG
|
|
1833
|
+
"smpte170m": 6
|
|
1834
|
+
// SMPTE 170M
|
|
1835
|
+
};
|
|
1836
|
+
var colr = (track) => box("colr", [
|
|
1837
|
+
ascii("nclx"),
|
|
1838
|
+
// Colour type
|
|
1839
|
+
u16(COLOR_PRIMARIES_MAP[track.info.decoderConfig.colorSpace.primaries]),
|
|
1840
|
+
// Colour primaries
|
|
1841
|
+
u16(TRANSFER_CHARACTERISTICS_MAP[track.info.decoderConfig.colorSpace.transfer]),
|
|
1842
|
+
// Transfer characteristics
|
|
1843
|
+
u16(MATRIX_COEFFICIENTS_MAP[track.info.decoderConfig.colorSpace.matrix]),
|
|
1844
|
+
// Matrix coefficients
|
|
1845
|
+
u8((track.info.decoderConfig.colorSpace.fullRange ? 1 : 0) << 7)
|
|
1846
|
+
// Full range flag
|
|
1847
|
+
]);
|
|
1848
|
+
var avcC = (track) => track.info.decoderConfig && box("avcC", [
|
|
1849
|
+
// For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here
|
|
1850
|
+
...new Uint8Array(track.info.decoderConfig.description)
|
|
1851
|
+
]);
|
|
1852
|
+
var hvcC = (track) => track.info.decoderConfig && box("hvcC", [
|
|
1853
|
+
// For HEVC, description is a HEVCDecoderConfigurationRecord, so nothing else to do here
|
|
1854
|
+
...new Uint8Array(track.info.decoderConfig.description)
|
|
1855
|
+
]);
|
|
1856
|
+
var vpcC = (track) => {
|
|
1857
|
+
if (!track.info.decoderConfig) {
|
|
1858
|
+
return null;
|
|
1859
|
+
}
|
|
1860
|
+
let decoderConfig = track.info.decoderConfig;
|
|
1861
|
+
if (!decoderConfig.colorSpace) {
|
|
1862
|
+
throw new Error(`'colorSpace' is required in the decoder config for VP9.`);
|
|
1863
|
+
}
|
|
1864
|
+
let parts = decoderConfig.codec.split(".");
|
|
1865
|
+
let profile = Number(parts[1]);
|
|
1866
|
+
let level = Number(parts[2]);
|
|
1867
|
+
let bitDepth = Number(parts[3]);
|
|
1868
|
+
let chromaSubsampling = 0;
|
|
1869
|
+
let thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + Number(decoderConfig.colorSpace.fullRange);
|
|
1870
|
+
let colourPrimaries = 2;
|
|
1871
|
+
let transferCharacteristics = 2;
|
|
1872
|
+
let matrixCoefficients = 2;
|
|
1873
|
+
return fullBox("vpcC", 1, 0, [
|
|
1874
|
+
u8(profile),
|
|
1875
|
+
// Profile
|
|
1876
|
+
u8(level),
|
|
1877
|
+
// Level
|
|
1878
|
+
u8(thirdByte),
|
|
1879
|
+
// Bit depth, chroma subsampling, full range
|
|
1880
|
+
u8(colourPrimaries),
|
|
1881
|
+
// Colour primaries
|
|
1882
|
+
u8(transferCharacteristics),
|
|
1883
|
+
// Transfer characteristics
|
|
1884
|
+
u8(matrixCoefficients),
|
|
1885
|
+
// Matrix coefficients
|
|
1886
|
+
u16(0)
|
|
1887
|
+
// Codec initialization data size
|
|
1888
|
+
]);
|
|
1889
|
+
};
|
|
1890
|
+
var av1C = () => {
|
|
1891
|
+
let marker = 1;
|
|
1892
|
+
let version = 1;
|
|
1893
|
+
let firstByte = (marker << 7) + version;
|
|
1894
|
+
return box("av1C", [
|
|
1895
|
+
firstByte,
|
|
1896
|
+
0,
|
|
1897
|
+
0,
|
|
1898
|
+
0
|
|
1899
|
+
]);
|
|
1900
|
+
};
|
|
1901
|
+
var soundSampleDescription = (compressionType, track) => box(compressionType, [
|
|
1902
|
+
Array(6).fill(0),
|
|
1903
|
+
// Reserved
|
|
1904
|
+
u16(1),
|
|
1905
|
+
// Data reference index
|
|
1906
|
+
u16(0),
|
|
1907
|
+
// Version
|
|
1908
|
+
u16(0),
|
|
1909
|
+
// Revision level
|
|
1910
|
+
u32(0),
|
|
1911
|
+
// Vendor
|
|
1912
|
+
u16(track.info.numberOfChannels),
|
|
1913
|
+
// Number of channels
|
|
1914
|
+
u16(16),
|
|
1915
|
+
// Sample size (bits)
|
|
1916
|
+
u16(0),
|
|
1917
|
+
// Compression ID
|
|
1918
|
+
u16(0),
|
|
1919
|
+
// Packet size
|
|
1920
|
+
fixed_16_16(track.info.sampleRate)
|
|
1921
|
+
// Sample rate
|
|
1922
|
+
], [
|
|
1923
|
+
AUDIO_CODEC_TO_CONFIGURATION_BOX[track.info.codec](track)
|
|
1924
|
+
]);
|
|
1925
|
+
var esds = (track) => {
|
|
1926
|
+
let description = new Uint8Array(track.info.decoderConfig.description);
|
|
1927
|
+
return fullBox("esds", 0, 0, [
|
|
1928
|
+
// https://stackoverflow.com/a/54803118
|
|
1929
|
+
u32(58753152),
|
|
1930
|
+
// TAG(3) = Object Descriptor ([2])
|
|
1931
|
+
u8(32 + description.byteLength),
|
|
1932
|
+
// length of this OD (which includes the next 2 tags)
|
|
1933
|
+
u16(1),
|
|
1934
|
+
// ES_ID = 1
|
|
1935
|
+
u8(0),
|
|
1936
|
+
// flags etc = 0
|
|
1937
|
+
u32(75530368),
|
|
1938
|
+
// TAG(4) = ES Descriptor ([2]) embedded in above OD
|
|
1939
|
+
u8(18 + description.byteLength),
|
|
1940
|
+
// length of this ESD
|
|
1941
|
+
u8(64),
|
|
1942
|
+
// MPEG-4 Audio
|
|
1943
|
+
u8(21),
|
|
1944
|
+
// stream type(6bits)=5 audio, flags(2bits)=1
|
|
1945
|
+
u24(0),
|
|
1946
|
+
// 24bit buffer size
|
|
1947
|
+
u32(130071),
|
|
1948
|
+
// max bitrate
|
|
1949
|
+
u32(130071),
|
|
1950
|
+
// avg bitrate
|
|
1951
|
+
u32(92307584),
|
|
1952
|
+
// TAG(5) = ASC ([2],[3]) embedded in above OD
|
|
1953
|
+
u8(description.byteLength),
|
|
1954
|
+
// length
|
|
1955
|
+
...description,
|
|
1956
|
+
u32(109084800),
|
|
1957
|
+
// TAG(6)
|
|
1958
|
+
u8(1),
|
|
1959
|
+
// length
|
|
1960
|
+
u8(2)
|
|
1961
|
+
// data
|
|
1962
|
+
]);
|
|
1963
|
+
};
|
|
1964
|
+
var dOps = (track) => {
|
|
1965
|
+
let preskip = 3840;
|
|
1966
|
+
let gain = 0;
|
|
1967
|
+
const description = track.info.decoderConfig?.description;
|
|
1968
|
+
if (description) {
|
|
1969
|
+
if (description.byteLength < 18) {
|
|
1970
|
+
throw new TypeError("Invalid decoder description provided for Opus; must be at least 18 bytes long.");
|
|
1971
|
+
}
|
|
1972
|
+
const view2 = ArrayBuffer.isView(description) ? new DataView(description.buffer, description.byteOffset, description.byteLength) : new DataView(description);
|
|
1973
|
+
preskip = view2.getUint16(10, true);
|
|
1974
|
+
gain = view2.getInt16(14, true);
|
|
1975
|
+
}
|
|
1976
|
+
return box("dOps", [
|
|
1977
|
+
u8(0),
|
|
1978
|
+
// Version
|
|
1979
|
+
u8(track.info.numberOfChannels),
|
|
1980
|
+
// OutputChannelCount
|
|
1981
|
+
u16(preskip),
|
|
1982
|
+
u32(track.info.sampleRate),
|
|
1983
|
+
// InputSampleRate
|
|
1984
|
+
fixed_8_8(gain),
|
|
1985
|
+
// OutputGain
|
|
1986
|
+
u8(0)
|
|
1987
|
+
// ChannelMappingFamily
|
|
1988
|
+
]);
|
|
1989
|
+
};
|
|
1990
|
+
var stts = (track) => {
|
|
1991
|
+
return fullBox("stts", 0, 0, [
|
|
1992
|
+
u32(track.timeToSampleTable.length),
|
|
1993
|
+
// Number of entries
|
|
1994
|
+
track.timeToSampleTable.map((x) => [
|
|
1995
|
+
// Time-to-sample table
|
|
1996
|
+
u32(x.sampleCount),
|
|
1997
|
+
// Sample count
|
|
1998
|
+
u32(x.sampleDelta)
|
|
1999
|
+
// Sample duration
|
|
2000
|
+
])
|
|
2001
|
+
]);
|
|
2002
|
+
};
|
|
2003
|
+
var stss = (track) => {
|
|
2004
|
+
if (track.samples.every((x) => x.type === "key"))
|
|
2005
|
+
return null;
|
|
2006
|
+
let keySamples = [...track.samples.entries()].filter(([, sample]) => sample.type === "key");
|
|
2007
|
+
return fullBox("stss", 0, 0, [
|
|
2008
|
+
u32(keySamples.length),
|
|
2009
|
+
// Number of entries
|
|
2010
|
+
keySamples.map(([index]) => u32(index + 1))
|
|
2011
|
+
// Sync sample table
|
|
2012
|
+
]);
|
|
2013
|
+
};
|
|
2014
|
+
var stsc = (track) => {
|
|
2015
|
+
return fullBox("stsc", 0, 0, [
|
|
2016
|
+
u32(track.compactlyCodedChunkTable.length),
|
|
2017
|
+
// Number of entries
|
|
2018
|
+
track.compactlyCodedChunkTable.map((x) => [
|
|
2019
|
+
// Sample-to-chunk table
|
|
2020
|
+
u32(x.firstChunk),
|
|
2021
|
+
// First chunk
|
|
2022
|
+
u32(x.samplesPerChunk),
|
|
2023
|
+
// Samples per chunk
|
|
2024
|
+
u32(1)
|
|
2025
|
+
// Sample description index
|
|
2026
|
+
])
|
|
2027
|
+
]);
|
|
2028
|
+
};
|
|
2029
|
+
var stsz = (track) => fullBox("stsz", 0, 0, [
|
|
2030
|
+
u32(0),
|
|
2031
|
+
// Sample size (0 means non-constant size)
|
|
2032
|
+
u32(track.samples.length),
|
|
2033
|
+
// Number of entries
|
|
2034
|
+
track.samples.map((x) => u32(x.size))
|
|
2035
|
+
// Sample size table
|
|
2036
|
+
]);
|
|
2037
|
+
var stco = (track) => {
|
|
2038
|
+
if (track.finalizedChunks.length > 0 && last(track.finalizedChunks).offset >= 2 ** 32) {
|
|
2039
|
+
return fullBox("co64", 0, 0, [
|
|
2040
|
+
u32(track.finalizedChunks.length),
|
|
2041
|
+
// Number of entries
|
|
2042
|
+
track.finalizedChunks.map((x) => u64(x.offset))
|
|
2043
|
+
// Chunk offset table
|
|
2044
|
+
]);
|
|
2045
|
+
}
|
|
2046
|
+
return fullBox("stco", 0, 0, [
|
|
2047
|
+
u32(track.finalizedChunks.length),
|
|
2048
|
+
// Number of entries
|
|
2049
|
+
track.finalizedChunks.map((x) => u32(x.offset))
|
|
2050
|
+
// Chunk offset table
|
|
2051
|
+
]);
|
|
2052
|
+
};
|
|
2053
|
+
var ctts = (track) => {
|
|
2054
|
+
return fullBox("ctts", 0, 0, [
|
|
2055
|
+
u32(track.compositionTimeOffsetTable.length),
|
|
2056
|
+
// Number of entries
|
|
2057
|
+
track.compositionTimeOffsetTable.map((x) => [
|
|
2058
|
+
// Time-to-sample table
|
|
2059
|
+
u32(x.sampleCount),
|
|
2060
|
+
// Sample count
|
|
2061
|
+
u32(x.sampleCompositionTimeOffset)
|
|
2062
|
+
// Sample offset
|
|
2063
|
+
])
|
|
2064
|
+
]);
|
|
2065
|
+
};
|
|
2066
|
+
var mvex = (tracks) => {
|
|
2067
|
+
return box("mvex", null, tracks.map(trex));
|
|
2068
|
+
};
|
|
2069
|
+
var trex = (track) => {
|
|
2070
|
+
return fullBox("trex", 0, 0, [
|
|
2071
|
+
u32(track.id),
|
|
2072
|
+
// Track ID
|
|
2073
|
+
u32(1),
|
|
2074
|
+
// Default sample description index
|
|
2075
|
+
u32(0),
|
|
2076
|
+
// Default sample duration
|
|
2077
|
+
u32(0),
|
|
2078
|
+
// Default sample size
|
|
2079
|
+
u32(0)
|
|
2080
|
+
// Default sample flags
|
|
2081
|
+
]);
|
|
2082
|
+
};
|
|
2083
|
+
var moof = (sequenceNumber, tracks) => {
|
|
2084
|
+
return box("moof", null, [
|
|
2085
|
+
mfhd(sequenceNumber),
|
|
2086
|
+
...tracks.map(traf)
|
|
2087
|
+
]);
|
|
2088
|
+
};
|
|
2089
|
+
var mfhd = (sequenceNumber) => {
|
|
2090
|
+
return fullBox("mfhd", 0, 0, [
|
|
2091
|
+
u32(sequenceNumber)
|
|
2092
|
+
// Sequence number
|
|
2093
|
+
]);
|
|
2094
|
+
};
|
|
2095
|
+
var fragmentSampleFlags = (sample) => {
|
|
2096
|
+
let byte1 = 0;
|
|
2097
|
+
let byte2 = 0;
|
|
2098
|
+
let byte3 = 0;
|
|
2099
|
+
let byte4 = 0;
|
|
2100
|
+
let sampleIsDifferenceSample = sample.type === "delta";
|
|
2101
|
+
byte2 |= +sampleIsDifferenceSample;
|
|
2102
|
+
if (sampleIsDifferenceSample) {
|
|
2103
|
+
byte1 |= 1;
|
|
2104
|
+
} else {
|
|
2105
|
+
byte1 |= 2;
|
|
2106
|
+
}
|
|
2107
|
+
return byte1 << 24 | byte2 << 16 | byte3 << 8 | byte4;
|
|
2108
|
+
};
|
|
2109
|
+
var traf = (track) => {
|
|
2110
|
+
return box("traf", null, [
|
|
2111
|
+
tfhd(track),
|
|
2112
|
+
tfdt(track),
|
|
2113
|
+
trun(track)
|
|
2114
|
+
]);
|
|
2115
|
+
};
|
|
2116
|
+
var tfhd = (track) => {
|
|
2117
|
+
let tfFlags = 0;
|
|
2118
|
+
tfFlags |= 8;
|
|
2119
|
+
tfFlags |= 16;
|
|
2120
|
+
tfFlags |= 32;
|
|
2121
|
+
tfFlags |= 131072;
|
|
2122
|
+
let referenceSample = track.currentChunk.samples[1] ?? track.currentChunk.samples[0];
|
|
2123
|
+
let referenceSampleInfo = {
|
|
2124
|
+
duration: referenceSample.timescaleUnitsToNextSample,
|
|
2125
|
+
size: referenceSample.size,
|
|
2126
|
+
flags: fragmentSampleFlags(referenceSample)
|
|
2127
|
+
};
|
|
2128
|
+
return fullBox("tfhd", 0, tfFlags, [
|
|
2129
|
+
u32(track.id),
|
|
2130
|
+
// Track ID
|
|
2131
|
+
u32(referenceSampleInfo.duration),
|
|
2132
|
+
// Default sample duration
|
|
2133
|
+
u32(referenceSampleInfo.size),
|
|
2134
|
+
// Default sample size
|
|
2135
|
+
u32(referenceSampleInfo.flags)
|
|
2136
|
+
// Default sample flags
|
|
2137
|
+
]);
|
|
2138
|
+
};
|
|
2139
|
+
var tfdt = (track) => {
|
|
2140
|
+
return fullBox("tfdt", 1, 0, [
|
|
2141
|
+
u64(intoTimescale(track.currentChunk.startTimestamp, track.timescale))
|
|
2142
|
+
// Base Media Decode Time
|
|
2143
|
+
]);
|
|
2144
|
+
};
|
|
2145
|
+
var trun = (track) => {
|
|
2146
|
+
let allSampleDurations = track.currentChunk.samples.map((x) => x.timescaleUnitsToNextSample);
|
|
2147
|
+
let allSampleSizes = track.currentChunk.samples.map((x) => x.size);
|
|
2148
|
+
let allSampleFlags = track.currentChunk.samples.map(fragmentSampleFlags);
|
|
2149
|
+
let allSampleCompositionTimeOffsets = track.currentChunk.samples.map((x) => intoTimescale(x.presentationTimestamp - x.decodeTimestamp, track.timescale));
|
|
2150
|
+
let uniqueSampleDurations = new Set(allSampleDurations);
|
|
2151
|
+
let uniqueSampleSizes = new Set(allSampleSizes);
|
|
2152
|
+
let uniqueSampleFlags = new Set(allSampleFlags);
|
|
2153
|
+
let uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets);
|
|
2154
|
+
let firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1];
|
|
2155
|
+
let sampleDurationPresent = uniqueSampleDurations.size > 1;
|
|
2156
|
+
let sampleSizePresent = uniqueSampleSizes.size > 1;
|
|
2157
|
+
let sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1;
|
|
2158
|
+
let sampleCompositionTimeOffsetsPresent = uniqueSampleCompositionTimeOffsets.size > 1 || [...uniqueSampleCompositionTimeOffsets].some((x) => x !== 0);
|
|
2159
|
+
let flags = 0;
|
|
2160
|
+
flags |= 1;
|
|
2161
|
+
flags |= 4 * +firstSampleFlagsPresent;
|
|
2162
|
+
flags |= 256 * +sampleDurationPresent;
|
|
2163
|
+
flags |= 512 * +sampleSizePresent;
|
|
2164
|
+
flags |= 1024 * +sampleFlagsPresent;
|
|
2165
|
+
flags |= 2048 * +sampleCompositionTimeOffsetsPresent;
|
|
2166
|
+
return fullBox("trun", 1, flags, [
|
|
2167
|
+
u32(track.currentChunk.samples.length),
|
|
2168
|
+
// Sample count
|
|
2169
|
+
u32(track.currentChunk.offset - track.currentChunk.moofOffset || 0),
|
|
2170
|
+
// Data offset
|
|
2171
|
+
firstSampleFlagsPresent ? u32(allSampleFlags[0]) : [],
|
|
2172
|
+
track.currentChunk.samples.map((_, i) => [
|
|
2173
|
+
sampleDurationPresent ? u32(allSampleDurations[i]) : [],
|
|
2174
|
+
// Sample duration
|
|
2175
|
+
sampleSizePresent ? u32(allSampleSizes[i]) : [],
|
|
2176
|
+
// Sample size
|
|
2177
|
+
sampleFlagsPresent ? u32(allSampleFlags[i]) : [],
|
|
2178
|
+
// Sample flags
|
|
2179
|
+
// Sample composition time offsets
|
|
2180
|
+
sampleCompositionTimeOffsetsPresent ? i32(allSampleCompositionTimeOffsets[i]) : []
|
|
2181
|
+
])
|
|
2182
|
+
]);
|
|
2183
|
+
};
|
|
2184
|
+
var mfra = (tracks) => {
|
|
2185
|
+
return box("mfra", null, [
|
|
2186
|
+
...tracks.map(tfra),
|
|
2187
|
+
mfro()
|
|
2188
|
+
]);
|
|
2189
|
+
};
|
|
2190
|
+
var tfra = (track, trackIndex) => {
|
|
2191
|
+
let version = 1;
|
|
2192
|
+
return fullBox("tfra", version, 0, [
|
|
2193
|
+
u32(track.id),
|
|
2194
|
+
// Track ID
|
|
2195
|
+
u32(63),
|
|
2196
|
+
// This specifies that traf number, trun number and sample number are 32-bit ints
|
|
2197
|
+
u32(track.finalizedChunks.length),
|
|
2198
|
+
// Number of entries
|
|
2199
|
+
track.finalizedChunks.map((chunk) => [
|
|
2200
|
+
u64(intoTimescale(chunk.startTimestamp, track.timescale)),
|
|
2201
|
+
// Time
|
|
2202
|
+
u64(chunk.moofOffset),
|
|
2203
|
+
// moof offset
|
|
2204
|
+
u32(trackIndex + 1),
|
|
2205
|
+
// traf number
|
|
2206
|
+
u32(1),
|
|
2207
|
+
// trun number
|
|
2208
|
+
u32(1)
|
|
2209
|
+
// Sample number
|
|
2210
|
+
])
|
|
2211
|
+
]);
|
|
2212
|
+
};
|
|
2213
|
+
var mfro = () => {
|
|
2214
|
+
return fullBox("mfro", 0, 0, [
|
|
2215
|
+
// This value needs to be overwritten manually from the outside, where the actual size of the enclosing mfra box
|
|
2216
|
+
// is known
|
|
2217
|
+
u32(0)
|
|
2218
|
+
// Size
|
|
2219
|
+
]);
|
|
2220
|
+
};
|
|
2221
|
+
var VIDEO_CODEC_TO_BOX_NAME = {
|
|
2222
|
+
"avc": "avc1",
|
|
2223
|
+
"hevc": "hvc1",
|
|
2224
|
+
"vp9": "vp09",
|
|
2225
|
+
"av1": "av01"
|
|
2226
|
+
};
|
|
2227
|
+
var VIDEO_CODEC_TO_CONFIGURATION_BOX = {
|
|
2228
|
+
"avc": avcC,
|
|
2229
|
+
"hevc": hvcC,
|
|
2230
|
+
"vp9": vpcC,
|
|
2231
|
+
"av1": av1C
|
|
2232
|
+
};
|
|
2233
|
+
var AUDIO_CODEC_TO_BOX_NAME = {
|
|
2234
|
+
"aac": "mp4a",
|
|
2235
|
+
"opus": "Opus"
|
|
2236
|
+
};
|
|
2237
|
+
var AUDIO_CODEC_TO_CONFIGURATION_BOX = {
|
|
2238
|
+
"aac": esds,
|
|
2239
|
+
"opus": dOps
|
|
2240
|
+
};
|
|
2241
|
+
var Target = class {
|
|
2242
|
+
};
|
|
2243
|
+
var ArrayBufferTarget = class extends Target {
|
|
2244
|
+
constructor() {
|
|
2245
|
+
super(...arguments);
|
|
2246
|
+
this.buffer = null;
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
var StreamTarget = class extends Target {
|
|
2250
|
+
constructor(options) {
|
|
2251
|
+
super();
|
|
2252
|
+
this.options = options;
|
|
2253
|
+
if (typeof options !== "object") {
|
|
2254
|
+
throw new TypeError("StreamTarget requires an options object to be passed to its constructor.");
|
|
2255
|
+
}
|
|
2256
|
+
if (options.onData) {
|
|
2257
|
+
if (typeof options.onData !== "function") {
|
|
2258
|
+
throw new TypeError("options.onData, when provided, must be a function.");
|
|
2259
|
+
}
|
|
2260
|
+
if (options.onData.length < 2) {
|
|
2261
|
+
throw new TypeError(
|
|
2262
|
+
"options.onData, when provided, must be a function that takes in at least two arguments (data and position). Ignoring the position argument, which specifies the byte offset at which the data is to be written, can lead to broken outputs."
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
if (options.chunked !== void 0 && typeof options.chunked !== "boolean") {
|
|
2267
|
+
throw new TypeError("options.chunked, when provided, must be a boolean.");
|
|
2268
|
+
}
|
|
2269
|
+
if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize < 1024)) {
|
|
2270
|
+
throw new TypeError("options.chunkSize, when provided, must be an integer and not smaller than 1024.");
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
var FileSystemWritableFileStreamTarget = class extends Target {
|
|
2275
|
+
constructor(stream, options) {
|
|
2276
|
+
super();
|
|
2277
|
+
this.stream = stream;
|
|
2278
|
+
this.options = options;
|
|
2279
|
+
if (!(stream instanceof FileSystemWritableFileStream)) {
|
|
2280
|
+
throw new TypeError("FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance.");
|
|
2281
|
+
}
|
|
2282
|
+
if (options !== void 0 && typeof options !== "object") {
|
|
2283
|
+
throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object.");
|
|
2284
|
+
}
|
|
2285
|
+
if (options) {
|
|
2286
|
+
if (options.chunkSize !== void 0 && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) {
|
|
2287
|
+
throw new TypeError("options.chunkSize, when provided, must be a positive integer");
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
var _helper;
|
|
2293
|
+
var _helperView;
|
|
2294
|
+
var Writer = class {
|
|
2295
|
+
constructor() {
|
|
2296
|
+
this.pos = 0;
|
|
2297
|
+
__privateAdd(this, _helper, new Uint8Array(8));
|
|
2298
|
+
__privateAdd(this, _helperView, new DataView(__privateGet(this, _helper).buffer));
|
|
2299
|
+
this.offsets = /* @__PURE__ */ new WeakMap();
|
|
2300
|
+
}
|
|
2301
|
+
/** Sets the current position for future writes to a new one. */
|
|
2302
|
+
seek(newPos) {
|
|
2303
|
+
this.pos = newPos;
|
|
2304
|
+
}
|
|
2305
|
+
writeU32(value) {
|
|
2306
|
+
__privateGet(this, _helperView).setUint32(0, value, false);
|
|
2307
|
+
this.write(__privateGet(this, _helper).subarray(0, 4));
|
|
2308
|
+
}
|
|
2309
|
+
writeU64(value) {
|
|
2310
|
+
__privateGet(this, _helperView).setUint32(0, Math.floor(value / 2 ** 32), false);
|
|
2311
|
+
__privateGet(this, _helperView).setUint32(4, value, false);
|
|
2312
|
+
this.write(__privateGet(this, _helper).subarray(0, 8));
|
|
2313
|
+
}
|
|
2314
|
+
writeAscii(text) {
|
|
2315
|
+
for (let i = 0; i < text.length; i++) {
|
|
2316
|
+
__privateGet(this, _helperView).setUint8(i % 8, text.charCodeAt(i));
|
|
2317
|
+
if (i % 8 === 7)
|
|
2318
|
+
this.write(__privateGet(this, _helper));
|
|
2319
|
+
}
|
|
2320
|
+
if (text.length % 8 !== 0) {
|
|
2321
|
+
this.write(__privateGet(this, _helper).subarray(0, text.length % 8));
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
writeBox(box2) {
|
|
2325
|
+
this.offsets.set(box2, this.pos);
|
|
2326
|
+
if (box2.contents && !box2.children) {
|
|
2327
|
+
this.writeBoxHeader(box2, box2.size ?? box2.contents.byteLength + 8);
|
|
2328
|
+
this.write(box2.contents);
|
|
2329
|
+
} else {
|
|
2330
|
+
let startPos = this.pos;
|
|
2331
|
+
this.writeBoxHeader(box2, 0);
|
|
2332
|
+
if (box2.contents)
|
|
2333
|
+
this.write(box2.contents);
|
|
2334
|
+
if (box2.children) {
|
|
2335
|
+
for (let child of box2.children)
|
|
2336
|
+
if (child)
|
|
2337
|
+
this.writeBox(child);
|
|
2338
|
+
}
|
|
2339
|
+
let endPos = this.pos;
|
|
2340
|
+
let size = box2.size ?? endPos - startPos;
|
|
2341
|
+
this.seek(startPos);
|
|
2342
|
+
this.writeBoxHeader(box2, size);
|
|
2343
|
+
this.seek(endPos);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
writeBoxHeader(box2, size) {
|
|
2347
|
+
this.writeU32(box2.largeSize ? 1 : size);
|
|
2348
|
+
this.writeAscii(box2.type);
|
|
2349
|
+
if (box2.largeSize)
|
|
2350
|
+
this.writeU64(size);
|
|
2351
|
+
}
|
|
2352
|
+
measureBoxHeader(box2) {
|
|
2353
|
+
return 8 + (box2.largeSize ? 8 : 0);
|
|
2354
|
+
}
|
|
2355
|
+
patchBox(box2) {
|
|
2356
|
+
let endPos = this.pos;
|
|
2357
|
+
this.seek(this.offsets.get(box2));
|
|
2358
|
+
this.writeBox(box2);
|
|
2359
|
+
this.seek(endPos);
|
|
2360
|
+
}
|
|
2361
|
+
measureBox(box2) {
|
|
2362
|
+
if (box2.contents && !box2.children) {
|
|
2363
|
+
let headerSize = this.measureBoxHeader(box2);
|
|
2364
|
+
return headerSize + box2.contents.byteLength;
|
|
2365
|
+
} else {
|
|
2366
|
+
let result = this.measureBoxHeader(box2);
|
|
2367
|
+
if (box2.contents)
|
|
2368
|
+
result += box2.contents.byteLength;
|
|
2369
|
+
if (box2.children) {
|
|
2370
|
+
for (let child of box2.children)
|
|
2371
|
+
if (child)
|
|
2372
|
+
result += this.measureBox(child);
|
|
2373
|
+
}
|
|
2374
|
+
return result;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
};
|
|
2378
|
+
_helper = /* @__PURE__ */ new WeakMap();
|
|
2379
|
+
_helperView = /* @__PURE__ */ new WeakMap();
|
|
2380
|
+
var _target;
|
|
2381
|
+
var _buffer;
|
|
2382
|
+
var _bytes;
|
|
2383
|
+
var _maxPos;
|
|
2384
|
+
var _ensureSize;
|
|
2385
|
+
var ensureSize_fn;
|
|
2386
|
+
var ArrayBufferTargetWriter = class extends Writer {
|
|
2387
|
+
constructor(target) {
|
|
2388
|
+
super();
|
|
2389
|
+
__privateAdd(this, _ensureSize);
|
|
2390
|
+
__privateAdd(this, _target, void 0);
|
|
2391
|
+
__privateAdd(this, _buffer, new ArrayBuffer(2 ** 16));
|
|
2392
|
+
__privateAdd(this, _bytes, new Uint8Array(__privateGet(this, _buffer)));
|
|
2393
|
+
__privateAdd(this, _maxPos, 0);
|
|
2394
|
+
__privateSet(this, _target, target);
|
|
2395
|
+
}
|
|
2396
|
+
write(data) {
|
|
2397
|
+
__privateMethod(this, _ensureSize, ensureSize_fn).call(this, this.pos + data.byteLength);
|
|
2398
|
+
__privateGet(this, _bytes).set(data, this.pos);
|
|
2399
|
+
this.pos += data.byteLength;
|
|
2400
|
+
__privateSet(this, _maxPos, Math.max(__privateGet(this, _maxPos), this.pos));
|
|
2401
|
+
}
|
|
2402
|
+
finalize() {
|
|
2403
|
+
__privateMethod(this, _ensureSize, ensureSize_fn).call(this, this.pos);
|
|
2404
|
+
__privateGet(this, _target).buffer = __privateGet(this, _buffer).slice(0, Math.max(__privateGet(this, _maxPos), this.pos));
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
_target = /* @__PURE__ */ new WeakMap();
|
|
2408
|
+
_buffer = /* @__PURE__ */ new WeakMap();
|
|
2409
|
+
_bytes = /* @__PURE__ */ new WeakMap();
|
|
2410
|
+
_maxPos = /* @__PURE__ */ new WeakMap();
|
|
2411
|
+
_ensureSize = /* @__PURE__ */ new WeakSet();
|
|
2412
|
+
ensureSize_fn = function(size) {
|
|
2413
|
+
let newLength = __privateGet(this, _buffer).byteLength;
|
|
2414
|
+
while (newLength < size)
|
|
2415
|
+
newLength *= 2;
|
|
2416
|
+
if (newLength === __privateGet(this, _buffer).byteLength)
|
|
2417
|
+
return;
|
|
2418
|
+
let newBuffer = new ArrayBuffer(newLength);
|
|
2419
|
+
let newBytes = new Uint8Array(newBuffer);
|
|
2420
|
+
newBytes.set(__privateGet(this, _bytes), 0);
|
|
2421
|
+
__privateSet(this, _buffer, newBuffer);
|
|
2422
|
+
__privateSet(this, _bytes, newBytes);
|
|
2423
|
+
};
|
|
2424
|
+
var DEFAULT_CHUNK_SIZE = 2 ** 24;
|
|
2425
|
+
var MAX_CHUNKS_AT_ONCE = 2;
|
|
2426
|
+
var _target2;
|
|
2427
|
+
var _sections;
|
|
2428
|
+
var _chunked;
|
|
2429
|
+
var _chunkSize;
|
|
2430
|
+
var _chunks;
|
|
2431
|
+
var _writeDataIntoChunks;
|
|
2432
|
+
var writeDataIntoChunks_fn;
|
|
2433
|
+
var _insertSectionIntoChunk;
|
|
2434
|
+
var insertSectionIntoChunk_fn;
|
|
2435
|
+
var _createChunk;
|
|
2436
|
+
var createChunk_fn;
|
|
2437
|
+
var _flushChunks;
|
|
2438
|
+
var flushChunks_fn;
|
|
2439
|
+
var StreamTargetWriter = class extends Writer {
|
|
2440
|
+
constructor(target) {
|
|
2441
|
+
super();
|
|
2442
|
+
__privateAdd(this, _writeDataIntoChunks);
|
|
2443
|
+
__privateAdd(this, _insertSectionIntoChunk);
|
|
2444
|
+
__privateAdd(this, _createChunk);
|
|
2445
|
+
__privateAdd(this, _flushChunks);
|
|
2446
|
+
__privateAdd(this, _target2, void 0);
|
|
2447
|
+
__privateAdd(this, _sections, []);
|
|
2448
|
+
__privateAdd(this, _chunked, void 0);
|
|
2449
|
+
__privateAdd(this, _chunkSize, void 0);
|
|
2450
|
+
__privateAdd(this, _chunks, []);
|
|
2451
|
+
__privateSet(this, _target2, target);
|
|
2452
|
+
__privateSet(this, _chunked, target.options?.chunked ?? false);
|
|
2453
|
+
__privateSet(this, _chunkSize, target.options?.chunkSize ?? DEFAULT_CHUNK_SIZE);
|
|
2454
|
+
}
|
|
2455
|
+
write(data) {
|
|
2456
|
+
__privateGet(this, _sections).push({
|
|
2457
|
+
data: data.slice(),
|
|
2458
|
+
start: this.pos
|
|
2459
|
+
});
|
|
2460
|
+
this.pos += data.byteLength;
|
|
2461
|
+
}
|
|
2462
|
+
flush() {
|
|
2463
|
+
if (__privateGet(this, _sections).length === 0)
|
|
2464
|
+
return;
|
|
2465
|
+
let chunks = [];
|
|
2466
|
+
let sorted = [...__privateGet(this, _sections)].sort((a, b) => a.start - b.start);
|
|
2467
|
+
chunks.push({
|
|
2468
|
+
start: sorted[0].start,
|
|
2469
|
+
size: sorted[0].data.byteLength
|
|
2470
|
+
});
|
|
2471
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
2472
|
+
let lastChunk = chunks[chunks.length - 1];
|
|
2473
|
+
let section = sorted[i];
|
|
2474
|
+
if (section.start <= lastChunk.start + lastChunk.size) {
|
|
2475
|
+
lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start);
|
|
2476
|
+
} else {
|
|
2477
|
+
chunks.push({
|
|
2478
|
+
start: section.start,
|
|
2479
|
+
size: section.data.byteLength
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
for (let chunk of chunks) {
|
|
2484
|
+
chunk.data = new Uint8Array(chunk.size);
|
|
2485
|
+
for (let section of __privateGet(this, _sections)) {
|
|
2486
|
+
if (chunk.start <= section.start && section.start < chunk.start + chunk.size) {
|
|
2487
|
+
chunk.data.set(section.data, section.start - chunk.start);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
if (__privateGet(this, _chunked)) {
|
|
2491
|
+
__privateMethod(this, _writeDataIntoChunks, writeDataIntoChunks_fn).call(this, chunk.data, chunk.start);
|
|
2492
|
+
__privateMethod(this, _flushChunks, flushChunks_fn).call(this);
|
|
2493
|
+
} else {
|
|
2494
|
+
__privateGet(this, _target2).options.onData?.(chunk.data, chunk.start);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
__privateGet(this, _sections).length = 0;
|
|
2498
|
+
}
|
|
2499
|
+
finalize() {
|
|
2500
|
+
if (__privateGet(this, _chunked)) {
|
|
2501
|
+
__privateMethod(this, _flushChunks, flushChunks_fn).call(this, true);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
};
|
|
2505
|
+
_target2 = /* @__PURE__ */ new WeakMap();
|
|
2506
|
+
_sections = /* @__PURE__ */ new WeakMap();
|
|
2507
|
+
_chunked = /* @__PURE__ */ new WeakMap();
|
|
2508
|
+
_chunkSize = /* @__PURE__ */ new WeakMap();
|
|
2509
|
+
_chunks = /* @__PURE__ */ new WeakMap();
|
|
2510
|
+
_writeDataIntoChunks = /* @__PURE__ */ new WeakSet();
|
|
2511
|
+
writeDataIntoChunks_fn = function(data, position) {
|
|
2512
|
+
let chunkIndex = __privateGet(this, _chunks).findIndex((x) => x.start <= position && position < x.start + __privateGet(this, _chunkSize));
|
|
2513
|
+
if (chunkIndex === -1)
|
|
2514
|
+
chunkIndex = __privateMethod(this, _createChunk, createChunk_fn).call(this, position);
|
|
2515
|
+
let chunk = __privateGet(this, _chunks)[chunkIndex];
|
|
2516
|
+
let relativePosition = position - chunk.start;
|
|
2517
|
+
let toWrite = data.subarray(0, Math.min(__privateGet(this, _chunkSize) - relativePosition, data.byteLength));
|
|
2518
|
+
chunk.data.set(toWrite, relativePosition);
|
|
2519
|
+
let section = {
|
|
2520
|
+
start: relativePosition,
|
|
2521
|
+
end: relativePosition + toWrite.byteLength
|
|
2522
|
+
};
|
|
2523
|
+
__privateMethod(this, _insertSectionIntoChunk, insertSectionIntoChunk_fn).call(this, chunk, section);
|
|
2524
|
+
if (chunk.written[0].start === 0 && chunk.written[0].end === __privateGet(this, _chunkSize)) {
|
|
2525
|
+
chunk.shouldFlush = true;
|
|
2526
|
+
}
|
|
2527
|
+
if (__privateGet(this, _chunks).length > MAX_CHUNKS_AT_ONCE) {
|
|
2528
|
+
for (let i = 0; i < __privateGet(this, _chunks).length - 1; i++) {
|
|
2529
|
+
__privateGet(this, _chunks)[i].shouldFlush = true;
|
|
2530
|
+
}
|
|
2531
|
+
__privateMethod(this, _flushChunks, flushChunks_fn).call(this);
|
|
2532
|
+
}
|
|
2533
|
+
if (toWrite.byteLength < data.byteLength) {
|
|
2534
|
+
__privateMethod(this, _writeDataIntoChunks, writeDataIntoChunks_fn).call(this, data.subarray(toWrite.byteLength), position + toWrite.byteLength);
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
_insertSectionIntoChunk = /* @__PURE__ */ new WeakSet();
|
|
2538
|
+
insertSectionIntoChunk_fn = function(chunk, section) {
|
|
2539
|
+
let low = 0;
|
|
2540
|
+
let high = chunk.written.length - 1;
|
|
2541
|
+
let index = -1;
|
|
2542
|
+
while (low <= high) {
|
|
2543
|
+
let mid = Math.floor(low + (high - low + 1) / 2);
|
|
2544
|
+
if (chunk.written[mid].start <= section.start) {
|
|
2545
|
+
low = mid + 1;
|
|
2546
|
+
index = mid;
|
|
2547
|
+
} else {
|
|
2548
|
+
high = mid - 1;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
chunk.written.splice(index + 1, 0, section);
|
|
2552
|
+
if (index === -1 || chunk.written[index].end < section.start)
|
|
2553
|
+
index++;
|
|
2554
|
+
while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) {
|
|
2555
|
+
chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end);
|
|
2556
|
+
chunk.written.splice(index + 1, 1);
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
_createChunk = /* @__PURE__ */ new WeakSet();
|
|
2560
|
+
createChunk_fn = function(includesPosition) {
|
|
2561
|
+
let start = Math.floor(includesPosition / __privateGet(this, _chunkSize)) * __privateGet(this, _chunkSize);
|
|
2562
|
+
let chunk = {
|
|
2563
|
+
start,
|
|
2564
|
+
data: new Uint8Array(__privateGet(this, _chunkSize)),
|
|
2565
|
+
written: [],
|
|
2566
|
+
shouldFlush: false
|
|
2567
|
+
};
|
|
2568
|
+
__privateGet(this, _chunks).push(chunk);
|
|
2569
|
+
__privateGet(this, _chunks).sort((a, b) => a.start - b.start);
|
|
2570
|
+
return __privateGet(this, _chunks).indexOf(chunk);
|
|
2571
|
+
};
|
|
2572
|
+
_flushChunks = /* @__PURE__ */ new WeakSet();
|
|
2573
|
+
flushChunks_fn = function(force = false) {
|
|
2574
|
+
for (let i = 0; i < __privateGet(this, _chunks).length; i++) {
|
|
2575
|
+
let chunk = __privateGet(this, _chunks)[i];
|
|
2576
|
+
if (!chunk.shouldFlush && !force)
|
|
2577
|
+
continue;
|
|
2578
|
+
for (let section of chunk.written) {
|
|
2579
|
+
__privateGet(this, _target2).options.onData?.(
|
|
2580
|
+
chunk.data.subarray(section.start, section.end),
|
|
2581
|
+
chunk.start + section.start
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
__privateGet(this, _chunks).splice(i--, 1);
|
|
2585
|
+
}
|
|
2586
|
+
};
|
|
2587
|
+
var FileSystemWritableFileStreamTargetWriter = class extends StreamTargetWriter {
|
|
2588
|
+
constructor(target) {
|
|
2589
|
+
super(new StreamTarget({
|
|
2590
|
+
onData: (data, position) => target.stream.write({
|
|
2591
|
+
type: "write",
|
|
2592
|
+
data,
|
|
2593
|
+
position
|
|
2594
|
+
}),
|
|
2595
|
+
chunked: true,
|
|
2596
|
+
chunkSize: target.options?.chunkSize
|
|
2597
|
+
}));
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
var GLOBAL_TIMESCALE = 1e3;
|
|
2601
|
+
var SUPPORTED_VIDEO_CODECS = ["avc", "hevc", "vp9", "av1"];
|
|
2602
|
+
var SUPPORTED_AUDIO_CODECS = ["aac", "opus"];
|
|
2603
|
+
var TIMESTAMP_OFFSET = 2082844800;
|
|
2604
|
+
var FIRST_TIMESTAMP_BEHAVIORS = ["strict", "offset", "cross-track-offset"];
|
|
2605
|
+
var _options;
|
|
2606
|
+
var _writer;
|
|
2607
|
+
var _ftypSize;
|
|
2608
|
+
var _mdat;
|
|
2609
|
+
var _videoTrack;
|
|
2610
|
+
var _audioTrack;
|
|
2611
|
+
var _creationTime;
|
|
2612
|
+
var _finalizedChunks;
|
|
2613
|
+
var _nextFragmentNumber;
|
|
2614
|
+
var _videoSampleQueue;
|
|
2615
|
+
var _audioSampleQueue;
|
|
2616
|
+
var _finalized;
|
|
2617
|
+
var _validateOptions;
|
|
2618
|
+
var validateOptions_fn;
|
|
2619
|
+
var _writeHeader;
|
|
2620
|
+
var writeHeader_fn;
|
|
2621
|
+
var _computeMoovSizeUpperBound;
|
|
2622
|
+
var computeMoovSizeUpperBound_fn;
|
|
2623
|
+
var _prepareTracks;
|
|
2624
|
+
var prepareTracks_fn;
|
|
2625
|
+
var _generateMpeg4AudioSpecificConfig;
|
|
2626
|
+
var generateMpeg4AudioSpecificConfig_fn;
|
|
2627
|
+
var _createSampleForTrack;
|
|
2628
|
+
var createSampleForTrack_fn;
|
|
2629
|
+
var _addSampleToTrack;
|
|
2630
|
+
var addSampleToTrack_fn;
|
|
2631
|
+
var _validateTimestamp;
|
|
2632
|
+
var validateTimestamp_fn;
|
|
2633
|
+
var _finalizeCurrentChunk;
|
|
2634
|
+
var finalizeCurrentChunk_fn;
|
|
2635
|
+
var _finalizeFragment;
|
|
2636
|
+
var finalizeFragment_fn;
|
|
2637
|
+
var _maybeFlushStreamingTargetWriter;
|
|
2638
|
+
var maybeFlushStreamingTargetWriter_fn;
|
|
2639
|
+
var _ensureNotFinalized;
|
|
2640
|
+
var ensureNotFinalized_fn;
|
|
2641
|
+
var Muxer = class {
|
|
2642
|
+
constructor(options) {
|
|
2643
|
+
__privateAdd(this, _validateOptions);
|
|
2644
|
+
__privateAdd(this, _writeHeader);
|
|
2645
|
+
__privateAdd(this, _computeMoovSizeUpperBound);
|
|
2646
|
+
__privateAdd(this, _prepareTracks);
|
|
2647
|
+
__privateAdd(this, _generateMpeg4AudioSpecificConfig);
|
|
2648
|
+
__privateAdd(this, _createSampleForTrack);
|
|
2649
|
+
__privateAdd(this, _addSampleToTrack);
|
|
2650
|
+
__privateAdd(this, _validateTimestamp);
|
|
2651
|
+
__privateAdd(this, _finalizeCurrentChunk);
|
|
2652
|
+
__privateAdd(this, _finalizeFragment);
|
|
2653
|
+
__privateAdd(this, _maybeFlushStreamingTargetWriter);
|
|
2654
|
+
__privateAdd(this, _ensureNotFinalized);
|
|
2655
|
+
__privateAdd(this, _options, void 0);
|
|
2656
|
+
__privateAdd(this, _writer, void 0);
|
|
2657
|
+
__privateAdd(this, _ftypSize, void 0);
|
|
2658
|
+
__privateAdd(this, _mdat, void 0);
|
|
2659
|
+
__privateAdd(this, _videoTrack, null);
|
|
2660
|
+
__privateAdd(this, _audioTrack, null);
|
|
2661
|
+
__privateAdd(this, _creationTime, Math.floor(Date.now() / 1e3) + TIMESTAMP_OFFSET);
|
|
2662
|
+
__privateAdd(this, _finalizedChunks, []);
|
|
2663
|
+
__privateAdd(this, _nextFragmentNumber, 1);
|
|
2664
|
+
__privateAdd(this, _videoSampleQueue, []);
|
|
2665
|
+
__privateAdd(this, _audioSampleQueue, []);
|
|
2666
|
+
__privateAdd(this, _finalized, false);
|
|
2667
|
+
__privateMethod(this, _validateOptions, validateOptions_fn).call(this, options);
|
|
2668
|
+
options.video = deepClone(options.video);
|
|
2669
|
+
options.audio = deepClone(options.audio);
|
|
2670
|
+
options.fastStart = deepClone(options.fastStart);
|
|
2671
|
+
this.target = options.target;
|
|
2672
|
+
__privateSet(this, _options, {
|
|
2673
|
+
firstTimestampBehavior: "strict",
|
|
2674
|
+
...options
|
|
2675
|
+
});
|
|
2676
|
+
if (options.target instanceof ArrayBufferTarget) {
|
|
2677
|
+
__privateSet(this, _writer, new ArrayBufferTargetWriter(options.target));
|
|
2678
|
+
} else if (options.target instanceof StreamTarget) {
|
|
2679
|
+
__privateSet(this, _writer, new StreamTargetWriter(options.target));
|
|
2680
|
+
} else if (options.target instanceof FileSystemWritableFileStreamTarget) {
|
|
2681
|
+
__privateSet(this, _writer, new FileSystemWritableFileStreamTargetWriter(options.target));
|
|
2682
|
+
} else {
|
|
2683
|
+
throw new Error(`Invalid target: ${options.target}`);
|
|
2684
|
+
}
|
|
2685
|
+
__privateMethod(this, _prepareTracks, prepareTracks_fn).call(this);
|
|
2686
|
+
__privateMethod(this, _writeHeader, writeHeader_fn).call(this);
|
|
2687
|
+
}
|
|
2688
|
+
addVideoChunk(sample, meta, timestamp, compositionTimeOffset) {
|
|
2689
|
+
if (!(sample instanceof EncodedVideoChunk)) {
|
|
2690
|
+
throw new TypeError("addVideoChunk's first argument (sample) must be of type EncodedVideoChunk.");
|
|
2691
|
+
}
|
|
2692
|
+
if (meta && typeof meta !== "object") {
|
|
2693
|
+
throw new TypeError("addVideoChunk's second argument (meta), when provided, must be an object.");
|
|
2694
|
+
}
|
|
2695
|
+
if (timestamp !== void 0 && (!Number.isFinite(timestamp) || timestamp < 0)) {
|
|
2696
|
+
throw new TypeError(
|
|
2697
|
+
"addVideoChunk's third argument (timestamp), when provided, must be a non-negative real number."
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
if (compositionTimeOffset !== void 0 && !Number.isFinite(compositionTimeOffset)) {
|
|
2701
|
+
throw new TypeError(
|
|
2702
|
+
"addVideoChunk's fourth argument (compositionTimeOffset), when provided, must be a real number."
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
let data = new Uint8Array(sample.byteLength);
|
|
2706
|
+
sample.copyTo(data);
|
|
2707
|
+
this.addVideoChunkRaw(
|
|
2708
|
+
data,
|
|
2709
|
+
sample.type,
|
|
2710
|
+
timestamp ?? sample.timestamp,
|
|
2711
|
+
sample.duration,
|
|
2712
|
+
meta,
|
|
2713
|
+
compositionTimeOffset
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
addVideoChunkRaw(data, type, timestamp, duration, meta, compositionTimeOffset) {
|
|
2717
|
+
if (!(data instanceof Uint8Array)) {
|
|
2718
|
+
throw new TypeError("addVideoChunkRaw's first argument (data) must be an instance of Uint8Array.");
|
|
2719
|
+
}
|
|
2720
|
+
if (type !== "key" && type !== "delta") {
|
|
2721
|
+
throw new TypeError("addVideoChunkRaw's second argument (type) must be either 'key' or 'delta'.");
|
|
2722
|
+
}
|
|
2723
|
+
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
2724
|
+
throw new TypeError("addVideoChunkRaw's third argument (timestamp) must be a non-negative real number.");
|
|
2725
|
+
}
|
|
2726
|
+
if (!Number.isFinite(duration) || duration < 0) {
|
|
2727
|
+
throw new TypeError("addVideoChunkRaw's fourth argument (duration) must be a non-negative real number.");
|
|
2728
|
+
}
|
|
2729
|
+
if (meta && typeof meta !== "object") {
|
|
2730
|
+
throw new TypeError("addVideoChunkRaw's fifth argument (meta), when provided, must be an object.");
|
|
2731
|
+
}
|
|
2732
|
+
if (compositionTimeOffset !== void 0 && !Number.isFinite(compositionTimeOffset)) {
|
|
2733
|
+
throw new TypeError(
|
|
2734
|
+
"addVideoChunkRaw's sixth argument (compositionTimeOffset), when provided, must be a real number."
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
__privateMethod(this, _ensureNotFinalized, ensureNotFinalized_fn).call(this);
|
|
2738
|
+
if (!__privateGet(this, _options).video)
|
|
2739
|
+
throw new Error("No video track declared.");
|
|
2740
|
+
if (typeof __privateGet(this, _options).fastStart === "object" && __privateGet(this, _videoTrack).samples.length === __privateGet(this, _options).fastStart.expectedVideoChunks) {
|
|
2741
|
+
throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${__privateGet(this, _options).fastStart.expectedVideoChunks}).`);
|
|
2742
|
+
}
|
|
2743
|
+
let videoSample = __privateMethod(this, _createSampleForTrack, createSampleForTrack_fn).call(this, __privateGet(this, _videoTrack), data, type, timestamp, duration, meta, compositionTimeOffset);
|
|
2744
|
+
if (__privateGet(this, _options).fastStart === "fragmented" && __privateGet(this, _audioTrack)) {
|
|
2745
|
+
while (__privateGet(this, _audioSampleQueue).length > 0 && __privateGet(this, _audioSampleQueue)[0].decodeTimestamp <= videoSample.decodeTimestamp) {
|
|
2746
|
+
let audioSample = __privateGet(this, _audioSampleQueue).shift();
|
|
2747
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _audioTrack), audioSample);
|
|
2748
|
+
}
|
|
2749
|
+
if (videoSample.decodeTimestamp <= __privateGet(this, _audioTrack).lastDecodeTimestamp) {
|
|
2750
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _videoTrack), videoSample);
|
|
2751
|
+
} else {
|
|
2752
|
+
__privateGet(this, _videoSampleQueue).push(videoSample);
|
|
2753
|
+
}
|
|
2754
|
+
} else {
|
|
2755
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _videoTrack), videoSample);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
addAudioChunk(sample, meta, timestamp) {
|
|
2759
|
+
if (!(sample instanceof EncodedAudioChunk)) {
|
|
2760
|
+
throw new TypeError("addAudioChunk's first argument (sample) must be of type EncodedAudioChunk.");
|
|
2761
|
+
}
|
|
2762
|
+
if (meta && typeof meta !== "object") {
|
|
2763
|
+
throw new TypeError("addAudioChunk's second argument (meta), when provided, must be an object.");
|
|
2764
|
+
}
|
|
2765
|
+
if (timestamp !== void 0 && (!Number.isFinite(timestamp) || timestamp < 0)) {
|
|
2766
|
+
throw new TypeError(
|
|
2767
|
+
"addAudioChunk's third argument (timestamp), when provided, must be a non-negative real number."
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
let data = new Uint8Array(sample.byteLength);
|
|
2771
|
+
sample.copyTo(data);
|
|
2772
|
+
this.addAudioChunkRaw(data, sample.type, timestamp ?? sample.timestamp, sample.duration, meta);
|
|
2773
|
+
}
|
|
2774
|
+
addAudioChunkRaw(data, type, timestamp, duration, meta) {
|
|
2775
|
+
if (!(data instanceof Uint8Array)) {
|
|
2776
|
+
throw new TypeError("addAudioChunkRaw's first argument (data) must be an instance of Uint8Array.");
|
|
2777
|
+
}
|
|
2778
|
+
if (type !== "key" && type !== "delta") {
|
|
2779
|
+
throw new TypeError("addAudioChunkRaw's second argument (type) must be either 'key' or 'delta'.");
|
|
2780
|
+
}
|
|
2781
|
+
if (!Number.isFinite(timestamp) || timestamp < 0) {
|
|
2782
|
+
throw new TypeError("addAudioChunkRaw's third argument (timestamp) must be a non-negative real number.");
|
|
2783
|
+
}
|
|
2784
|
+
if (!Number.isFinite(duration) || duration < 0) {
|
|
2785
|
+
throw new TypeError("addAudioChunkRaw's fourth argument (duration) must be a non-negative real number.");
|
|
2786
|
+
}
|
|
2787
|
+
if (meta && typeof meta !== "object") {
|
|
2788
|
+
throw new TypeError("addAudioChunkRaw's fifth argument (meta), when provided, must be an object.");
|
|
2789
|
+
}
|
|
2790
|
+
__privateMethod(this, _ensureNotFinalized, ensureNotFinalized_fn).call(this);
|
|
2791
|
+
if (!__privateGet(this, _options).audio)
|
|
2792
|
+
throw new Error("No audio track declared.");
|
|
2793
|
+
if (typeof __privateGet(this, _options).fastStart === "object" && __privateGet(this, _audioTrack).samples.length === __privateGet(this, _options).fastStart.expectedAudioChunks) {
|
|
2794
|
+
throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${__privateGet(this, _options).fastStart.expectedAudioChunks}).`);
|
|
2795
|
+
}
|
|
2796
|
+
let audioSample = __privateMethod(this, _createSampleForTrack, createSampleForTrack_fn).call(this, __privateGet(this, _audioTrack), data, type, timestamp, duration, meta);
|
|
2797
|
+
if (__privateGet(this, _options).fastStart === "fragmented" && __privateGet(this, _videoTrack)) {
|
|
2798
|
+
while (__privateGet(this, _videoSampleQueue).length > 0 && __privateGet(this, _videoSampleQueue)[0].decodeTimestamp <= audioSample.decodeTimestamp) {
|
|
2799
|
+
let videoSample = __privateGet(this, _videoSampleQueue).shift();
|
|
2800
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _videoTrack), videoSample);
|
|
2801
|
+
}
|
|
2802
|
+
if (audioSample.decodeTimestamp <= __privateGet(this, _videoTrack).lastDecodeTimestamp) {
|
|
2803
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _audioTrack), audioSample);
|
|
2804
|
+
} else {
|
|
2805
|
+
__privateGet(this, _audioSampleQueue).push(audioSample);
|
|
2806
|
+
}
|
|
2807
|
+
} else {
|
|
2808
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _audioTrack), audioSample);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
|
|
2812
|
+
finalize() {
|
|
2813
|
+
if (__privateGet(this, _finalized)) {
|
|
2814
|
+
throw new Error("Cannot finalize a muxer more than once.");
|
|
2815
|
+
}
|
|
2816
|
+
if (__privateGet(this, _options).fastStart === "fragmented") {
|
|
2817
|
+
for (let videoSample of __privateGet(this, _videoSampleQueue))
|
|
2818
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _videoTrack), videoSample);
|
|
2819
|
+
for (let audioSample of __privateGet(this, _audioSampleQueue))
|
|
2820
|
+
__privateMethod(this, _addSampleToTrack, addSampleToTrack_fn).call(this, __privateGet(this, _audioTrack), audioSample);
|
|
2821
|
+
__privateMethod(this, _finalizeFragment, finalizeFragment_fn).call(this, false);
|
|
2822
|
+
} else {
|
|
2823
|
+
if (__privateGet(this, _videoTrack))
|
|
2824
|
+
__privateMethod(this, _finalizeCurrentChunk, finalizeCurrentChunk_fn).call(this, __privateGet(this, _videoTrack));
|
|
2825
|
+
if (__privateGet(this, _audioTrack))
|
|
2826
|
+
__privateMethod(this, _finalizeCurrentChunk, finalizeCurrentChunk_fn).call(this, __privateGet(this, _audioTrack));
|
|
2827
|
+
}
|
|
2828
|
+
let tracks = [__privateGet(this, _videoTrack), __privateGet(this, _audioTrack)].filter(Boolean);
|
|
2829
|
+
if (__privateGet(this, _options).fastStart === "in-memory") {
|
|
2830
|
+
let mdatSize;
|
|
2831
|
+
for (let i = 0; i < 2; i++) {
|
|
2832
|
+
let movieBox2 = moov(tracks, __privateGet(this, _creationTime));
|
|
2833
|
+
let movieBoxSize = __privateGet(this, _writer).measureBox(movieBox2);
|
|
2834
|
+
mdatSize = __privateGet(this, _writer).measureBox(__privateGet(this, _mdat));
|
|
2835
|
+
let currentChunkPos = __privateGet(this, _writer).pos + movieBoxSize + mdatSize;
|
|
2836
|
+
for (let chunk of __privateGet(this, _finalizedChunks)) {
|
|
2837
|
+
chunk.offset = currentChunkPos;
|
|
2838
|
+
for (let { data } of chunk.samples) {
|
|
2839
|
+
currentChunkPos += data.byteLength;
|
|
2840
|
+
mdatSize += data.byteLength;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
if (currentChunkPos < 2 ** 32)
|
|
2844
|
+
break;
|
|
2845
|
+
if (mdatSize >= 2 ** 32)
|
|
2846
|
+
__privateGet(this, _mdat).largeSize = true;
|
|
2847
|
+
}
|
|
2848
|
+
let movieBox = moov(tracks, __privateGet(this, _creationTime));
|
|
2849
|
+
__privateGet(this, _writer).writeBox(movieBox);
|
|
2850
|
+
__privateGet(this, _mdat).size = mdatSize;
|
|
2851
|
+
__privateGet(this, _writer).writeBox(__privateGet(this, _mdat));
|
|
2852
|
+
for (let chunk of __privateGet(this, _finalizedChunks)) {
|
|
2853
|
+
for (let sample of chunk.samples) {
|
|
2854
|
+
__privateGet(this, _writer).write(sample.data);
|
|
2855
|
+
sample.data = null;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
} else if (__privateGet(this, _options).fastStart === "fragmented") {
|
|
2859
|
+
let startPos = __privateGet(this, _writer).pos;
|
|
2860
|
+
let mfraBox = mfra(tracks);
|
|
2861
|
+
__privateGet(this, _writer).writeBox(mfraBox);
|
|
2862
|
+
let mfraBoxSize = __privateGet(this, _writer).pos - startPos;
|
|
2863
|
+
__privateGet(this, _writer).seek(__privateGet(this, _writer).pos - 4);
|
|
2864
|
+
__privateGet(this, _writer).writeU32(mfraBoxSize);
|
|
2865
|
+
} else {
|
|
2866
|
+
let mdatPos = __privateGet(this, _writer).offsets.get(__privateGet(this, _mdat));
|
|
2867
|
+
let mdatSize = __privateGet(this, _writer).pos - mdatPos;
|
|
2868
|
+
__privateGet(this, _mdat).size = mdatSize;
|
|
2869
|
+
__privateGet(this, _mdat).largeSize = mdatSize >= 2 ** 32;
|
|
2870
|
+
__privateGet(this, _writer).patchBox(__privateGet(this, _mdat));
|
|
2871
|
+
let movieBox = moov(tracks, __privateGet(this, _creationTime));
|
|
2872
|
+
if (typeof __privateGet(this, _options).fastStart === "object") {
|
|
2873
|
+
__privateGet(this, _writer).seek(__privateGet(this, _ftypSize));
|
|
2874
|
+
__privateGet(this, _writer).writeBox(movieBox);
|
|
2875
|
+
let remainingBytes = mdatPos - __privateGet(this, _writer).pos;
|
|
2876
|
+
__privateGet(this, _writer).writeBox(free(remainingBytes));
|
|
2877
|
+
} else {
|
|
2878
|
+
__privateGet(this, _writer).writeBox(movieBox);
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
__privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
|
|
2882
|
+
__privateGet(this, _writer).finalize();
|
|
2883
|
+
__privateSet(this, _finalized, true);
|
|
2884
|
+
}
|
|
2885
|
+
};
|
|
2886
|
+
_options = /* @__PURE__ */ new WeakMap();
|
|
2887
|
+
_writer = /* @__PURE__ */ new WeakMap();
|
|
2888
|
+
_ftypSize = /* @__PURE__ */ new WeakMap();
|
|
2889
|
+
_mdat = /* @__PURE__ */ new WeakMap();
|
|
2890
|
+
_videoTrack = /* @__PURE__ */ new WeakMap();
|
|
2891
|
+
_audioTrack = /* @__PURE__ */ new WeakMap();
|
|
2892
|
+
_creationTime = /* @__PURE__ */ new WeakMap();
|
|
2893
|
+
_finalizedChunks = /* @__PURE__ */ new WeakMap();
|
|
2894
|
+
_nextFragmentNumber = /* @__PURE__ */ new WeakMap();
|
|
2895
|
+
_videoSampleQueue = /* @__PURE__ */ new WeakMap();
|
|
2896
|
+
_audioSampleQueue = /* @__PURE__ */ new WeakMap();
|
|
2897
|
+
_finalized = /* @__PURE__ */ new WeakMap();
|
|
2898
|
+
_validateOptions = /* @__PURE__ */ new WeakSet();
|
|
2899
|
+
validateOptions_fn = function(options) {
|
|
2900
|
+
if (typeof options !== "object") {
|
|
2901
|
+
throw new TypeError("The muxer requires an options object to be passed to its constructor.");
|
|
2902
|
+
}
|
|
2903
|
+
if (!(options.target instanceof Target)) {
|
|
2904
|
+
throw new TypeError("The target must be provided and an instance of Target.");
|
|
2905
|
+
}
|
|
2906
|
+
if (options.video) {
|
|
2907
|
+
if (!SUPPORTED_VIDEO_CODECS.includes(options.video.codec)) {
|
|
2908
|
+
throw new TypeError(`Unsupported video codec: ${options.video.codec}`);
|
|
2909
|
+
}
|
|
2910
|
+
if (!Number.isInteger(options.video.width) || options.video.width <= 0) {
|
|
2911
|
+
throw new TypeError(`Invalid video width: ${options.video.width}. Must be a positive integer.`);
|
|
2912
|
+
}
|
|
2913
|
+
if (!Number.isInteger(options.video.height) || options.video.height <= 0) {
|
|
2914
|
+
throw new TypeError(`Invalid video height: ${options.video.height}. Must be a positive integer.`);
|
|
2915
|
+
}
|
|
2916
|
+
const videoRotation = options.video.rotation;
|
|
2917
|
+
if (typeof videoRotation === "number" && ![0, 90, 180, 270].includes(videoRotation)) {
|
|
2918
|
+
throw new TypeError(`Invalid video rotation: ${videoRotation}. Has to be 0, 90, 180 or 270.`);
|
|
2919
|
+
} else if (Array.isArray(videoRotation) && (videoRotation.length !== 9 || videoRotation.some((value) => typeof value !== "number"))) {
|
|
2920
|
+
throw new TypeError(`Invalid video transformation matrix: ${videoRotation.join()}`);
|
|
2921
|
+
}
|
|
2922
|
+
if (options.video.frameRate !== void 0 && (!Number.isInteger(options.video.frameRate) || options.video.frameRate <= 0)) {
|
|
2923
|
+
throw new TypeError(
|
|
2924
|
+
`Invalid video frame rate: ${options.video.frameRate}. Must be a positive integer.`
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
if (options.audio) {
|
|
2929
|
+
if (!SUPPORTED_AUDIO_CODECS.includes(options.audio.codec)) {
|
|
2930
|
+
throw new TypeError(`Unsupported audio codec: ${options.audio.codec}`);
|
|
2931
|
+
}
|
|
2932
|
+
if (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0) {
|
|
2933
|
+
throw new TypeError(
|
|
2934
|
+
`Invalid number of audio channels: ${options.audio.numberOfChannels}. Must be a positive integer.`
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
if (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0) {
|
|
2938
|
+
throw new TypeError(
|
|
2939
|
+
`Invalid audio sample rate: ${options.audio.sampleRate}. Must be a positive integer.`
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
if (options.firstTimestampBehavior && !FIRST_TIMESTAMP_BEHAVIORS.includes(options.firstTimestampBehavior)) {
|
|
2944
|
+
throw new TypeError(`Invalid first timestamp behavior: ${options.firstTimestampBehavior}`);
|
|
2945
|
+
}
|
|
2946
|
+
if (typeof options.fastStart === "object") {
|
|
2947
|
+
if (options.video) {
|
|
2948
|
+
if (options.fastStart.expectedVideoChunks === void 0) {
|
|
2949
|
+
throw new TypeError(`'fastStart' is an object but is missing property 'expectedVideoChunks'.`);
|
|
2950
|
+
} else if (!Number.isInteger(options.fastStart.expectedVideoChunks) || options.fastStart.expectedVideoChunks < 0) {
|
|
2951
|
+
throw new TypeError(`'expectedVideoChunks' must be a non-negative integer.`);
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
if (options.audio) {
|
|
2955
|
+
if (options.fastStart.expectedAudioChunks === void 0) {
|
|
2956
|
+
throw new TypeError(`'fastStart' is an object but is missing property 'expectedAudioChunks'.`);
|
|
2957
|
+
} else if (!Number.isInteger(options.fastStart.expectedAudioChunks) || options.fastStart.expectedAudioChunks < 0) {
|
|
2958
|
+
throw new TypeError(`'expectedAudioChunks' must be a non-negative integer.`);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
} else if (![false, "in-memory", "fragmented"].includes(options.fastStart)) {
|
|
2962
|
+
throw new TypeError(`'fastStart' option must be false, 'in-memory', 'fragmented' or an object.`);
|
|
2963
|
+
}
|
|
2964
|
+
if (options.minFragmentDuration !== void 0 && (!Number.isFinite(options.minFragmentDuration) || options.minFragmentDuration < 0)) {
|
|
2965
|
+
throw new TypeError(`'minFragmentDuration' must be a non-negative number.`);
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
_writeHeader = /* @__PURE__ */ new WeakSet();
|
|
2969
|
+
writeHeader_fn = function() {
|
|
2970
|
+
__privateGet(this, _writer).writeBox(ftyp({
|
|
2971
|
+
holdsAvc: __privateGet(this, _options).video?.codec === "avc",
|
|
2972
|
+
fragmented: __privateGet(this, _options).fastStart === "fragmented"
|
|
2973
|
+
}));
|
|
2974
|
+
__privateSet(this, _ftypSize, __privateGet(this, _writer).pos);
|
|
2975
|
+
if (__privateGet(this, _options).fastStart === "in-memory") {
|
|
2976
|
+
__privateSet(this, _mdat, mdat(false));
|
|
2977
|
+
} else if (__privateGet(this, _options).fastStart === "fragmented") {
|
|
2978
|
+
} else {
|
|
2979
|
+
if (typeof __privateGet(this, _options).fastStart === "object") {
|
|
2980
|
+
let moovSizeUpperBound = __privateMethod(this, _computeMoovSizeUpperBound, computeMoovSizeUpperBound_fn).call(this);
|
|
2981
|
+
__privateGet(this, _writer).seek(__privateGet(this, _writer).pos + moovSizeUpperBound);
|
|
2982
|
+
}
|
|
2983
|
+
__privateSet(this, _mdat, mdat(true));
|
|
2984
|
+
__privateGet(this, _writer).writeBox(__privateGet(this, _mdat));
|
|
2985
|
+
}
|
|
2986
|
+
__privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
|
|
2987
|
+
};
|
|
2988
|
+
_computeMoovSizeUpperBound = /* @__PURE__ */ new WeakSet();
|
|
2989
|
+
computeMoovSizeUpperBound_fn = function() {
|
|
2990
|
+
if (typeof __privateGet(this, _options).fastStart !== "object")
|
|
2991
|
+
return;
|
|
2992
|
+
let upperBound = 0;
|
|
2993
|
+
let sampleCounts = [
|
|
2994
|
+
__privateGet(this, _options).fastStart.expectedVideoChunks,
|
|
2995
|
+
__privateGet(this, _options).fastStart.expectedAudioChunks
|
|
2996
|
+
];
|
|
2997
|
+
for (let n of sampleCounts) {
|
|
2998
|
+
if (!n)
|
|
2999
|
+
continue;
|
|
3000
|
+
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
|
|
3001
|
+
upperBound += 4 * n;
|
|
3002
|
+
upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n);
|
|
3003
|
+
upperBound += 4 * n;
|
|
3004
|
+
upperBound += 8 * n;
|
|
3005
|
+
}
|
|
3006
|
+
upperBound += 4096;
|
|
3007
|
+
return upperBound;
|
|
3008
|
+
};
|
|
3009
|
+
_prepareTracks = /* @__PURE__ */ new WeakSet();
|
|
3010
|
+
prepareTracks_fn = function() {
|
|
3011
|
+
if (__privateGet(this, _options).video) {
|
|
3012
|
+
__privateSet(this, _videoTrack, {
|
|
3013
|
+
id: 1,
|
|
3014
|
+
info: {
|
|
3015
|
+
type: "video",
|
|
3016
|
+
codec: __privateGet(this, _options).video.codec,
|
|
3017
|
+
width: __privateGet(this, _options).video.width,
|
|
3018
|
+
height: __privateGet(this, _options).video.height,
|
|
3019
|
+
rotation: __privateGet(this, _options).video.rotation ?? 0,
|
|
3020
|
+
decoderConfig: null
|
|
3021
|
+
},
|
|
3022
|
+
// The fallback contains many common frame rates as factors
|
|
3023
|
+
timescale: __privateGet(this, _options).video.frameRate ?? 57600,
|
|
3024
|
+
samples: [],
|
|
3025
|
+
finalizedChunks: [],
|
|
3026
|
+
currentChunk: null,
|
|
3027
|
+
firstDecodeTimestamp: void 0,
|
|
3028
|
+
lastDecodeTimestamp: -1,
|
|
3029
|
+
timeToSampleTable: [],
|
|
3030
|
+
compositionTimeOffsetTable: [],
|
|
3031
|
+
lastTimescaleUnits: null,
|
|
3032
|
+
lastSample: null,
|
|
3033
|
+
compactlyCodedChunkTable: []
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
if (__privateGet(this, _options).audio) {
|
|
3037
|
+
__privateSet(this, _audioTrack, {
|
|
3038
|
+
id: __privateGet(this, _options).video ? 2 : 1,
|
|
3039
|
+
info: {
|
|
3040
|
+
type: "audio",
|
|
3041
|
+
codec: __privateGet(this, _options).audio.codec,
|
|
3042
|
+
numberOfChannels: __privateGet(this, _options).audio.numberOfChannels,
|
|
3043
|
+
sampleRate: __privateGet(this, _options).audio.sampleRate,
|
|
3044
|
+
decoderConfig: null
|
|
3045
|
+
},
|
|
3046
|
+
timescale: __privateGet(this, _options).audio.sampleRate,
|
|
3047
|
+
samples: [],
|
|
3048
|
+
finalizedChunks: [],
|
|
3049
|
+
currentChunk: null,
|
|
3050
|
+
firstDecodeTimestamp: void 0,
|
|
3051
|
+
lastDecodeTimestamp: -1,
|
|
3052
|
+
timeToSampleTable: [],
|
|
3053
|
+
compositionTimeOffsetTable: [],
|
|
3054
|
+
lastTimescaleUnits: null,
|
|
3055
|
+
lastSample: null,
|
|
3056
|
+
compactlyCodedChunkTable: []
|
|
3057
|
+
});
|
|
3058
|
+
if (__privateGet(this, _options).audio.codec === "aac") {
|
|
3059
|
+
let guessedCodecPrivate = __privateMethod(this, _generateMpeg4AudioSpecificConfig, generateMpeg4AudioSpecificConfig_fn).call(
|
|
3060
|
+
this,
|
|
3061
|
+
2,
|
|
3062
|
+
// Object type for AAC-LC, since it's the most common
|
|
3063
|
+
__privateGet(this, _options).audio.sampleRate,
|
|
3064
|
+
__privateGet(this, _options).audio.numberOfChannels
|
|
3065
|
+
);
|
|
3066
|
+
__privateGet(this, _audioTrack).info.decoderConfig = {
|
|
3067
|
+
codec: __privateGet(this, _options).audio.codec,
|
|
3068
|
+
description: guessedCodecPrivate,
|
|
3069
|
+
numberOfChannels: __privateGet(this, _options).audio.numberOfChannels,
|
|
3070
|
+
sampleRate: __privateGet(this, _options).audio.sampleRate
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
};
|
|
3075
|
+
_generateMpeg4AudioSpecificConfig = /* @__PURE__ */ new WeakSet();
|
|
3076
|
+
generateMpeg4AudioSpecificConfig_fn = function(objectType, sampleRate, numberOfChannels) {
|
|
3077
|
+
let frequencyIndices = [96e3, 88200, 64e3, 48e3, 44100, 32e3, 24e3, 22050, 16e3, 12e3, 11025, 8e3, 7350];
|
|
3078
|
+
let frequencyIndex = frequencyIndices.indexOf(sampleRate);
|
|
3079
|
+
let channelConfig = numberOfChannels;
|
|
3080
|
+
let configBits = "";
|
|
3081
|
+
configBits += objectType.toString(2).padStart(5, "0");
|
|
3082
|
+
configBits += frequencyIndex.toString(2).padStart(4, "0");
|
|
3083
|
+
if (frequencyIndex === 15)
|
|
3084
|
+
configBits += sampleRate.toString(2).padStart(24, "0");
|
|
3085
|
+
configBits += channelConfig.toString(2).padStart(4, "0");
|
|
3086
|
+
let paddingLength = Math.ceil(configBits.length / 8) * 8;
|
|
3087
|
+
configBits = configBits.padEnd(paddingLength, "0");
|
|
3088
|
+
let configBytes = new Uint8Array(configBits.length / 8);
|
|
3089
|
+
for (let i = 0; i < configBits.length; i += 8) {
|
|
3090
|
+
configBytes[i / 8] = parseInt(configBits.slice(i, i + 8), 2);
|
|
3091
|
+
}
|
|
3092
|
+
return configBytes;
|
|
3093
|
+
};
|
|
3094
|
+
_createSampleForTrack = /* @__PURE__ */ new WeakSet();
|
|
3095
|
+
createSampleForTrack_fn = function(track, data, type, timestamp, duration, meta, compositionTimeOffset) {
|
|
3096
|
+
let presentationTimestampInSeconds = timestamp / 1e6;
|
|
3097
|
+
let decodeTimestampInSeconds = (timestamp - (compositionTimeOffset ?? 0)) / 1e6;
|
|
3098
|
+
let durationInSeconds = duration / 1e6;
|
|
3099
|
+
let adjusted = __privateMethod(this, _validateTimestamp, validateTimestamp_fn).call(this, presentationTimestampInSeconds, decodeTimestampInSeconds, track);
|
|
3100
|
+
presentationTimestampInSeconds = adjusted.presentationTimestamp;
|
|
3101
|
+
decodeTimestampInSeconds = adjusted.decodeTimestamp;
|
|
3102
|
+
if (meta?.decoderConfig) {
|
|
3103
|
+
if (track.info.decoderConfig === null) {
|
|
3104
|
+
track.info.decoderConfig = meta.decoderConfig;
|
|
3105
|
+
} else {
|
|
3106
|
+
Object.assign(track.info.decoderConfig, meta.decoderConfig);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
let sample = {
|
|
3110
|
+
presentationTimestamp: presentationTimestampInSeconds,
|
|
3111
|
+
decodeTimestamp: decodeTimestampInSeconds,
|
|
3112
|
+
duration: durationInSeconds,
|
|
3113
|
+
data,
|
|
3114
|
+
size: data.byteLength,
|
|
3115
|
+
type,
|
|
3116
|
+
// Will be refined once the next sample comes in
|
|
3117
|
+
timescaleUnitsToNextSample: intoTimescale(durationInSeconds, track.timescale)
|
|
3118
|
+
};
|
|
3119
|
+
return sample;
|
|
3120
|
+
};
|
|
3121
|
+
_addSampleToTrack = /* @__PURE__ */ new WeakSet();
|
|
3122
|
+
addSampleToTrack_fn = function(track, sample) {
|
|
3123
|
+
if (__privateGet(this, _options).fastStart !== "fragmented") {
|
|
3124
|
+
track.samples.push(sample);
|
|
3125
|
+
}
|
|
3126
|
+
const sampleCompositionTimeOffset = intoTimescale(sample.presentationTimestamp - sample.decodeTimestamp, track.timescale);
|
|
3127
|
+
if (track.lastTimescaleUnits !== null) {
|
|
3128
|
+
let timescaleUnits = intoTimescale(sample.decodeTimestamp, track.timescale, false);
|
|
3129
|
+
let delta = Math.round(timescaleUnits - track.lastTimescaleUnits);
|
|
3130
|
+
track.lastTimescaleUnits += delta;
|
|
3131
|
+
track.lastSample.timescaleUnitsToNextSample = delta;
|
|
3132
|
+
if (__privateGet(this, _options).fastStart !== "fragmented") {
|
|
3133
|
+
let lastTableEntry = last(track.timeToSampleTable);
|
|
3134
|
+
if (lastTableEntry.sampleCount === 1) {
|
|
3135
|
+
lastTableEntry.sampleDelta = delta;
|
|
3136
|
+
lastTableEntry.sampleCount++;
|
|
3137
|
+
} else if (lastTableEntry.sampleDelta === delta) {
|
|
3138
|
+
lastTableEntry.sampleCount++;
|
|
3139
|
+
} else {
|
|
3140
|
+
lastTableEntry.sampleCount--;
|
|
3141
|
+
track.timeToSampleTable.push({
|
|
3142
|
+
sampleCount: 2,
|
|
3143
|
+
sampleDelta: delta
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
const lastCompositionTimeOffsetTableEntry = last(track.compositionTimeOffsetTable);
|
|
3147
|
+
if (lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset) {
|
|
3148
|
+
lastCompositionTimeOffsetTableEntry.sampleCount++;
|
|
3149
|
+
} else {
|
|
3150
|
+
track.compositionTimeOffsetTable.push({
|
|
3151
|
+
sampleCount: 1,
|
|
3152
|
+
sampleCompositionTimeOffset
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
} else {
|
|
3157
|
+
track.lastTimescaleUnits = 0;
|
|
3158
|
+
if (__privateGet(this, _options).fastStart !== "fragmented") {
|
|
3159
|
+
track.timeToSampleTable.push({
|
|
3160
|
+
sampleCount: 1,
|
|
3161
|
+
sampleDelta: intoTimescale(sample.duration, track.timescale)
|
|
3162
|
+
});
|
|
3163
|
+
track.compositionTimeOffsetTable.push({
|
|
3164
|
+
sampleCount: 1,
|
|
3165
|
+
sampleCompositionTimeOffset
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
track.lastSample = sample;
|
|
3170
|
+
let beginNewChunk = false;
|
|
3171
|
+
if (!track.currentChunk) {
|
|
3172
|
+
beginNewChunk = true;
|
|
3173
|
+
} else {
|
|
3174
|
+
let currentChunkDuration = sample.presentationTimestamp - track.currentChunk.startTimestamp;
|
|
3175
|
+
if (__privateGet(this, _options).fastStart === "fragmented") {
|
|
3176
|
+
let mostImportantTrack = __privateGet(this, _videoTrack) ?? __privateGet(this, _audioTrack);
|
|
3177
|
+
const chunkDuration = __privateGet(this, _options).minFragmentDuration ?? 1;
|
|
3178
|
+
if (track === mostImportantTrack && sample.type === "key" && currentChunkDuration >= chunkDuration) {
|
|
3179
|
+
beginNewChunk = true;
|
|
3180
|
+
__privateMethod(this, _finalizeFragment, finalizeFragment_fn).call(this);
|
|
3181
|
+
}
|
|
3182
|
+
} else {
|
|
3183
|
+
beginNewChunk = currentChunkDuration >= 0.5;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
if (beginNewChunk) {
|
|
3187
|
+
if (track.currentChunk) {
|
|
3188
|
+
__privateMethod(this, _finalizeCurrentChunk, finalizeCurrentChunk_fn).call(this, track);
|
|
3189
|
+
}
|
|
3190
|
+
track.currentChunk = {
|
|
3191
|
+
startTimestamp: sample.presentationTimestamp,
|
|
3192
|
+
samples: []
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
track.currentChunk.samples.push(sample);
|
|
3196
|
+
};
|
|
3197
|
+
_validateTimestamp = /* @__PURE__ */ new WeakSet();
|
|
3198
|
+
validateTimestamp_fn = function(presentationTimestamp, decodeTimestamp, track) {
|
|
3199
|
+
const strictTimestampBehavior = __privateGet(this, _options).firstTimestampBehavior === "strict";
|
|
3200
|
+
const noLastDecodeTimestamp = track.lastDecodeTimestamp === -1;
|
|
3201
|
+
const timestampNonZero = decodeTimestamp !== 0;
|
|
3202
|
+
if (strictTimestampBehavior && noLastDecodeTimestamp && timestampNonZero) {
|
|
3203
|
+
throw new Error(
|
|
3204
|
+
`The first chunk for your media track must have a timestamp of 0 (received DTS=${decodeTimestamp}).Non-zero first timestamps are often caused by directly piping frames or audio data from a MediaStreamTrack into the encoder. Their timestamps are typically relative to the age of thedocument, which is probably what you want.
|
|
3205
|
+
|
|
3206
|
+
If you want to offset all timestamps of a track such that the first one is zero, set firstTimestampBehavior: 'offset' in the options.
|
|
3207
|
+
`
|
|
3208
|
+
);
|
|
3209
|
+
} else if (__privateGet(this, _options).firstTimestampBehavior === "offset" || __privateGet(this, _options).firstTimestampBehavior === "cross-track-offset") {
|
|
3210
|
+
if (track.firstDecodeTimestamp === void 0) {
|
|
3211
|
+
track.firstDecodeTimestamp = decodeTimestamp;
|
|
3212
|
+
}
|
|
3213
|
+
let baseDecodeTimestamp;
|
|
3214
|
+
if (__privateGet(this, _options).firstTimestampBehavior === "offset") {
|
|
3215
|
+
baseDecodeTimestamp = track.firstDecodeTimestamp;
|
|
3216
|
+
} else {
|
|
3217
|
+
baseDecodeTimestamp = Math.min(
|
|
3218
|
+
__privateGet(this, _videoTrack)?.firstDecodeTimestamp ?? Infinity,
|
|
3219
|
+
__privateGet(this, _audioTrack)?.firstDecodeTimestamp ?? Infinity
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
decodeTimestamp -= baseDecodeTimestamp;
|
|
3223
|
+
presentationTimestamp -= baseDecodeTimestamp;
|
|
3224
|
+
}
|
|
3225
|
+
if (decodeTimestamp < track.lastDecodeTimestamp) {
|
|
3226
|
+
throw new Error(
|
|
3227
|
+
`Timestamps must be monotonically increasing (DTS went from ${track.lastDecodeTimestamp * 1e6} to ${decodeTimestamp * 1e6}).`
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
track.lastDecodeTimestamp = decodeTimestamp;
|
|
3231
|
+
return { presentationTimestamp, decodeTimestamp };
|
|
3232
|
+
};
|
|
3233
|
+
_finalizeCurrentChunk = /* @__PURE__ */ new WeakSet();
|
|
3234
|
+
finalizeCurrentChunk_fn = function(track) {
|
|
3235
|
+
if (__privateGet(this, _options).fastStart === "fragmented") {
|
|
3236
|
+
throw new Error("Can't finalize individual chunks if 'fastStart' is set to 'fragmented'.");
|
|
3237
|
+
}
|
|
3238
|
+
if (!track.currentChunk)
|
|
3239
|
+
return;
|
|
3240
|
+
track.finalizedChunks.push(track.currentChunk);
|
|
3241
|
+
__privateGet(this, _finalizedChunks).push(track.currentChunk);
|
|
3242
|
+
if (track.compactlyCodedChunkTable.length === 0 || last(track.compactlyCodedChunkTable).samplesPerChunk !== track.currentChunk.samples.length) {
|
|
3243
|
+
track.compactlyCodedChunkTable.push({
|
|
3244
|
+
firstChunk: track.finalizedChunks.length,
|
|
3245
|
+
// 1-indexed
|
|
3246
|
+
samplesPerChunk: track.currentChunk.samples.length
|
|
3247
|
+
});
|
|
3248
|
+
}
|
|
3249
|
+
if (__privateGet(this, _options).fastStart === "in-memory") {
|
|
3250
|
+
track.currentChunk.offset = 0;
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
track.currentChunk.offset = __privateGet(this, _writer).pos;
|
|
3254
|
+
for (let sample of track.currentChunk.samples) {
|
|
3255
|
+
__privateGet(this, _writer).write(sample.data);
|
|
3256
|
+
sample.data = null;
|
|
3257
|
+
}
|
|
3258
|
+
__privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
|
|
3259
|
+
};
|
|
3260
|
+
_finalizeFragment = /* @__PURE__ */ new WeakSet();
|
|
3261
|
+
finalizeFragment_fn = function(flushStreamingWriter = true) {
|
|
3262
|
+
if (__privateGet(this, _options).fastStart !== "fragmented") {
|
|
3263
|
+
throw new Error("Can't finalize a fragment unless 'fastStart' is set to 'fragmented'.");
|
|
3264
|
+
}
|
|
3265
|
+
let tracks = [__privateGet(this, _videoTrack), __privateGet(this, _audioTrack)].filter((track) => track && track.currentChunk);
|
|
3266
|
+
if (tracks.length === 0)
|
|
3267
|
+
return;
|
|
3268
|
+
let fragmentNumber = __privateWrapper(this, _nextFragmentNumber)._++;
|
|
3269
|
+
if (fragmentNumber === 1) {
|
|
3270
|
+
let movieBox = moov(tracks, __privateGet(this, _creationTime), true);
|
|
3271
|
+
__privateGet(this, _writer).writeBox(movieBox);
|
|
3272
|
+
}
|
|
3273
|
+
let moofOffset = __privateGet(this, _writer).pos;
|
|
3274
|
+
let moofBox = moof(fragmentNumber, tracks);
|
|
3275
|
+
__privateGet(this, _writer).writeBox(moofBox);
|
|
3276
|
+
{
|
|
3277
|
+
let mdatBox = mdat(false);
|
|
3278
|
+
let totalTrackSampleSize = 0;
|
|
3279
|
+
for (let track of tracks) {
|
|
3280
|
+
for (let sample of track.currentChunk.samples) {
|
|
3281
|
+
totalTrackSampleSize += sample.size;
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
let mdatSize = __privateGet(this, _writer).measureBox(mdatBox) + totalTrackSampleSize;
|
|
3285
|
+
if (mdatSize >= 2 ** 32) {
|
|
3286
|
+
mdatBox.largeSize = true;
|
|
3287
|
+
mdatSize = __privateGet(this, _writer).measureBox(mdatBox) + totalTrackSampleSize;
|
|
3288
|
+
}
|
|
3289
|
+
mdatBox.size = mdatSize;
|
|
3290
|
+
__privateGet(this, _writer).writeBox(mdatBox);
|
|
3291
|
+
}
|
|
3292
|
+
for (let track of tracks) {
|
|
3293
|
+
track.currentChunk.offset = __privateGet(this, _writer).pos;
|
|
3294
|
+
track.currentChunk.moofOffset = moofOffset;
|
|
3295
|
+
for (let sample of track.currentChunk.samples) {
|
|
3296
|
+
__privateGet(this, _writer).write(sample.data);
|
|
3297
|
+
sample.data = null;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
let endPos = __privateGet(this, _writer).pos;
|
|
3301
|
+
__privateGet(this, _writer).seek(__privateGet(this, _writer).offsets.get(moofBox));
|
|
3302
|
+
let newMoofBox = moof(fragmentNumber, tracks);
|
|
3303
|
+
__privateGet(this, _writer).writeBox(newMoofBox);
|
|
3304
|
+
__privateGet(this, _writer).seek(endPos);
|
|
3305
|
+
for (let track of tracks) {
|
|
3306
|
+
track.finalizedChunks.push(track.currentChunk);
|
|
3307
|
+
__privateGet(this, _finalizedChunks).push(track.currentChunk);
|
|
3308
|
+
track.currentChunk = null;
|
|
3309
|
+
}
|
|
3310
|
+
if (flushStreamingWriter) {
|
|
3311
|
+
__privateMethod(this, _maybeFlushStreamingTargetWriter, maybeFlushStreamingTargetWriter_fn).call(this);
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
_maybeFlushStreamingTargetWriter = /* @__PURE__ */ new WeakSet();
|
|
3315
|
+
maybeFlushStreamingTargetWriter_fn = function() {
|
|
3316
|
+
if (__privateGet(this, _writer) instanceof StreamTargetWriter) {
|
|
3317
|
+
__privateGet(this, _writer).flush();
|
|
3318
|
+
}
|
|
3319
|
+
};
|
|
3320
|
+
_ensureNotFinalized = /* @__PURE__ */ new WeakSet();
|
|
3321
|
+
ensureNotFinalized_fn = function() {
|
|
3322
|
+
if (__privateGet(this, _finalized)) {
|
|
3323
|
+
throw new Error("Cannot add new video or audio chunks after the file has been finalized.");
|
|
3324
|
+
}
|
|
3325
|
+
};
|
|
3326
|
+
|
|
3327
|
+
var KEYFRAME_INTERVAL_SECONDS = 2;
|
|
3328
|
+
var VIDEO_ENCODE_QUEUE_LIMIT = 30;
|
|
3329
|
+
var WebCodecsMp4Strategy = class {
|
|
3330
|
+
kind = "webcodecs-mp4";
|
|
3331
|
+
muxer = null;
|
|
3332
|
+
videoEncoder = null;
|
|
3333
|
+
audioEncoder = null;
|
|
3334
|
+
videoReader = null;
|
|
3335
|
+
audioReader = null;
|
|
3336
|
+
videoPump = null;
|
|
3337
|
+
audioPump = null;
|
|
3338
|
+
framesEncoded = 0;
|
|
3339
|
+
framesDropped = 0;
|
|
3340
|
+
keyframeInterval = 60;
|
|
3341
|
+
running = false;
|
|
3342
|
+
started = false;
|
|
3343
|
+
firstError = null;
|
|
3344
|
+
errorHandler = null;
|
|
3345
|
+
codecLabels = {
|
|
3346
|
+
video: "avc1.42E01E",
|
|
3347
|
+
audio: "mp4a.40.2"
|
|
3348
|
+
};
|
|
3349
|
+
onError(handler) {
|
|
3350
|
+
this.errorHandler = handler;
|
|
3351
|
+
}
|
|
3352
|
+
reportError(cause) {
|
|
3353
|
+
const err = cause instanceof Error ? cause : new Error(String(cause));
|
|
3354
|
+
if (!this.firstError)
|
|
3355
|
+
this.firstError = err;
|
|
3356
|
+
this.running = false;
|
|
3357
|
+
if (!this.started)
|
|
3358
|
+
return;
|
|
3359
|
+
const handler = this.errorHandler;
|
|
3360
|
+
this.errorHandler = null;
|
|
3361
|
+
if (!handler)
|
|
3362
|
+
return;
|
|
3363
|
+
try {
|
|
3364
|
+
handler(err);
|
|
3365
|
+
} catch {
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
async start(stream, sink, opts) {
|
|
3369
|
+
if (this.muxer) {
|
|
3370
|
+
throw RecordingEngineError.state("start", "recording");
|
|
3371
|
+
}
|
|
3372
|
+
if (typeof MediaStreamTrackProcessor === "undefined") {
|
|
3373
|
+
throw RecordingEngineError.capability("MediaStreamTrackProcessor");
|
|
3374
|
+
}
|
|
3375
|
+
const videoTrack = stream.getVideoTracks()[0];
|
|
3376
|
+
const audioTrack = stream.getAudioTracks()[0];
|
|
3377
|
+
if (!videoTrack || !audioTrack) {
|
|
3378
|
+
throw RecordingEngineError.invalidArg("stream", "WebCodecsMp4Strategy requires both video and audio tracks");
|
|
3379
|
+
}
|
|
3380
|
+
const videoSettings = videoTrack.getSettings();
|
|
3381
|
+
const audioSettings = audioTrack.getSettings();
|
|
3382
|
+
const width = videoSettings.width ?? opts.videoConstraints?.width ?? 1920;
|
|
3383
|
+
const height = videoSettings.height ?? opts.videoConstraints?.height ?? 1080;
|
|
3384
|
+
const frameRate = videoSettings.frameRate ?? opts.videoConstraints?.frameRate ?? 30;
|
|
3385
|
+
this.keyframeInterval = Math.max(1, Math.round(frameRate * KEYFRAME_INTERVAL_SECONDS));
|
|
3386
|
+
const sampleRate = opts.audioConstraints?.sampleRate ?? audioSettings.sampleRate ?? 48e3;
|
|
3387
|
+
const numberOfChannels = audioSettings.channelCount ?? 2;
|
|
3388
|
+
const muxer = new Muxer({
|
|
3389
|
+
target: new StreamTarget({
|
|
3390
|
+
onData: (data, position) => {
|
|
3391
|
+
if (position !== this.bytesWritten) {
|
|
3392
|
+
this.reportError(RecordingEngineError.muxWrite(position, this.bytesWritten));
|
|
3393
|
+
return;
|
|
3394
|
+
}
|
|
3395
|
+
this.bytesWritten += data.byteLength;
|
|
3396
|
+
this.writeTail = this.writeTail.then(() => sink.write(data)).catch((err) => this.reportError(err));
|
|
3397
|
+
},
|
|
3398
|
+
chunked: true
|
|
3399
|
+
}),
|
|
3400
|
+
video: { codec: "avc", width, height, frameRate },
|
|
3401
|
+
audio: { codec: "aac", numberOfChannels, sampleRate },
|
|
3402
|
+
fastStart: "fragmented",
|
|
3403
|
+
// Both tracks share the capture clock; per-track 'offset' would zero
|
|
3404
|
+
// each independently and bake in permanent lip-sync error.
|
|
3405
|
+
firstTimestampBehavior: "cross-track-offset"
|
|
3406
|
+
});
|
|
3407
|
+
this.muxer = muxer;
|
|
3408
|
+
this.codecLabels.video = pickAvcCodecString(width, height, frameRate);
|
|
3409
|
+
const videoEncoder = new VideoEncoder({
|
|
3410
|
+
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
|
3411
|
+
error: (err) => this.reportError(err)
|
|
3412
|
+
});
|
|
3413
|
+
videoEncoder.configure({
|
|
3414
|
+
codec: this.codecLabels.video,
|
|
3415
|
+
width,
|
|
3416
|
+
height,
|
|
3417
|
+
bitrate: opts.videoBitsPerSecond ?? bitrateForResolution(height, frameRate),
|
|
3418
|
+
framerate: frameRate
|
|
3419
|
+
});
|
|
3420
|
+
this.videoEncoder = videoEncoder;
|
|
3421
|
+
const audioEncoder = new AudioEncoder({
|
|
3422
|
+
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
|
|
3423
|
+
error: (err) => this.reportError(err)
|
|
3424
|
+
});
|
|
3425
|
+
audioEncoder.configure({
|
|
3426
|
+
codec: this.codecLabels.audio,
|
|
3427
|
+
sampleRate,
|
|
3428
|
+
numberOfChannels,
|
|
3429
|
+
bitrate: opts.audioBitsPerSecond ?? 192e3
|
|
3430
|
+
});
|
|
3431
|
+
this.audioEncoder = audioEncoder;
|
|
3432
|
+
this.running = true;
|
|
3433
|
+
const videoProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
|
|
3434
|
+
const audioProcessor = new MediaStreamTrackProcessor({ track: audioTrack });
|
|
3435
|
+
this.videoReader = videoProcessor.readable.getReader();
|
|
3436
|
+
this.audioReader = audioProcessor.readable.getReader();
|
|
3437
|
+
this.videoPump = this.pumpVideo();
|
|
3438
|
+
this.audioPump = this.pumpAudio();
|
|
3439
|
+
this.started = true;
|
|
3440
|
+
}
|
|
3441
|
+
async stop() {
|
|
3442
|
+
if (!this.muxer || !this.videoEncoder || !this.audioEncoder) {
|
|
3443
|
+
throw RecordingEngineError.state("stop", "idle");
|
|
3444
|
+
}
|
|
3445
|
+
this.running = false;
|
|
3446
|
+
try {
|
|
3447
|
+
await this.videoReader?.cancel();
|
|
3448
|
+
} catch {
|
|
3449
|
+
}
|
|
3450
|
+
try {
|
|
3451
|
+
await this.audioReader?.cancel();
|
|
3452
|
+
} catch {
|
|
3453
|
+
}
|
|
3454
|
+
await Promise.allSettled([this.videoPump, this.audioPump]);
|
|
3455
|
+
if (this.firstError) {
|
|
3456
|
+
throw this.firstError;
|
|
3457
|
+
}
|
|
3458
|
+
await this.videoEncoder.flush();
|
|
3459
|
+
await this.audioEncoder.flush();
|
|
3460
|
+
this.videoEncoder.close();
|
|
3461
|
+
this.audioEncoder.close();
|
|
3462
|
+
this.muxer.finalize();
|
|
3463
|
+
await this.writeTail.catch(() => void 0);
|
|
3464
|
+
if (this.firstError) {
|
|
3465
|
+
throw this.firstError;
|
|
3466
|
+
}
|
|
3467
|
+
return {
|
|
3468
|
+
videoCodec: this.codecLabels.video,
|
|
3469
|
+
audioCodec: this.codecLabels.audio,
|
|
3470
|
+
containerMime: "video/mp4",
|
|
3471
|
+
framesDropped: this.framesDropped
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
async cancel() {
|
|
3475
|
+
this.running = false;
|
|
3476
|
+
try {
|
|
3477
|
+
await this.videoReader?.cancel();
|
|
3478
|
+
} catch {
|
|
3479
|
+
}
|
|
3480
|
+
try {
|
|
3481
|
+
await this.audioReader?.cancel();
|
|
3482
|
+
} catch {
|
|
3483
|
+
}
|
|
3484
|
+
await Promise.allSettled([this.videoPump, this.audioPump]);
|
|
3485
|
+
try {
|
|
3486
|
+
this.videoEncoder?.close();
|
|
3487
|
+
} catch {
|
|
3488
|
+
}
|
|
3489
|
+
try {
|
|
3490
|
+
this.audioEncoder?.close();
|
|
3491
|
+
} catch {
|
|
3492
|
+
}
|
|
3493
|
+
await this.writeTail.catch(() => void 0);
|
|
3494
|
+
this.muxer = null;
|
|
3495
|
+
this.videoEncoder = null;
|
|
3496
|
+
this.audioEncoder = null;
|
|
3497
|
+
this.videoReader = null;
|
|
3498
|
+
this.audioReader = null;
|
|
3499
|
+
}
|
|
3500
|
+
bytesWritten = 0;
|
|
3501
|
+
// Rolling tail of serialized sink writes — bounds memory vs. an array that
|
|
3502
|
+
// grows per muxer chunk. Awaiting it awaits the whole chain.
|
|
3503
|
+
writeTail = Promise.resolve();
|
|
3504
|
+
async pumpVideo() {
|
|
3505
|
+
const reader = this.videoReader;
|
|
3506
|
+
const encoder = this.videoEncoder;
|
|
3507
|
+
if (!reader || !encoder)
|
|
3508
|
+
return;
|
|
3509
|
+
try {
|
|
3510
|
+
while (this.running) {
|
|
3511
|
+
const { done, value } = await reader.read();
|
|
3512
|
+
if (done || !value)
|
|
3513
|
+
break;
|
|
3514
|
+
if (encoder.encodeQueueSize > VIDEO_ENCODE_QUEUE_LIMIT) {
|
|
3515
|
+
this.framesDropped++;
|
|
3516
|
+
value.close();
|
|
3517
|
+
continue;
|
|
3518
|
+
}
|
|
3519
|
+
try {
|
|
3520
|
+
const isKeyframe = this.framesEncoded % this.keyframeInterval === 0;
|
|
3521
|
+
encoder.encode(value, { keyFrame: isKeyframe });
|
|
3522
|
+
this.framesEncoded++;
|
|
3523
|
+
} finally {
|
|
3524
|
+
value.close();
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
} catch (err) {
|
|
3528
|
+
this.reportError(err);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
async pumpAudio() {
|
|
3532
|
+
const reader = this.audioReader;
|
|
3533
|
+
const encoder = this.audioEncoder;
|
|
3534
|
+
if (!reader || !encoder)
|
|
3535
|
+
return;
|
|
3536
|
+
try {
|
|
3537
|
+
while (this.running) {
|
|
3538
|
+
const { done, value } = await reader.read();
|
|
3539
|
+
if (done || !value)
|
|
3540
|
+
break;
|
|
3541
|
+
try {
|
|
3542
|
+
encoder.encode(value);
|
|
3543
|
+
} finally {
|
|
3544
|
+
value.close();
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
} catch (err) {
|
|
3548
|
+
this.reportError(err);
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
|
|
3553
|
+
function createMuxStrategy(kind) {
|
|
3554
|
+
switch (kind) {
|
|
3555
|
+
case "mediarecorder-mp4":
|
|
3556
|
+
return new MediaRecorderMp4Strategy();
|
|
3557
|
+
case "webcodecs-mp4":
|
|
3558
|
+
return new WebCodecsMp4Strategy();
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
var EngineIdValidationError = class extends Error {
|
|
3563
|
+
constructor(brand, value) {
|
|
3564
|
+
super(`Invalid ${brand}: ${JSON.stringify(value)}`);
|
|
3565
|
+
this.name = "EngineIdValidationError";
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
var ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
3569
|
+
function asSessionId(value) {
|
|
3570
|
+
if (typeof value !== "string" || !ID_RE.test(value)) {
|
|
3571
|
+
throw new EngineIdValidationError("SessionId", value);
|
|
3572
|
+
}
|
|
3573
|
+
return value;
|
|
3574
|
+
}
|
|
3575
|
+
function asParticipantId(value) {
|
|
3576
|
+
if (typeof value !== "string" || !ID_RE.test(value)) {
|
|
3577
|
+
throw new EngineIdValidationError("ParticipantId", value);
|
|
3578
|
+
}
|
|
3579
|
+
return value;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
var EMPTY_MARKER = { version: 1, inProgress: [] };
|
|
3583
|
+
async function readSafe(store) {
|
|
3584
|
+
const current = await store.read();
|
|
3585
|
+
return current ?? EMPTY_MARKER;
|
|
3586
|
+
}
|
|
3587
|
+
var MARKER_LOCK = "capturly-recording-marker";
|
|
3588
|
+
async function withMarkerLock(fn) {
|
|
3589
|
+
const locks = globalThis.navigator?.locks;
|
|
3590
|
+
if (!locks?.request)
|
|
3591
|
+
return fn();
|
|
3592
|
+
return await locks.request(MARKER_LOCK, () => fn());
|
|
3593
|
+
}
|
|
3594
|
+
async function appendInProgress(store, entry) {
|
|
3595
|
+
await withMarkerLock(async () => {
|
|
3596
|
+
const current = await readSafe(store);
|
|
3597
|
+
const filtered = current.inProgress.filter((e) => e.videoPath !== entry.videoPath);
|
|
3598
|
+
await store.write({ version: 1, inProgress: [...filtered, entry] });
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
async function removeInProgress(store, videoPath) {
|
|
3602
|
+
await withMarkerLock(async () => {
|
|
3603
|
+
const current = await readSafe(store);
|
|
3604
|
+
const filtered = current.inProgress.filter((e) => e.videoPath !== videoPath);
|
|
3605
|
+
if (filtered.length === current.inProgress.length)
|
|
3606
|
+
return;
|
|
3607
|
+
await store.write({ version: 1, inProgress: filtered });
|
|
3608
|
+
});
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
var TeeFileSinkHandle = class {
|
|
3612
|
+
primary;
|
|
3613
|
+
secondary;
|
|
3614
|
+
onSecondaryError;
|
|
3615
|
+
path;
|
|
3616
|
+
secondaryFailed = false;
|
|
3617
|
+
secondaryFailedOp;
|
|
3618
|
+
secondaryFailureReason;
|
|
3619
|
+
constructor(primary, secondary, onSecondaryError) {
|
|
3620
|
+
this.primary = primary;
|
|
3621
|
+
this.secondary = secondary;
|
|
3622
|
+
this.onSecondaryError = onSecondaryError;
|
|
3623
|
+
this.path = primary.path;
|
|
3624
|
+
}
|
|
3625
|
+
/**
|
|
3626
|
+
* Snapshot of the secondary sink's failure state. Lets a consumer that
|
|
3627
|
+
* subscribed late (or polls at finalize time) tell whether the cloud copy
|
|
3628
|
+
* is truncated without needing to have observed the live event stream.
|
|
3629
|
+
*/
|
|
3630
|
+
getSecondaryStatus() {
|
|
3631
|
+
if (!this.secondaryFailed)
|
|
3632
|
+
return { failed: false };
|
|
3633
|
+
return {
|
|
3634
|
+
failed: true,
|
|
3635
|
+
...this.secondaryFailedOp ? { failedOp: this.secondaryFailedOp } : {},
|
|
3636
|
+
...this.secondaryFailureReason ? { reason: this.secondaryFailureReason } : {}
|
|
3637
|
+
};
|
|
3638
|
+
}
|
|
3639
|
+
async write(chunk) {
|
|
3640
|
+
const primaryWrite = this.primary.write(chunk);
|
|
3641
|
+
if (!this.secondaryFailed) {
|
|
3642
|
+
const secondaryWrite = this.secondary.write(chunk).catch((err) => {
|
|
3643
|
+
this.handleSecondaryError("write", err);
|
|
3644
|
+
});
|
|
3645
|
+
await Promise.all([primaryWrite, secondaryWrite]);
|
|
3646
|
+
} else {
|
|
3647
|
+
await primaryWrite;
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
async close() {
|
|
3651
|
+
const primaryClose = this.primary.close();
|
|
3652
|
+
const secondaryClose = this.secondary.close().catch((err) => {
|
|
3653
|
+
this.handleSecondaryError("close", err);
|
|
3654
|
+
});
|
|
3655
|
+
await Promise.all([primaryClose, secondaryClose]);
|
|
3656
|
+
}
|
|
3657
|
+
async abort() {
|
|
3658
|
+
const primaryAbort = this.primary.abort();
|
|
3659
|
+
const secondaryAbort = this.secondary.abort().catch((err) => {
|
|
3660
|
+
this.handleSecondaryError("abort", err);
|
|
3661
|
+
});
|
|
3662
|
+
await Promise.all([primaryAbort, secondaryAbort]);
|
|
3663
|
+
}
|
|
3664
|
+
async discard() {
|
|
3665
|
+
const primaryDiscard = this.primary.discard();
|
|
3666
|
+
const secondaryDiscard = this.secondary.discard().catch((err) => {
|
|
3667
|
+
this.handleSecondaryError("discard", err);
|
|
3668
|
+
});
|
|
3669
|
+
await Promise.all([primaryDiscard, secondaryDiscard]);
|
|
3670
|
+
}
|
|
3671
|
+
handleSecondaryError(op, err) {
|
|
3672
|
+
if (!this.secondaryFailed) {
|
|
3673
|
+
this.secondaryFailedOp = op;
|
|
3674
|
+
this.secondaryFailureReason = err instanceof Error ? err.message : String(err);
|
|
3675
|
+
}
|
|
3676
|
+
this.secondaryFailed = true;
|
|
3677
|
+
try {
|
|
3678
|
+
this.onSecondaryError?.(op, err);
|
|
3679
|
+
} catch {
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
3683
|
+
|
|
3684
|
+
function projectState(state) {
|
|
3685
|
+
switch (state.status) {
|
|
3686
|
+
case "idle":
|
|
3687
|
+
case "preparing":
|
|
3688
|
+
return { status: state.status };
|
|
3689
|
+
case "recording":
|
|
3690
|
+
return { status: "recording", startedAt: state.resources.startedAt };
|
|
3691
|
+
case "stopping":
|
|
3692
|
+
return { status: "stopping", startedAt: state.resources.startedAt };
|
|
3693
|
+
case "error":
|
|
3694
|
+
return { status: "error", message: state.message };
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
var DEFAULT_VIDEO_CONSTRAINTS = {
|
|
3698
|
+
width: { ideal: 1920 },
|
|
3699
|
+
height: { ideal: 1080 },
|
|
3700
|
+
frameRate: { ideal: 30 }
|
|
3701
|
+
};
|
|
3702
|
+
var STALL_THRESHOLD_MS = 1e4;
|
|
3703
|
+
var STALL_CHECK_INTERVAL_MS = 2500;
|
|
3704
|
+
function newTakeId() {
|
|
3705
|
+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
3706
|
+
}
|
|
3707
|
+
function buildVideoFilePath(opts, takeId) {
|
|
3708
|
+
return `sessions/${opts.sessionId}/${opts.participantId}_${takeId}_video.mp4`;
|
|
3709
|
+
}
|
|
3710
|
+
var CaptureSession = class {
|
|
3711
|
+
deps;
|
|
3712
|
+
state = { status: "idle" };
|
|
3713
|
+
listeners = {
|
|
3714
|
+
state: /* @__PURE__ */ new Set(),
|
|
3715
|
+
error: /* @__PURE__ */ new Set(),
|
|
3716
|
+
cloud: /* @__PURE__ */ new Set(),
|
|
3717
|
+
stall: /* @__PURE__ */ new Set()
|
|
3718
|
+
};
|
|
3719
|
+
// In-flight start(); cancel() flips `requested` and awaits `finished`.
|
|
3720
|
+
prepare = null;
|
|
3721
|
+
constructor(deps) {
|
|
3722
|
+
this.deps = deps;
|
|
3723
|
+
}
|
|
3724
|
+
async start(options) {
|
|
3725
|
+
if (this.state.status !== "idle" && this.state.status !== "error") {
|
|
3726
|
+
throw RecordingEngineError.state("start", this.state.status);
|
|
3727
|
+
}
|
|
3728
|
+
if (this.state.status === "error") {
|
|
3729
|
+
await this.state.cleanup;
|
|
3730
|
+
}
|
|
3731
|
+
this.setState({ status: "preparing" });
|
|
3732
|
+
let finishPrepare;
|
|
3733
|
+
const prepare = {
|
|
3734
|
+
requested: false,
|
|
3735
|
+
finished: new Promise((resolve) => {
|
|
3736
|
+
finishPrepare = resolve;
|
|
3737
|
+
})
|
|
3738
|
+
};
|
|
3739
|
+
this.prepare = prepare;
|
|
3740
|
+
const cancelledSignal = RecordingEngineError.state("start", "cancelled");
|
|
3741
|
+
const takeId = newTakeId();
|
|
3742
|
+
let stream = null;
|
|
3743
|
+
let streamOwned = false;
|
|
3744
|
+
let strategy = null;
|
|
3745
|
+
let sinkHandle = null;
|
|
3746
|
+
let compositor = null;
|
|
3747
|
+
let markerAppended = false;
|
|
3748
|
+
try {
|
|
3749
|
+
stream = options.streamOverride ?? await this.acquireStream(options);
|
|
3750
|
+
streamOwned = !options.streamOverride;
|
|
3751
|
+
if (prepare.requested)
|
|
3752
|
+
throw cancelledSignal;
|
|
3753
|
+
let recordStream = stream;
|
|
3754
|
+
if (options.watermark) {
|
|
3755
|
+
if (watermarkSupported()) {
|
|
3756
|
+
try {
|
|
3757
|
+
compositor = new WatermarkCompositor(stream, {
|
|
3758
|
+
...options.watermark,
|
|
3759
|
+
...this.deps.logError ? { logError: this.deps.logError } : {}
|
|
3760
|
+
});
|
|
3761
|
+
recordStream = await compositor.start();
|
|
3762
|
+
} catch (err) {
|
|
3763
|
+
compositor?.stop();
|
|
3764
|
+
compositor = null;
|
|
3765
|
+
recordStream = stream;
|
|
3766
|
+
this.deps.logError?.("watermark", "compositor failed to start; recording without watermark", err);
|
|
3767
|
+
}
|
|
3768
|
+
} else {
|
|
3769
|
+
this.deps.logError?.("watermark", "canvas captureStream unavailable in this environment; recording without watermark");
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
if (prepare.requested)
|
|
3773
|
+
throw cancelledSignal;
|
|
3774
|
+
const kind = options.muxStrategyOverride ?? pickMuxStrategy();
|
|
3775
|
+
if (!kind) {
|
|
3776
|
+
throw RecordingEngineError.capability("mp4-mux", "No supported MP4 mux strategy in this environment (need MediaRecorder MP4 or WebCodecs)");
|
|
3777
|
+
}
|
|
3778
|
+
strategy = createMuxStrategy(kind);
|
|
3779
|
+
strategy.onError((err) => this.handleStrategyError(err));
|
|
3780
|
+
const opfsHandle = await this.deps.sink.open(buildVideoFilePath(options, takeId));
|
|
3781
|
+
const openedSink = options.cloudBackup ? this.wrapWithCloudBackup(opfsHandle, options.cloudBackup) : opfsHandle;
|
|
3782
|
+
const stallThresholdMs = Math.max(1, this.deps.stallConfig?.thresholdMs ?? STALL_THRESHOLD_MS);
|
|
3783
|
+
const stallCheckIntervalMs = Math.max(1, this.deps.stallConfig?.checkIntervalMs ?? STALL_CHECK_INTERVAL_MS);
|
|
3784
|
+
let lastWriteAt = Date.now();
|
|
3785
|
+
let stalled = false;
|
|
3786
|
+
const monitoredSink = {
|
|
3787
|
+
path: openedSink.path,
|
|
3788
|
+
write: (chunk) => {
|
|
3789
|
+
lastWriteAt = Date.now();
|
|
3790
|
+
if (stalled) {
|
|
3791
|
+
stalled = false;
|
|
3792
|
+
this.emit("stall", { stalled: false });
|
|
3793
|
+
}
|
|
3794
|
+
return openedSink.write(chunk);
|
|
3795
|
+
},
|
|
3796
|
+
close: () => openedSink.close(),
|
|
3797
|
+
abort: () => openedSink.abort(),
|
|
3798
|
+
discard: () => openedSink.discard()
|
|
3799
|
+
};
|
|
3800
|
+
sinkHandle = monitoredSink;
|
|
3801
|
+
if (prepare.requested)
|
|
3802
|
+
throw cancelledSignal;
|
|
3803
|
+
await strategy.start(recordStream, monitoredSink, options);
|
|
3804
|
+
if (prepare.requested)
|
|
3805
|
+
throw cancelledSignal;
|
|
3806
|
+
const startedAt = Date.now();
|
|
3807
|
+
if (this.deps.markerStore) {
|
|
3808
|
+
await safeMarkerOp(this.deps.markerStore, (store) => appendInProgress(store, {
|
|
3809
|
+
sessionId: options.sessionId,
|
|
3810
|
+
participantId: options.participantId,
|
|
3811
|
+
startedAtMs: startedAt,
|
|
3812
|
+
videoPath: openedSink.path,
|
|
3813
|
+
surface: this.deps.surface
|
|
3814
|
+
}), "append-on-start", this.deps.logError);
|
|
3815
|
+
markerAppended = true;
|
|
3816
|
+
}
|
|
3817
|
+
if (prepare.requested)
|
|
3818
|
+
throw cancelledSignal;
|
|
3819
|
+
const detachTrackEnded = watchTrackEnded(stream, (track) => {
|
|
3820
|
+
this.handleStrategyError(new Error(`Recording input lost: ${track.kind} device disconnected`));
|
|
3821
|
+
});
|
|
3822
|
+
const stallTimer = setInterval(() => {
|
|
3823
|
+
if (this.state.status !== "recording")
|
|
3824
|
+
return;
|
|
3825
|
+
const sinceMs = Date.now() - lastWriteAt;
|
|
3826
|
+
if (!stalled && sinceMs >= stallThresholdMs) {
|
|
3827
|
+
stalled = true;
|
|
3828
|
+
this.deps.logError?.("capture", `no sink writes for ${sinceMs}ms while recording (throttled tab, sleep, or dead device)`);
|
|
3829
|
+
this.emit("stall", { stalled: true, sinceMs });
|
|
3830
|
+
}
|
|
3831
|
+
}, stallCheckIntervalMs);
|
|
3832
|
+
const detachStallWatchdog = () => clearInterval(stallTimer);
|
|
3833
|
+
this.setState({
|
|
3834
|
+
status: "recording",
|
|
3835
|
+
resources: {
|
|
3836
|
+
strategy,
|
|
3837
|
+
sinkHandle,
|
|
3838
|
+
stream,
|
|
3839
|
+
streamOwned,
|
|
3840
|
+
compositor,
|
|
3841
|
+
options,
|
|
3842
|
+
startedAt,
|
|
3843
|
+
detachTrackEnded,
|
|
3844
|
+
detachStallWatchdog
|
|
3845
|
+
}
|
|
3846
|
+
});
|
|
3847
|
+
} catch (err) {
|
|
3848
|
+
await teardownResources({ strategy, sinkHandle, stream, streamOwned, compositor });
|
|
3849
|
+
if (markerAppended && this.deps.markerStore) {
|
|
3850
|
+
await safeMarkerOp(this.deps.markerStore, (store) => removeInProgress(store, buildVideoFilePath(options, takeId)), "remove-on-cancelled-start", this.deps.logError);
|
|
3851
|
+
}
|
|
3852
|
+
if (err === cancelledSignal) {
|
|
3853
|
+
this.setState({ status: "idle" });
|
|
3854
|
+
throw err;
|
|
3855
|
+
}
|
|
3856
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3857
|
+
this.setState({ status: "error", message, cleanup: Promise.resolve(), salvage: null });
|
|
3858
|
+
this.emit("error", { message, cause: err });
|
|
3859
|
+
throw err;
|
|
3860
|
+
} finally {
|
|
3861
|
+
this.prepare = null;
|
|
3862
|
+
finishPrepare();
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
async stop() {
|
|
3866
|
+
if (this.state.status !== "recording") {
|
|
3867
|
+
throw RecordingEngineError.state("stop", this.state.status);
|
|
3868
|
+
}
|
|
3869
|
+
const resources = this.state.resources;
|
|
3870
|
+
this.setState({ status: "stopping", resources });
|
|
3871
|
+
resources.detachTrackEnded();
|
|
3872
|
+
resources.detachStallWatchdog();
|
|
3873
|
+
try {
|
|
3874
|
+
const muxResult = await resources.strategy.stop();
|
|
3875
|
+
await resources.sinkHandle.close();
|
|
3876
|
+
const endedAt = Date.now();
|
|
3877
|
+
const videoSettings = resources.stream.getVideoTracks()[0]?.getSettings();
|
|
3878
|
+
const audioSettings = resources.stream.getAudioTracks()[0]?.getSettings();
|
|
3879
|
+
const metadata = {
|
|
3880
|
+
sessionId: resources.options.sessionId,
|
|
3881
|
+
participantId: resources.options.participantId,
|
|
3882
|
+
startedAt: resources.startedAt,
|
|
3883
|
+
endedAt,
|
|
3884
|
+
durationMs: endedAt - resources.startedAt,
|
|
3885
|
+
videoCodec: muxResult.videoCodec,
|
|
3886
|
+
audioCodec: muxResult.audioCodec,
|
|
3887
|
+
videoWidth: videoSettings?.width ?? resources.options.videoConstraints?.width ?? 0,
|
|
3888
|
+
videoHeight: videoSettings?.height ?? resources.options.videoConstraints?.height ?? 0,
|
|
3889
|
+
videoFrameRate: videoSettings?.frameRate ?? resources.options.videoConstraints?.frameRate ?? 0,
|
|
3890
|
+
audioSampleRate: audioSettings?.sampleRate ?? resources.options.audioConstraints?.sampleRate ?? 0,
|
|
3891
|
+
surface: this.deps.surface,
|
|
3892
|
+
// The compositor, not options.watermark: a requested mark that failed
|
|
3893
|
+
// to start leaves this null, and the manifest must say so.
|
|
3894
|
+
watermarked: resources.compositor !== null,
|
|
3895
|
+
...muxResult.framesDropped !== void 0 ? { framesDropped: muxResult.framesDropped } : {}
|
|
3896
|
+
};
|
|
3897
|
+
if (muxResult.framesDropped && muxResult.framesDropped > 0) {
|
|
3898
|
+
this.deps.logError?.("capture", `encoder dropped ${muxResult.framesDropped} frames at ${metadata.videoWidth}x${metadata.videoHeight} (machine couldn't sustain the resolution)`);
|
|
3899
|
+
}
|
|
3900
|
+
const artifact = {
|
|
3901
|
+
videoPath: resources.sinkHandle.path,
|
|
3902
|
+
metadata
|
|
3903
|
+
};
|
|
3904
|
+
if (this.deps.markerStore) {
|
|
3905
|
+
await safeMarkerOp(this.deps.markerStore, (store) => removeInProgress(store, resources.sinkHandle.path), "remove-on-stop", this.deps.logError);
|
|
3906
|
+
}
|
|
3907
|
+
resources.compositor?.stop();
|
|
3908
|
+
stopOwnedStream(resources);
|
|
3909
|
+
this.setState({ status: "idle" });
|
|
3910
|
+
return artifact;
|
|
3911
|
+
} catch (err) {
|
|
3912
|
+
const cleanup = commitResources({
|
|
3913
|
+
strategy: resources.strategy,
|
|
3914
|
+
sinkHandle: resources.sinkHandle,
|
|
3915
|
+
stream: resources.stream,
|
|
3916
|
+
streamOwned: resources.streamOwned,
|
|
3917
|
+
compositor: resources.compositor
|
|
3918
|
+
}, this.deps.logError);
|
|
3919
|
+
await cleanup;
|
|
3920
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3921
|
+
this.setState({
|
|
3922
|
+
status: "error",
|
|
3923
|
+
message,
|
|
3924
|
+
cleanup,
|
|
3925
|
+
salvage: { videoPath: resources.sinkHandle.path }
|
|
3926
|
+
});
|
|
3927
|
+
this.emit("error", { message, cause: err });
|
|
3928
|
+
throw err;
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
async cancel() {
|
|
3932
|
+
if (this.state.status === "idle")
|
|
3933
|
+
return;
|
|
3934
|
+
if (this.state.status === "preparing") {
|
|
3935
|
+
const prepare = this.prepare;
|
|
3936
|
+
if (prepare) {
|
|
3937
|
+
prepare.requested = true;
|
|
3938
|
+
await prepare.finished;
|
|
3939
|
+
}
|
|
3940
|
+
if (this.state.status !== "preparing") {
|
|
3941
|
+
await this.cancel();
|
|
3942
|
+
} else {
|
|
3943
|
+
this.setState({ status: "idle" });
|
|
3944
|
+
}
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
if (this.state.status === "error") {
|
|
3948
|
+
const { cleanup, salvage } = this.state;
|
|
3949
|
+
await cleanup;
|
|
3950
|
+
if (salvage) {
|
|
3951
|
+
try {
|
|
3952
|
+
await this.deps.sink.remove(salvage.videoPath);
|
|
3953
|
+
} catch {
|
|
3954
|
+
}
|
|
3955
|
+
if (this.deps.markerStore) {
|
|
3956
|
+
await safeMarkerOp(this.deps.markerStore, (store) => removeInProgress(store, salvage.videoPath), "remove-on-cancel", this.deps.logError);
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
this.setState({ status: "idle" });
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
if (this.state.status === "recording" || this.state.status === "stopping") {
|
|
3963
|
+
const r = this.state.resources;
|
|
3964
|
+
r.detachTrackEnded();
|
|
3965
|
+
r.detachStallWatchdog();
|
|
3966
|
+
await teardownResources({
|
|
3967
|
+
strategy: r.strategy,
|
|
3968
|
+
sinkHandle: r.sinkHandle,
|
|
3969
|
+
stream: r.stream,
|
|
3970
|
+
streamOwned: r.streamOwned,
|
|
3971
|
+
compositor: r.compositor
|
|
3972
|
+
});
|
|
3973
|
+
if (this.deps.markerStore) {
|
|
3974
|
+
await safeMarkerOp(this.deps.markerStore, (store) => removeInProgress(store, r.sinkHandle.path), "remove-on-cancel", this.deps.logError);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
this.setState({ status: "idle" });
|
|
3978
|
+
}
|
|
3979
|
+
getPreviewStream() {
|
|
3980
|
+
if (this.state.status !== "recording" && this.state.status !== "stopping") {
|
|
3981
|
+
throw RecordingEngineError.state("getPreviewStream", this.state.status);
|
|
3982
|
+
}
|
|
3983
|
+
return this.state.resources.stream;
|
|
3984
|
+
}
|
|
3985
|
+
getState() {
|
|
3986
|
+
return projectState(this.state);
|
|
3987
|
+
}
|
|
3988
|
+
on(event, handler) {
|
|
3989
|
+
this.listeners[event].add(handler);
|
|
3990
|
+
return () => {
|
|
3991
|
+
this.listeners[event].delete(handler);
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3994
|
+
emit(event, payload) {
|
|
3995
|
+
for (const handler of this.listeners[event]) {
|
|
3996
|
+
try {
|
|
3997
|
+
handler(payload);
|
|
3998
|
+
} catch {
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
setState(state) {
|
|
4003
|
+
this.state = state;
|
|
4004
|
+
this.emit("state", projectState(state));
|
|
4005
|
+
}
|
|
4006
|
+
wrapWithCloudBackup(primary, config) {
|
|
4007
|
+
let cloudHandle;
|
|
4008
|
+
try {
|
|
4009
|
+
cloudHandle = createCloudFileSinkHandle({
|
|
4010
|
+
initUrl: config.initUrl,
|
|
4011
|
+
contentType: config.contentType,
|
|
4012
|
+
recordingId: config.recordingId,
|
|
4013
|
+
fileId: config.fileId,
|
|
4014
|
+
// The OPFS sync-worker handle can re-read committed bytes, which
|
|
4015
|
+
// lets the cloud sink survive back-pressure and upload outages by
|
|
4016
|
+
// tailing the durable file instead of failing terminally.
|
|
4017
|
+
...supportsRangeRead(primary) ? { readSource: (offset, length) => primary.readAt(offset, length) } : {},
|
|
4018
|
+
callbacks: {
|
|
4019
|
+
onLagging: (reason) => {
|
|
4020
|
+
this.deps.logError?.("cloud-lag", `cloud backup fell behind; draining from local file (${reason})`);
|
|
4021
|
+
},
|
|
4022
|
+
onState: (state, info) => {
|
|
4023
|
+
this.emit("cloud", {
|
|
4024
|
+
state,
|
|
4025
|
+
...info?.reason ? { reason: info.reason } : {}
|
|
4026
|
+
});
|
|
4027
|
+
},
|
|
4028
|
+
onProgress: (bytesUploaded) => {
|
|
4029
|
+
this.emit("cloud", {
|
|
4030
|
+
state: cloudHandle.getState(),
|
|
4031
|
+
bytesUploaded
|
|
4032
|
+
});
|
|
4033
|
+
},
|
|
4034
|
+
onSessionAcquired: (sessionUrl) => {
|
|
4035
|
+
this.emit("cloud", {
|
|
4036
|
+
state: cloudHandle.getState(),
|
|
4037
|
+
sessionUrl
|
|
4038
|
+
});
|
|
4039
|
+
},
|
|
4040
|
+
onCleanupError: (context, err) => {
|
|
4041
|
+
this.deps.logError?.(`cloud-${context}`, `${context} failed`, err);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
});
|
|
4045
|
+
} catch (err) {
|
|
4046
|
+
this.emit("cloud", {
|
|
4047
|
+
state: "failed",
|
|
4048
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
4049
|
+
});
|
|
4050
|
+
return primary;
|
|
4051
|
+
}
|
|
4052
|
+
return new TeeFileSinkHandle(primary, cloudHandle, (op, err) => {
|
|
4053
|
+
this.deps.logError?.("cloud-tee", `cloud sink ${op} failed`, err);
|
|
4054
|
+
});
|
|
4055
|
+
}
|
|
4056
|
+
handleStrategyError(err) {
|
|
4057
|
+
if (this.state.status !== "recording")
|
|
4058
|
+
return;
|
|
4059
|
+
const resources = this.state.resources;
|
|
4060
|
+
resources.detachTrackEnded();
|
|
4061
|
+
resources.detachStallWatchdog();
|
|
4062
|
+
const cleanup = commitResources({
|
|
4063
|
+
strategy: resources.strategy,
|
|
4064
|
+
sinkHandle: resources.sinkHandle,
|
|
4065
|
+
stream: resources.stream,
|
|
4066
|
+
streamOwned: resources.streamOwned,
|
|
4067
|
+
compositor: resources.compositor
|
|
4068
|
+
}, this.deps.logError);
|
|
4069
|
+
const message = err.message;
|
|
4070
|
+
this.setState({
|
|
4071
|
+
status: "error",
|
|
4072
|
+
message,
|
|
4073
|
+
cleanup,
|
|
4074
|
+
salvage: { videoPath: resources.sinkHandle.path }
|
|
4075
|
+
});
|
|
4076
|
+
this.emit("error", { message, cause: err });
|
|
4077
|
+
}
|
|
4078
|
+
async acquireStream(options) {
|
|
4079
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
|
4080
|
+
throw RecordingEngineError.capability("navigator.mediaDevices");
|
|
4081
|
+
}
|
|
4082
|
+
const videoConstraints = options.videoDeviceId ? { deviceId: { exact: options.videoDeviceId } } : { ...DEFAULT_VIDEO_CONSTRAINTS };
|
|
4083
|
+
if (options.videoConstraints?.width !== void 0) {
|
|
4084
|
+
videoConstraints.width = { ideal: options.videoConstraints.width };
|
|
4085
|
+
}
|
|
4086
|
+
if (options.videoConstraints?.height !== void 0) {
|
|
4087
|
+
videoConstraints.height = { ideal: options.videoConstraints.height };
|
|
4088
|
+
}
|
|
4089
|
+
if (options.videoConstraints?.frameRate !== void 0) {
|
|
4090
|
+
videoConstraints.frameRate = { ideal: options.videoConstraints.frameRate };
|
|
4091
|
+
}
|
|
4092
|
+
const audioConstraints = options.audioDeviceId ? { deviceId: { exact: options.audioDeviceId } } : {};
|
|
4093
|
+
if (options.audioConstraints) {
|
|
4094
|
+
if (options.audioConstraints.echoCancellation !== void 0) {
|
|
4095
|
+
audioConstraints.echoCancellation = options.audioConstraints.echoCancellation;
|
|
4096
|
+
}
|
|
4097
|
+
if (options.audioConstraints.noiseSuppression !== void 0) {
|
|
4098
|
+
audioConstraints.noiseSuppression = options.audioConstraints.noiseSuppression;
|
|
4099
|
+
}
|
|
4100
|
+
if (options.audioConstraints.autoGainControl !== void 0) {
|
|
4101
|
+
audioConstraints.autoGainControl = options.audioConstraints.autoGainControl;
|
|
4102
|
+
}
|
|
4103
|
+
if (options.audioConstraints.sampleRate !== void 0) {
|
|
4104
|
+
audioConstraints.sampleRate = options.audioConstraints.sampleRate;
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
return navigator.mediaDevices.getUserMedia({
|
|
4108
|
+
video: videoConstraints,
|
|
4109
|
+
audio: audioConstraints
|
|
4110
|
+
});
|
|
4111
|
+
}
|
|
4112
|
+
};
|
|
4113
|
+
function watchTrackEnded(stream, onEnded) {
|
|
4114
|
+
const watched = [];
|
|
4115
|
+
for (const track of stream.getTracks()) {
|
|
4116
|
+
if (typeof track.addEventListener !== "function")
|
|
4117
|
+
continue;
|
|
4118
|
+
const handler = () => onEnded(track);
|
|
4119
|
+
track.addEventListener("ended", handler);
|
|
4120
|
+
watched.push({ track, handler });
|
|
4121
|
+
}
|
|
4122
|
+
return () => {
|
|
4123
|
+
for (const { track, handler } of watched) {
|
|
4124
|
+
try {
|
|
4125
|
+
track.removeEventListener("ended", handler);
|
|
4126
|
+
} catch {
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
};
|
|
4130
|
+
}
|
|
4131
|
+
function stopOwnedStream(r) {
|
|
4132
|
+
if (!r.streamOwned)
|
|
4133
|
+
return;
|
|
4134
|
+
for (const track of r.stream.getTracks()) {
|
|
4135
|
+
try {
|
|
4136
|
+
track.stop();
|
|
4137
|
+
} catch {
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
async function safeMarkerOp(store, op, opName, logError) {
|
|
4142
|
+
try {
|
|
4143
|
+
await op(store);
|
|
4144
|
+
} catch (err) {
|
|
4145
|
+
logError?.("marker", `safeMarkerOp ${opName} failed`, err);
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
async function teardownResources(r) {
|
|
4149
|
+
try {
|
|
4150
|
+
if (r.strategy)
|
|
4151
|
+
await r.strategy.cancel();
|
|
4152
|
+
} catch {
|
|
4153
|
+
}
|
|
4154
|
+
try {
|
|
4155
|
+
if (r.sinkHandle)
|
|
4156
|
+
await r.sinkHandle.discard();
|
|
4157
|
+
} catch {
|
|
4158
|
+
}
|
|
4159
|
+
r.compositor?.stop();
|
|
4160
|
+
if (r.stream)
|
|
4161
|
+
stopOwnedStream({ stream: r.stream, streamOwned: r.streamOwned });
|
|
4162
|
+
}
|
|
4163
|
+
function commitResources(r, logError) {
|
|
4164
|
+
return (async () => {
|
|
4165
|
+
try {
|
|
4166
|
+
if (r.strategy)
|
|
4167
|
+
await r.strategy.cancel();
|
|
4168
|
+
} catch (err) {
|
|
4169
|
+
logError?.("capture", "commitResources: strategy.cancel failed", err);
|
|
4170
|
+
}
|
|
4171
|
+
if (r.sinkHandle) {
|
|
4172
|
+
try {
|
|
4173
|
+
await r.sinkHandle.close();
|
|
4174
|
+
} catch (err) {
|
|
4175
|
+
logError?.("capture", "commitResources: sink close failed, aborting handle", err);
|
|
4176
|
+
try {
|
|
4177
|
+
await r.sinkHandle.abort();
|
|
4178
|
+
} catch {
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
r.compositor?.stop();
|
|
4183
|
+
if (r.stream)
|
|
4184
|
+
stopOwnedStream({ stream: r.stream, streamOwned: r.streamOwned });
|
|
4185
|
+
})();
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
// src/types.ts
|
|
4189
|
+
var CaptureError = class extends Error {
|
|
4190
|
+
code;
|
|
4191
|
+
cause;
|
|
4192
|
+
constructor(code, message, cause) {
|
|
4193
|
+
super(message);
|
|
4194
|
+
this.name = "CaptureError";
|
|
4195
|
+
this.code = code;
|
|
4196
|
+
if (cause !== void 0) this.cause = cause;
|
|
4197
|
+
}
|
|
4198
|
+
};
|
|
4199
|
+
|
|
4200
|
+
// src/transport.ts
|
|
4201
|
+
var TERMINAL_ACCESS_ERRORS = /* @__PURE__ */ new Set([
|
|
4202
|
+
"campaign_closed",
|
|
4203
|
+
"deadline_passed",
|
|
4204
|
+
"link_revoked",
|
|
4205
|
+
"not_found",
|
|
4206
|
+
"forbidden"
|
|
4207
|
+
]);
|
|
4208
|
+
function accessBody(access) {
|
|
4209
|
+
return access.mode === "contact" ? { mode: "contact", contactId: access.contactId, key: access.key } : { mode: "open", campaignId: access.campaignId, key: access.key };
|
|
4210
|
+
}
|
|
4211
|
+
function codeForResponse(status, error) {
|
|
4212
|
+
if (status === 413) return "quota_exceeded";
|
|
4213
|
+
if (status === 429) return "quota_exceeded";
|
|
4214
|
+
if (status === 410 || error !== null && TERMINAL_ACCESS_ERRORS.has(error)) {
|
|
4215
|
+
return error === "campaign_closed" ? "request_closed" : "link_expired";
|
|
4216
|
+
}
|
|
4217
|
+
if (status === 401 || status === 403) return "link_expired";
|
|
4218
|
+
return "upload_failed";
|
|
4219
|
+
}
|
|
4220
|
+
async function postJson(baseUrl, path, body) {
|
|
4221
|
+
let res;
|
|
4222
|
+
try {
|
|
4223
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
4224
|
+
method: "POST",
|
|
4225
|
+
headers: { "content-type": "application/json" },
|
|
4226
|
+
body: JSON.stringify(body)
|
|
4227
|
+
});
|
|
4228
|
+
} catch (err) {
|
|
4229
|
+
throw new CaptureError("network", "Could not reach Capturly.", err);
|
|
4230
|
+
}
|
|
4231
|
+
const json = await res.json().catch(() => null);
|
|
4232
|
+
if (!res.ok) {
|
|
4233
|
+
const error = typeof json?.error === "string" ? json.error : null;
|
|
4234
|
+
throw new CaptureError(
|
|
4235
|
+
codeForResponse(res.status, error),
|
|
4236
|
+
error ?? `Capturly returned ${res.status}.`
|
|
4237
|
+
);
|
|
4238
|
+
}
|
|
4239
|
+
return json ?? {};
|
|
4240
|
+
}
|
|
4241
|
+
async function requestUpload(args) {
|
|
4242
|
+
const json = await postJson(args.baseUrl, "/api/campaigns/respond/upload-url", {
|
|
4243
|
+
...accessBody(args.access),
|
|
4244
|
+
contentType: "video/mp4",
|
|
4245
|
+
sizeBytes: args.sizeBytes
|
|
4246
|
+
});
|
|
4247
|
+
const objectName = typeof json.objectName === "string" ? json.objectName : null;
|
|
4248
|
+
const url2 = typeof json.url === "string" ? json.url : null;
|
|
4249
|
+
if (!objectName || !url2) {
|
|
4250
|
+
throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
|
|
4251
|
+
}
|
|
4252
|
+
return {
|
|
4253
|
+
objectName,
|
|
4254
|
+
url: url2,
|
|
4255
|
+
method: typeof json.method === "string" ? json.method : "PUT",
|
|
4256
|
+
headers: json.headers !== null && typeof json.headers === "object" ? json.headers : {}
|
|
4257
|
+
};
|
|
4258
|
+
}
|
|
4259
|
+
function uploadBlob(args) {
|
|
4260
|
+
return new Promise((resolve, reject) => {
|
|
4261
|
+
const xhr = new XMLHttpRequest();
|
|
4262
|
+
xhr.open(args.target.method, args.target.url, true);
|
|
4263
|
+
for (const [name, value] of Object.entries(args.target.headers)) {
|
|
4264
|
+
xhr.setRequestHeader(name, value);
|
|
4265
|
+
}
|
|
4266
|
+
xhr.upload.onprogress = (event) => {
|
|
4267
|
+
if (!event.lengthComputable || !args.onProgress) return;
|
|
4268
|
+
args.onProgress(Math.min(1, event.loaded / event.total));
|
|
4269
|
+
};
|
|
4270
|
+
xhr.onload = () => {
|
|
4271
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
4272
|
+
args.onProgress?.(1);
|
|
4273
|
+
resolve();
|
|
4274
|
+
return;
|
|
4275
|
+
}
|
|
4276
|
+
reject(new CaptureError("upload_failed", `Storage rejected the upload (${xhr.status}).`));
|
|
4277
|
+
};
|
|
4278
|
+
xhr.onerror = () => reject(new CaptureError("network", "The upload could not reach storage."));
|
|
4279
|
+
xhr.onabort = () => reject(new CaptureError("upload_failed", "The upload was aborted."));
|
|
4280
|
+
xhr.send(args.blob);
|
|
4281
|
+
});
|
|
4282
|
+
}
|
|
4283
|
+
async function finalizeSubmission(args) {
|
|
4284
|
+
const json = await postJson(args.baseUrl, "/api/campaigns/respond/finalize", {
|
|
4285
|
+
...accessBody(args.access),
|
|
4286
|
+
objectName: args.objectName,
|
|
4287
|
+
durationSec: args.durationSec,
|
|
4288
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
4289
|
+
releaseAccepted: args.releaseAccepted
|
|
4290
|
+
});
|
|
4291
|
+
const submissionId = typeof json.submissionId === "string" ? json.submissionId : null;
|
|
4292
|
+
if (!submissionId) {
|
|
4293
|
+
throw new CaptureError(
|
|
4294
|
+
"unknown",
|
|
4295
|
+
"The recording was submitted but Capturly did not return its id."
|
|
4296
|
+
);
|
|
4297
|
+
}
|
|
4298
|
+
return {
|
|
4299
|
+
submissionId,
|
|
4300
|
+
submittedAt: typeof json.submittedAt === "string" ? json.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
4301
|
+
replaced: json.replaced === true
|
|
4302
|
+
};
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
// src/CaptureClient.ts
|
|
4306
|
+
var DEFAULT_BASE_URL = "https://capturly.app";
|
|
4307
|
+
var CaptureClient = class _CaptureClient {
|
|
4308
|
+
#options;
|
|
4309
|
+
#sink = null;
|
|
4310
|
+
#session = null;
|
|
4311
|
+
#stream = null;
|
|
4312
|
+
#pending = null;
|
|
4313
|
+
#state = "idle";
|
|
4314
|
+
#startedAt = 0;
|
|
4315
|
+
#takeCounter = 0;
|
|
4316
|
+
#destroyed = false;
|
|
4317
|
+
#stateListeners = /* @__PURE__ */ new Set();
|
|
4318
|
+
constructor(options) {
|
|
4319
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
4320
|
+
this.#options = {
|
|
4321
|
+
access: options.access,
|
|
4322
|
+
baseUrl,
|
|
4323
|
+
onWarning: options.onWarning ?? (() => {
|
|
4324
|
+
})
|
|
4325
|
+
};
|
|
4326
|
+
}
|
|
4327
|
+
/** What the recorder is doing right now. */
|
|
4328
|
+
get state() {
|
|
4329
|
+
return this.#state;
|
|
4330
|
+
}
|
|
4331
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
4332
|
+
onStateChange(listener) {
|
|
4333
|
+
this.#stateListeners.add(listener);
|
|
4334
|
+
return () => this.#stateListeners.delete(listener);
|
|
4335
|
+
}
|
|
4336
|
+
/**
|
|
4337
|
+
* Whether this browser can record. Check before showing a record button; a
|
|
4338
|
+
* browser that fails cannot be retried into working, so offer another one.
|
|
4339
|
+
*/
|
|
4340
|
+
static isSupported() {
|
|
4341
|
+
try {
|
|
4342
|
+
const caps = detectCapabilities();
|
|
4343
|
+
return caps.mux !== null && caps.opfs && caps.mediaDevices;
|
|
4344
|
+
} catch {
|
|
4345
|
+
return false;
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
/**
|
|
4349
|
+
* Cameras and microphones. Labels are blank until permission has been
|
|
4350
|
+
* granted once, so call this again after `preview()` to fill a picker.
|
|
4351
|
+
*/
|
|
4352
|
+
async listDevices() {
|
|
4353
|
+
this.#assertAlive();
|
|
4354
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
4355
|
+
return devices.filter((d) => d.kind === "audioinput" || d.kind === "videoinput").map((d) => ({
|
|
4356
|
+
deviceId: d.deviceId,
|
|
4357
|
+
label: d.label,
|
|
4358
|
+
kind: d.kind
|
|
4359
|
+
}));
|
|
4360
|
+
}
|
|
4361
|
+
/**
|
|
4362
|
+
* Request the camera and microphone and return the stream to render.
|
|
4363
|
+
* Separate from `start()` so people can see themselves before recording.
|
|
4364
|
+
*/
|
|
4365
|
+
async preview(options = {}) {
|
|
4366
|
+
this.#assertAlive();
|
|
4367
|
+
if (!_CaptureClient.isSupported()) {
|
|
4368
|
+
throw new CaptureError(
|
|
4369
|
+
"unsupported_browser",
|
|
4370
|
+
"This browser cannot record. Chrome, Edge, Firefox or Safari 16.4+ can."
|
|
4371
|
+
);
|
|
4372
|
+
}
|
|
4373
|
+
const constraints = {
|
|
4374
|
+
audio: options.audioDeviceId ? { deviceId: { exact: options.audioDeviceId } } : true,
|
|
4375
|
+
video: options.audioOnly ? false : options.videoDeviceId ? { deviceId: { exact: options.videoDeviceId } } : true
|
|
4376
|
+
};
|
|
4377
|
+
let stream;
|
|
4378
|
+
try {
|
|
4379
|
+
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
4380
|
+
} catch (err) {
|
|
4381
|
+
throw new CaptureError(this.#gumCode(err), this.#gumMessage(err), err);
|
|
4382
|
+
}
|
|
4383
|
+
this.#stopStream();
|
|
4384
|
+
this.#stream = stream;
|
|
4385
|
+
return stream;
|
|
4386
|
+
}
|
|
4387
|
+
/** Begin recording the current preview stream. */
|
|
4388
|
+
async start() {
|
|
4389
|
+
this.#assertAlive();
|
|
4390
|
+
const stream = this.#stream;
|
|
4391
|
+
if (!stream) {
|
|
4392
|
+
throw new CaptureError("not_recording", "Call preview() before start().");
|
|
4393
|
+
}
|
|
4394
|
+
if (this.#session) {
|
|
4395
|
+
throw new CaptureError("not_recording", "Already recording.");
|
|
4396
|
+
}
|
|
4397
|
+
this.#setState("starting");
|
|
4398
|
+
this.#sink ??= new OpfsFileSink({ logError: (m, e) => this.#options.onWarning(m, e) });
|
|
4399
|
+
const session = new CaptureSession({
|
|
4400
|
+
sink: this.#sink,
|
|
4401
|
+
surface: "web",
|
|
4402
|
+
logError: (m, e) => this.#options.onWarning(m, e)
|
|
4403
|
+
});
|
|
4404
|
+
this.#takeCounter += 1;
|
|
4405
|
+
try {
|
|
4406
|
+
await session.start({
|
|
4407
|
+
// Local grouping only; the server derives the real object path.
|
|
4408
|
+
sessionId: asSessionId(`sdk-${Date.now()}`),
|
|
4409
|
+
participantId: asParticipantId(`sdk-t${this.#takeCounter}`),
|
|
4410
|
+
streamOverride: stream
|
|
4411
|
+
});
|
|
4412
|
+
} catch (err) {
|
|
4413
|
+
this.#setState("idle");
|
|
4414
|
+
throw new CaptureError("unknown", "Recording could not start.", err);
|
|
4415
|
+
}
|
|
4416
|
+
this.#session = session;
|
|
4417
|
+
this.#startedAt = Date.now();
|
|
4418
|
+
this.#setState("recording");
|
|
4419
|
+
}
|
|
4420
|
+
/**
|
|
4421
|
+
* Stop and return the take. Nothing has left the browser yet — the file sits
|
|
4422
|
+
* in OPFS until `submit()`, so it can be reviewed or re-recorded first.
|
|
4423
|
+
*/
|
|
4424
|
+
async stop() {
|
|
4425
|
+
this.#assertAlive();
|
|
4426
|
+
const session = this.#session;
|
|
4427
|
+
if (!session) {
|
|
4428
|
+
throw new CaptureError("not_recording", "Not recording.");
|
|
4429
|
+
}
|
|
4430
|
+
this.#session = null;
|
|
4431
|
+
this.#setState("stopping");
|
|
4432
|
+
let artifact;
|
|
4433
|
+
try {
|
|
4434
|
+
artifact = await session.stop();
|
|
4435
|
+
} catch (err) {
|
|
4436
|
+
this.#setState("idle");
|
|
4437
|
+
throw new CaptureError("unknown", "Recording could not be finished.", err);
|
|
4438
|
+
}
|
|
4439
|
+
const durationSec = Math.max(1, Math.round((Date.now() - this.#startedAt) / 1e3));
|
|
4440
|
+
let previewUrl = null;
|
|
4441
|
+
let sizeBytes = 0;
|
|
4442
|
+
try {
|
|
4443
|
+
const blob = await this.#sink?.read(artifact.videoPath);
|
|
4444
|
+
if (blob) {
|
|
4445
|
+
previewUrl = URL.createObjectURL(blob);
|
|
4446
|
+
sizeBytes = blob.size;
|
|
4447
|
+
}
|
|
4448
|
+
} catch (err) {
|
|
4449
|
+
this.#options.onWarning("take preview could not be read", err);
|
|
4450
|
+
}
|
|
4451
|
+
this.#discardPending();
|
|
4452
|
+
this.#pending = { path: artifact.videoPath, durationSec, previewUrl, sizeBytes };
|
|
4453
|
+
this.#setState("stopped");
|
|
4454
|
+
return { durationSec, previewUrl, sizeBytes };
|
|
4455
|
+
}
|
|
4456
|
+
/**
|
|
4457
|
+
* Upload the take and record the submission: request a signed target, PUT
|
|
4458
|
+
* the bytes straight to storage, then confirm. The API never sees the bytes.
|
|
4459
|
+
* Confirming is keyed on the object name, so a retry after a lost response
|
|
4460
|
+
* resolves to the same submission.
|
|
4461
|
+
*/
|
|
4462
|
+
async submit(options) {
|
|
4463
|
+
this.#assertAlive();
|
|
4464
|
+
const pending = this.#pending;
|
|
4465
|
+
if (!pending) {
|
|
4466
|
+
throw new CaptureError("nothing_to_submit", "Record something before submitting.");
|
|
4467
|
+
}
|
|
4468
|
+
if (this.#options.access.mode === "open" && !options.displayName) {
|
|
4469
|
+
throw new CaptureError(
|
|
4470
|
+
"nothing_to_submit",
|
|
4471
|
+
"An open link has no roster name \u2014 pass displayName."
|
|
4472
|
+
);
|
|
4473
|
+
}
|
|
4474
|
+
const blob = await this.#sink?.read(pending.path);
|
|
4475
|
+
if (!blob) {
|
|
4476
|
+
throw new CaptureError("nothing_to_submit", "The recording is no longer in local storage.");
|
|
4477
|
+
}
|
|
4478
|
+
const target = await requestUpload({
|
|
4479
|
+
baseUrl: this.#options.baseUrl,
|
|
4480
|
+
access: this.#options.access,
|
|
4481
|
+
sizeBytes: blob.size
|
|
4482
|
+
});
|
|
4483
|
+
await uploadBlob({
|
|
4484
|
+
target,
|
|
4485
|
+
blob,
|
|
4486
|
+
...options.onProgress ? { onProgress: options.onProgress } : {}
|
|
4487
|
+
});
|
|
4488
|
+
const result = await finalizeSubmission({
|
|
4489
|
+
baseUrl: this.#options.baseUrl,
|
|
4490
|
+
access: this.#options.access,
|
|
4491
|
+
objectName: target.objectName,
|
|
4492
|
+
durationSec: pending.durationSec,
|
|
4493
|
+
displayName: options.displayName,
|
|
4494
|
+
releaseAccepted: options.releaseAccepted
|
|
4495
|
+
});
|
|
4496
|
+
this.#discardPending();
|
|
4497
|
+
return result;
|
|
4498
|
+
}
|
|
4499
|
+
/** Throw the take away without submitting, so someone can record again. */
|
|
4500
|
+
discard() {
|
|
4501
|
+
this.#discardPending();
|
|
4502
|
+
this.#setState("idle");
|
|
4503
|
+
}
|
|
4504
|
+
/**
|
|
4505
|
+
* Release the camera, microphone and recorder. Call it on unmount, or the
|
|
4506
|
+
* camera light stays on after the person has navigated away.
|
|
4507
|
+
*/
|
|
4508
|
+
destroy() {
|
|
4509
|
+
this.#destroyed = true;
|
|
4510
|
+
void this.#session?.stop().catch(() => {
|
|
4511
|
+
});
|
|
4512
|
+
this.#session = null;
|
|
4513
|
+
this.#stopStream();
|
|
4514
|
+
this.#discardPending();
|
|
4515
|
+
this.#stateListeners.clear();
|
|
4516
|
+
}
|
|
4517
|
+
// -------------------------------------------------------------------------
|
|
4518
|
+
#assertAlive() {
|
|
4519
|
+
if (this.#destroyed) {
|
|
4520
|
+
throw new CaptureError("not_recording", "This client has been destroyed.");
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
#setState(state) {
|
|
4524
|
+
this.#state = state;
|
|
4525
|
+
for (const listener of this.#stateListeners) listener(state);
|
|
4526
|
+
}
|
|
4527
|
+
#stopStream() {
|
|
4528
|
+
this.#stream?.getTracks().forEach((t) => t.stop());
|
|
4529
|
+
this.#stream = null;
|
|
4530
|
+
}
|
|
4531
|
+
#discardPending() {
|
|
4532
|
+
if (this.#pending?.previewUrl) URL.revokeObjectURL(this.#pending.previewUrl);
|
|
4533
|
+
this.#pending = null;
|
|
4534
|
+
}
|
|
4535
|
+
/** getUserMedia DOMException names, mapped to codes. */
|
|
4536
|
+
#gumCode(err) {
|
|
4537
|
+
const name = err?.name;
|
|
4538
|
+
if (name === "NotAllowedError" || name === "SecurityError") return "permission_denied";
|
|
4539
|
+
if (name === "NotFoundError" || name === "OverconstrainedError") return "no_devices";
|
|
4540
|
+
return "unknown";
|
|
4541
|
+
}
|
|
4542
|
+
#gumMessage(err) {
|
|
4543
|
+
switch (this.#gumCode(err)) {
|
|
4544
|
+
case "permission_denied":
|
|
4545
|
+
return "Camera and microphone access was denied.";
|
|
4546
|
+
case "no_devices":
|
|
4547
|
+
return "No camera or microphone was available.";
|
|
4548
|
+
default:
|
|
4549
|
+
return "The camera and microphone could not be opened.";
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
};
|
|
4553
|
+
export {
|
|
4554
|
+
CaptureClient,
|
|
4555
|
+
CaptureError
|
|
4556
|
+
};
|