@incodetech/core 2.3.0-rc.8 → 2.3.0
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/dist/{BrowserDiagnosticsCryptoProvider-5-MGxBk6.esm.js → BrowserDiagnosticsCryptoProvider-BuzqvbhN.esm.js} +6 -24
- package/dist/{FaceDetectionProvider-DypXyX5c.esm.js → FaceDetectionProvider-Lb6i4Ilt.esm.js} +7 -7
- package/dist/{MotionSensorProvider-BgTYwg9L.esm.js → MotionSensorProvider-DyhbM4Si.esm.js} +1 -1
- package/dist/ae-signature.esm.js +1 -1
- package/dist/{apiError-4Pik-5MI.esm.js → apiError-Dkl9_HOx.esm.js} +1 -1
- package/dist/authentication.d.ts +2 -2
- package/dist/authentication.esm.js +2 -2
- package/dist/{authenticationManager-DcTHmYmL.d.ts → authenticationManager-B23V74p7.d.ts} +1 -1
- package/dist/{authenticationManager-DOINObSm.esm.js → authenticationManager-C7BaQC99.esm.js} +4 -4
- package/dist/{authenticationStateMachine-BIFwZ10i.esm.js → authenticationStateMachine-CW7a9qpV.esm.js} +3 -3
- package/dist/camera.esm.js +1 -1
- package/dist/consent.d.ts +1 -1
- package/dist/{consentManager-3Pbtj-RV.d.ts → consentManager-uhDPdU6i.d.ts} +4 -4
- package/dist/{createCameraCycleOwnership-CX3hJGKc.esm.js → createCameraCycleOwnership-DnwTl1ad.esm.js} +6 -1
- package/dist/{deepsightLoader-DkFE8YIF.esm.js → deepsightLoader-DgaaacGJ.esm.js} +6 -6
- package/dist/document-capture.d.ts +1 -1
- package/dist/document-capture.esm.js +2 -2
- package/dist/{documentCaptureManager-BeR7ttQy.esm.js → documentCaptureManager-BRPxqYD2.esm.js} +1 -1
- package/dist/{documentCaptureManager-DD0qtFdE.d.ts → documentCaptureManager-W7ZXcuvL.d.ts} +14 -0
- package/dist/{documentCaptureStateMachine-C_8bQ3fw.esm.js → documentCaptureStateMachine-D312sSIu.esm.js} +12 -7
- package/dist/dynamic-forms.esm.js +2 -2
- package/dist/{dynamicFormsManager-CBULYDVK.esm.js → dynamicFormsManager-Cm33oGqC.esm.js} +1 -1
- package/dist/{dynamicFormsStateMachine-BZOa354n.esm.js → dynamicFormsStateMachine-4EVsg15i.esm.js} +1 -1
- package/dist/ekyb.esm.js +3 -3
- package/dist/{ekybManager-TkRpTfhD.esm.js → ekybManager-CgjQ2Thl.esm.js} +1 -1
- package/dist/{ekybStateMachine-CsNFTbTv.esm.js → ekybStateMachine-Dwgt5Dqa.esm.js} +1 -1
- package/dist/electronic-signature.esm.js +1 -1
- package/dist/{electronicSignatureManager-D_EKcYMu.esm.js → electronicSignatureManager-DGEUq9nY.esm.js} +1 -1
- package/dist/extensibility.d.ts +7 -7
- package/dist/extensibility.esm.js +12 -12
- package/dist/{faceCaptureManagerFactory-vZTG70oT.esm.js → faceCaptureManagerFactory-3x7qaM76.esm.js} +3 -3
- package/dist/{faceCaptureManagerFactory-Cx-8SqRy.d.ts → faceCaptureManagerFactory-DMLstvui.d.ts} +35 -3
- package/dist/{faceCaptureSetup-DJuqgytw.esm.js → faceCaptureSetup-CJzsvv1I.esm.js} +10 -9
- package/dist/{feature-management-CZ-nMsqU.esm.js → feature-management-BG6sf4O4.esm.js} +1 -1
- package/dist/feature-management.esm.js +1 -1
- package/dist/flow.esm.js +3 -3
- package/dist/{flowCompletionService-tBWtQeo7.esm.js → flowCompletionService-BusrkMdp.esm.js} +1 -1
- package/dist/{flowServices-CAgj_Her.esm.js → flowServices-pIc1cq8q.esm.js} +5 -5
- package/dist/geolocation.esm.js +2 -2
- package/dist/{geolocationManager-CVuxiwBM.esm.js → geolocationManager-BbgMboZ-.esm.js} +1 -1
- package/dist/{geolocationStateMachine-CQqZ5gcL.esm.js → geolocationStateMachine-Bixn87pI.esm.js} +1 -1
- package/dist/id-selfie-flow.d.ts +1 -1
- package/dist/id-selfie-flow.esm.js +9 -10
- package/dist/id.d.ts +1 -1
- package/dist/id.esm.js +3 -3
- package/dist/{idCaptureDefaults-bznawUFX.esm.js → idCaptureDefaults-BmnsDgZZ.esm.js} +3 -3
- package/dist/{idCaptureManager-De7O0TOD.d.ts → idCaptureManager-BaHEyQyr.d.ts} +0 -1
- package/dist/{idCaptureManager-DptfJ9IG.esm.js → idCaptureManager-BpdRLyqZ.esm.js} +5 -5
- package/dist/{idCaptureServices-BEJ8nz7l.esm.js → idCaptureServices-Di_APvz_.esm.js} +9 -8
- package/dist/{idCaptureStateMachine-Cx0T0ARJ.esm.js → idCaptureStateMachine-hY-RpDH_.esm.js} +14 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/mandatory-consent.d.ts +1 -1
- package/dist/{mandatoryConsentManager-vhv1co_n.d.ts → mandatoryConsentManager-Csfl7_XQ.d.ts} +4 -4
- package/dist/{multipartRecordingService-CuXLH4tK.esm.js → multipartRecordingService-mwKFTlhR.esm.js} +733 -15
- package/dist/{permissionGuards-uYZYFu4J.esm.js → permissionGuards-D6TfVop2.esm.js} +1 -1
- package/dist/{permissionServices-Bj_fyWtv.esm.js → permissionServices-EY3_Vx6c.esm.js} +2 -2
- package/dist/qe-signature.esm.js +1 -1
- package/dist/{runChildModule-D22IPoPN.esm.js → runChildModule-kZQwHJ21.esm.js} +1 -1
- package/dist/{safeStorage-B-trhxg6.esm.js → safeStorage-BdU9_m2E.esm.js} +1 -1
- package/dist/{sdkVersion-CRxt5823.esm.js → sdkVersion-KY79FtZx.esm.js} +1 -1
- package/dist/selfie.d.ts +2 -2
- package/dist/selfie.esm.js +7 -7
- package/dist/{selfieManager-DfH-2r_c.esm.js → selfieManager-Bjane9c3.esm.js} +4 -4
- package/dist/{selfieManager-jXqwqosi.d.ts → selfieManager-CDCTOrcg.d.ts} +1 -1
- package/dist/{selfieStateMachine-DPnxoVg_.esm.js → selfieStateMachine-OsbjyRR6.esm.js} +3 -3
- package/dist/{session-CVnTjODB.esm.js → session-D4EAR74k.esm.js} +3 -3
- package/dist/session.esm.js +4 -4
- package/dist/{sessionInitializer-C9m14Xqj.esm.js → sessionInitializer-BqGb0lFN.esm.js} +3 -3
- package/dist/{setup-COWDO2Xv.esm.js → setup-Cc6MzR5Q.esm.js} +7 -57
- package/dist/{setup-CLfBztP0.d.ts → setup-Cyko6cAn.d.ts} +4 -17
- package/dist/{trueSightService-DOiLWpRP.esm.js → trueSightService-Di5RGIls.esm.js} +16 -554
- package/dist/video-selfie.d.ts +61 -3
- package/dist/video-selfie.esm.js +60 -14
- package/dist/{videoSelfieCameraWarmer-Dq9ffySJ.esm.js → videoSelfieCameraWarmer-PV8PLwmD.esm.js} +5 -5
- package/dist/{videoSelfieStepDetector-Co0Gu2k2.d.ts → videoSelfieStepDetector-CAqAG2G3.d.ts} +49 -0
- package/dist/{warmup-CUUnwYxe.esm.js → warmup-ChDwHSel.esm.js} +5 -0
- package/dist/wasm.d.ts +1 -1
- package/dist/wasm.esm.js +3 -3
- package/dist/{wasmDsFlagSync-B2e8p268.esm.js → wasmDsFlagSync-DYrLUEII.esm.js} +1 -1
- package/dist/workflow.esm.js +15 -15
- package/package.json +2 -7
- package/dist/FaceDetectionProvider-LdZ47bWy.d.ts +0 -36
- package/dist/StreamCanvasProcessingSession-CBnbTnOr.esm.js +0 -42
- package/dist/personhood.d.ts +0 -538
- package/dist/personhood.esm.js +0 -1574
- package/dist/types-BT_Oalip.d.ts +0 -1
package/dist/{multipartRecordingService-CuXLH4tK.esm.js → multipartRecordingService-mwKFTlhR.esm.js}
RENAMED
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
import { t as BrowserTimerProvider } from "./BrowserTimerProvider-BXhgNxLt.esm.js";
|
|
2
2
|
import { t as api } from "./api-Bx3gKW_y.esm.js";
|
|
3
3
|
import { t as endpoints } from "./endpoints-B-3Q-LUw.esm.js";
|
|
4
|
-
import { a as isIOS
|
|
4
|
+
import { a as isIOS } from "./platform-DkcRc9JV.esm.js";
|
|
5
|
+
//#region ../infra/src/media/StreamCanvasProcessingSession.ts
|
|
6
|
+
var StreamCanvasProcessingSession = class {
|
|
7
|
+
/**
|
|
8
|
+
* Creates a processing session that reacts to `StreamCanvasCapture` frame events
|
|
9
|
+
* and drives a provider's `processFrame()` method with backpressure.
|
|
10
|
+
*/
|
|
11
|
+
constructor(params) {
|
|
12
|
+
this.disposed = false;
|
|
13
|
+
this.isProcessing = false;
|
|
14
|
+
this.onFrameEvent = () => {
|
|
15
|
+
if (this.disposed || this.isProcessing) return;
|
|
16
|
+
const frame = this.capturer.getLatestFrame();
|
|
17
|
+
if (!frame) return;
|
|
18
|
+
this.onFrame?.(frame);
|
|
19
|
+
this.isProcessing = true;
|
|
20
|
+
this.provider.processFrame(frame).catch(() => {}).finally(() => {
|
|
21
|
+
this.isProcessing = false;
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
this.capturer = params.capturer;
|
|
25
|
+
this.provider = params.provider;
|
|
26
|
+
this.onFrame = params.onFrame;
|
|
27
|
+
this.capturer.addEventListener("frame", this.onFrameEvent);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Unsubscribes from frame events and resets the provider.
|
|
31
|
+
*/
|
|
32
|
+
dispose() {
|
|
33
|
+
if (this.disposed) return;
|
|
34
|
+
this.disposed = true;
|
|
35
|
+
this.capturer.removeEventListener("frame", this.onFrameEvent);
|
|
36
|
+
this.provider.reset();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns whether the session has been disposed.
|
|
40
|
+
*/
|
|
41
|
+
isDisposed() {
|
|
42
|
+
return this.disposed;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
5
46
|
//#region ../infra/src/media/mediaRecorderOptions.ts
|
|
6
47
|
/**
|
|
7
48
|
* Picks a `MediaRecorder` mime type the current browser actually supports.
|
|
@@ -19,14 +60,10 @@ function getSupportedMediaRecorderMimeType() {
|
|
|
19
60
|
return "";
|
|
20
61
|
}
|
|
21
62
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* the service already ingests from Safari. Measured on a Galaxy S21 under ID
|
|
25
|
-
* detection load (ENG-55397): 720p VP8 dropped 16% of frames, H.264 dropped
|
|
26
|
-
* none. Everything else keeps the WebM-first order.
|
|
63
|
+
* Multipart recordings use the same WebM-first order. Some Android devices
|
|
64
|
+
* advertise H.264 support but fail after MediaRecorder starts (ENG-56439).
|
|
27
65
|
*/
|
|
28
66
|
function getMultipartRecorderMimeType() {
|
|
29
|
-
if (isAndroid() && MediaRecorder.isTypeSupported("video/mp4;codecs=avc1")) return "video/mp4;codecs=avc1";
|
|
30
67
|
return getSupportedMediaRecorderMimeType();
|
|
31
68
|
}
|
|
32
69
|
/**
|
|
@@ -46,6 +83,33 @@ function getAdaptiveMediaRecorderOptions() {
|
|
|
46
83
|
bitsPerSecond: 5e5
|
|
47
84
|
};
|
|
48
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Worker-encoded multipart recording profile (ENG-55397). 2 GB-class devices
|
|
88
|
+
* (`navigator.deviceMemory` ≤ 2) record 360p at 15 fps: on a Galaxy A01 Core,
|
|
89
|
+
* 720p30 plus WASM detection exhausted memory (renderer swapped, GPU process
|
|
90
|
+
* killed), and even the in-worker encoder starved the camera preview to
|
|
91
|
+
* 6 fps. They also upload 2 s parts: every part is one request through the
|
|
92
|
+
* WASM HTTP client on the main thread, and at 0.5 s parts the uploads fell
|
|
93
|
+
* behind during face detection, stretching the final "processing" wait.
|
|
94
|
+
* Other devices, and browsers without `deviceMemory`, keep 720p30 at the
|
|
95
|
+
* MediaRecorder bitrate and 0.5 s parts.
|
|
96
|
+
*/
|
|
97
|
+
function getWorkerRecordingProfile(deviceMemoryGb = typeof navigator !== "undefined" ? navigator.deviceMemory : void 0) {
|
|
98
|
+
if (deviceMemoryGb !== void 0 && deviceMemoryGb <= 2) return {
|
|
99
|
+
maxLongEdge: 640,
|
|
100
|
+
maxShortEdge: 360,
|
|
101
|
+
fps: 15,
|
|
102
|
+
bitrate: 6e5,
|
|
103
|
+
timesliceMs: 2e3
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
maxLongEdge: 1280,
|
|
107
|
+
maxShortEdge: 720,
|
|
108
|
+
fps: 30,
|
|
109
|
+
bitrate: 25e5,
|
|
110
|
+
timesliceMs: 500
|
|
111
|
+
};
|
|
112
|
+
}
|
|
49
113
|
//#endregion
|
|
50
114
|
//#region ../infra/src/media/cameraCompositorWorker.ts
|
|
51
115
|
/**
|
|
@@ -85,6 +149,9 @@ function cameraCompositorWorkerMain(scope) {
|
|
|
85
149
|
let generation = 0;
|
|
86
150
|
let paused = false;
|
|
87
151
|
let announceNextFrame = false;
|
|
152
|
+
let minFrameIntervalUs = 0;
|
|
153
|
+
let lastWrittenUs = -Infinity;
|
|
154
|
+
let encodeConfig = null;
|
|
88
155
|
const boundedSize = (width, height) => {
|
|
89
156
|
const scale = Math.min(1, maxLongEdge / Math.max(width, height), maxShortEdge / Math.min(width, height));
|
|
90
157
|
return {
|
|
@@ -104,6 +171,423 @@ function cameraCompositorWorkerMain(scope) {
|
|
|
104
171
|
frame.close();
|
|
105
172
|
return out;
|
|
106
173
|
};
|
|
174
|
+
const video = {
|
|
175
|
+
number: 1,
|
|
176
|
+
offsetUs: null,
|
|
177
|
+
lastUs: -1
|
|
178
|
+
};
|
|
179
|
+
const audio = {
|
|
180
|
+
number: 2,
|
|
181
|
+
offsetUs: null,
|
|
182
|
+
lastUs: -1
|
|
183
|
+
};
|
|
184
|
+
let videoEncoder = null;
|
|
185
|
+
let audioEncoder = null;
|
|
186
|
+
let hasAudioSource = false;
|
|
187
|
+
let audioWanted = false;
|
|
188
|
+
let audioFormat = null;
|
|
189
|
+
let audioInHeader = false;
|
|
190
|
+
/**
|
|
191
|
+
* The Opus encoder numbers its output by sample count from its first input,
|
|
192
|
+
* so track where that count has reached on our timeline (µs).
|
|
193
|
+
*/
|
|
194
|
+
let audioClockStartUs = null;
|
|
195
|
+
let audioFedUs = 0;
|
|
196
|
+
let recording = "off";
|
|
197
|
+
let encodedSize = {
|
|
198
|
+
width: 0,
|
|
199
|
+
height: 0
|
|
200
|
+
};
|
|
201
|
+
let originMs = 0;
|
|
202
|
+
let pausedMs = 0;
|
|
203
|
+
let pauseStartedMs = 0;
|
|
204
|
+
/** Pacer: source time the next frame is due (schedule, not last frame). */
|
|
205
|
+
let nextDueUs = -Infinity;
|
|
206
|
+
let lastKeyUs = -Infinity;
|
|
207
|
+
let forceKey = true;
|
|
208
|
+
let headerSent = false;
|
|
209
|
+
let lastFlushMs = 0;
|
|
210
|
+
/** Oldest pending block timestamp (µs); pending is small but hot. */
|
|
211
|
+
let oldestPendingUs = Infinity;
|
|
212
|
+
let pending = [];
|
|
213
|
+
const concat = (parts) => {
|
|
214
|
+
let length = 0;
|
|
215
|
+
for (const part of parts) length += part.length;
|
|
216
|
+
const out = new Uint8Array(length);
|
|
217
|
+
let offset = 0;
|
|
218
|
+
for (const part of parts) {
|
|
219
|
+
out.set(part, offset);
|
|
220
|
+
offset += part.length;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
};
|
|
224
|
+
const uint = (value) => {
|
|
225
|
+
const out = [];
|
|
226
|
+
let rest = value;
|
|
227
|
+
do {
|
|
228
|
+
out.unshift(rest % 256);
|
|
229
|
+
rest = Math.floor(rest / 256);
|
|
230
|
+
} while (rest > 0);
|
|
231
|
+
return out;
|
|
232
|
+
};
|
|
233
|
+
/** EBML variable-length size, shortest form. */
|
|
234
|
+
const vsize = (size) => {
|
|
235
|
+
for (let length = 1; length <= 8; length += 1) if (size < 2 ** (7 * length) - 1) {
|
|
236
|
+
const out = uint(size);
|
|
237
|
+
while (out.length < length) out.unshift(0);
|
|
238
|
+
out[0] |= 128 >> length - 1;
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
throw new Error("EBML element too large");
|
|
242
|
+
};
|
|
243
|
+
const element = (id, ...body) => {
|
|
244
|
+
const data = concat(body);
|
|
245
|
+
return concat([
|
|
246
|
+
id,
|
|
247
|
+
vsize(data.length),
|
|
248
|
+
data
|
|
249
|
+
]);
|
|
250
|
+
};
|
|
251
|
+
const text = (value) => Array.from(value, (char) => char.charCodeAt(0));
|
|
252
|
+
const float64 = (value) => {
|
|
253
|
+
const view = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(8));
|
|
254
|
+
view.setFloat64(0, value);
|
|
255
|
+
return Array.from(new Uint8Array(view.buffer));
|
|
256
|
+
};
|
|
257
|
+
/** RFC 7845 identification header: version 1, pre-skip 312, family 0. */
|
|
258
|
+
const opusHead = (channels, sampleRate) => [
|
|
259
|
+
...text("OpusHead"),
|
|
260
|
+
1,
|
|
261
|
+
channels,
|
|
262
|
+
56,
|
|
263
|
+
1,
|
|
264
|
+
sampleRate & 255,
|
|
265
|
+
sampleRate >> 8 & 255,
|
|
266
|
+
sampleRate >> 16 & 255,
|
|
267
|
+
sampleRate >> 24 & 255,
|
|
268
|
+
0,
|
|
269
|
+
0,
|
|
270
|
+
0
|
|
271
|
+
];
|
|
272
|
+
const webmHeader = () => {
|
|
273
|
+
const videoEntry = element([174], element([215], [1]), element([115, 197], [1]), element([131], [1]), element([134], text("V_VP8")), element([224], element([176], uint(encodedSize.width)), element([186], uint(encodedSize.height))));
|
|
274
|
+
const audioEntry = audioInHeader && audioFormat ? element([174], element([215], [2]), element([115, 197], [2]), element([131], [2]), element([134], text("A_OPUS")), element([99, 162], opusHead(audioFormat.channels, audioFormat.sampleRate)), element([225], element([181], float64(48e3)), element([159], [audioFormat.channels]))) : /* @__PURE__ */ new Uint8Array(0);
|
|
275
|
+
return concat([
|
|
276
|
+
element([
|
|
277
|
+
26,
|
|
278
|
+
69,
|
|
279
|
+
223,
|
|
280
|
+
163
|
|
281
|
+
], element([66, 134], [1]), element([66, 247], [1]), element([66, 242], [4]), element([66, 243], [8]), element([66, 130], text("webm")), element([66, 135], [4]), element([66, 133], [2])),
|
|
282
|
+
[
|
|
283
|
+
24,
|
|
284
|
+
83,
|
|
285
|
+
128,
|
|
286
|
+
103,
|
|
287
|
+
1,
|
|
288
|
+
255,
|
|
289
|
+
255,
|
|
290
|
+
255,
|
|
291
|
+
255,
|
|
292
|
+
255,
|
|
293
|
+
255,
|
|
294
|
+
255
|
|
295
|
+
],
|
|
296
|
+
element([
|
|
297
|
+
21,
|
|
298
|
+
73,
|
|
299
|
+
169,
|
|
300
|
+
102
|
|
301
|
+
], element([
|
|
302
|
+
42,
|
|
303
|
+
215,
|
|
304
|
+
177
|
|
305
|
+
], uint(1e6)), element([77, 128], text("incode")), element([87, 65], text("incode"))),
|
|
306
|
+
element([
|
|
307
|
+
22,
|
|
308
|
+
84,
|
|
309
|
+
174,
|
|
310
|
+
107
|
|
311
|
+
], videoEntry, audioEntry)
|
|
312
|
+
]);
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Writes every pending block older than `limitUs` as one cluster (one
|
|
316
|
+
* upload part). The first part carries the header, so it waits (up to
|
|
317
|
+
* 3 s) for the microphone's format when a microphone is attached.
|
|
318
|
+
*/
|
|
319
|
+
const flushBefore = (limitUs, force = false) => {
|
|
320
|
+
if (pending.length === 0) return;
|
|
321
|
+
const oldestUs = oldestPendingUs;
|
|
322
|
+
const timesliceMs = encodeConfig?.timesliceMs ?? 500;
|
|
323
|
+
if (!force && performance.now() - lastFlushMs < timesliceMs * .8) return;
|
|
324
|
+
if (!force && !headerSent && audioWanted && !audioFormat && limitUs - oldestUs < 3e6) return;
|
|
325
|
+
const ready = pending.filter((block) => block.us < limitUs).sort((a, b) => a.us - b.us || a.track - b.track);
|
|
326
|
+
if (ready.length === 0) return;
|
|
327
|
+
pending = pending.filter((block) => block.us >= limitUs);
|
|
328
|
+
oldestPendingUs = pending.reduce((min, block) => Math.min(min, block.us), Infinity);
|
|
329
|
+
if (!headerSent) audioInHeader = audioFormat !== null;
|
|
330
|
+
const blocks = ready.filter((block) => block.track === 1 || audioInHeader);
|
|
331
|
+
if (blocks.length === 0) return;
|
|
332
|
+
const clusters = [];
|
|
333
|
+
let run = [];
|
|
334
|
+
const closeRun = () => {
|
|
335
|
+
if (run.length === 0) return;
|
|
336
|
+
const clusterMs = Math.floor(run[0].us / 1e3);
|
|
337
|
+
clusters.push(element([
|
|
338
|
+
31,
|
|
339
|
+
67,
|
|
340
|
+
182,
|
|
341
|
+
117
|
|
342
|
+
], element([231], uint(clusterMs)), ...run.map((block) => {
|
|
343
|
+
const relative = Math.round(block.us / 1e3) - clusterMs;
|
|
344
|
+
return element([163], [
|
|
345
|
+
128 | block.track,
|
|
346
|
+
relative >> 8 & 255,
|
|
347
|
+
relative & 255,
|
|
348
|
+
block.key ? 128 : 0
|
|
349
|
+
], block.data);
|
|
350
|
+
})));
|
|
351
|
+
run = [];
|
|
352
|
+
};
|
|
353
|
+
for (const block of blocks) {
|
|
354
|
+
if (run.length > 0 && block.us - run[0].us > 3e7) closeRun();
|
|
355
|
+
run.push(block);
|
|
356
|
+
}
|
|
357
|
+
closeRun();
|
|
358
|
+
const cluster = concat(clusters);
|
|
359
|
+
const part = headerSent ? cluster : concat([webmHeader(), cluster]);
|
|
360
|
+
headerSent = true;
|
|
361
|
+
lastFlushMs = performance.now();
|
|
362
|
+
scope.postMessage({
|
|
363
|
+
type: "part",
|
|
364
|
+
data: part.buffer
|
|
365
|
+
}, [part.buffer]);
|
|
366
|
+
};
|
|
367
|
+
const onEncoded = (track) => (chunk) => {
|
|
368
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
369
|
+
chunk.copyTo(data);
|
|
370
|
+
pending.push({
|
|
371
|
+
us: chunk.timestamp,
|
|
372
|
+
data,
|
|
373
|
+
key: chunk.type === "key",
|
|
374
|
+
track: track.number
|
|
375
|
+
});
|
|
376
|
+
oldestPendingUs = Math.min(oldestPendingUs, chunk.timestamp);
|
|
377
|
+
const timesliceUs = (encodeConfig?.timesliceMs ?? 500) * 1e3;
|
|
378
|
+
const spanUs = chunk.timestamp - oldestPendingUs;
|
|
379
|
+
if (track === video && chunk.type === "key" && spanUs >= timesliceUs / 2) flushBefore(chunk.timestamp);
|
|
380
|
+
else if (spanUs > timesliceUs * 3) flushBefore(chunk.timestamp - 2e5);
|
|
381
|
+
};
|
|
382
|
+
const timelineNowUs = () => (performance.now() - originMs - pausedMs) * 1e3;
|
|
383
|
+
const anchor = (track, sourceUs) => {
|
|
384
|
+
const nowUs = timelineNowUs();
|
|
385
|
+
if (track === audio && track.offsetUs !== null && nowUs - (sourceUs + track.offsetUs) > 25e4) track.offsetUs = null;
|
|
386
|
+
if (track.offsetUs === null) track.offsetUs = nowUs - sourceUs;
|
|
387
|
+
const us = Math.max(Math.round(sourceUs + track.offsetUs), track.lastUs + 1);
|
|
388
|
+
track.lastUs = us;
|
|
389
|
+
return us;
|
|
390
|
+
};
|
|
391
|
+
const reanchor = () => {
|
|
392
|
+
video.offsetUs = null;
|
|
393
|
+
audio.offsetUs = null;
|
|
394
|
+
nextDueUs = -Infinity;
|
|
395
|
+
forceKey = true;
|
|
396
|
+
};
|
|
397
|
+
const resetRecording = () => {
|
|
398
|
+
videoEncoder = null;
|
|
399
|
+
audioEncoder = null;
|
|
400
|
+
audioWanted = hasAudioSource;
|
|
401
|
+
audioFormat = null;
|
|
402
|
+
audioInHeader = false;
|
|
403
|
+
audioClockStartUs = null;
|
|
404
|
+
audioFedUs = 0;
|
|
405
|
+
originMs = performance.now();
|
|
406
|
+
pausedMs = 0;
|
|
407
|
+
video.lastUs = -1;
|
|
408
|
+
audio.lastUs = -1;
|
|
409
|
+
reanchor();
|
|
410
|
+
lastKeyUs = -Infinity;
|
|
411
|
+
headerSent = false;
|
|
412
|
+
lastFlushMs = 0;
|
|
413
|
+
oldestPendingUs = Infinity;
|
|
414
|
+
pending = [];
|
|
415
|
+
};
|
|
416
|
+
const encodeVideo = (frame) => {
|
|
417
|
+
const config = encodeConfig;
|
|
418
|
+
if (!config) {
|
|
419
|
+
frame.close();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (minFrameIntervalUs > 0) {
|
|
423
|
+
const intervalUs = 1e6 / config.fps;
|
|
424
|
+
if (frame.timestamp < nextDueUs - intervalUs * .2) {
|
|
425
|
+
frame.close();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
nextDueUs = nextDueUs === -Infinity || frame.timestamp - nextDueUs > intervalUs ? frame.timestamp + intervalUs : nextDueUs + intervalUs;
|
|
429
|
+
}
|
|
430
|
+
if (!videoEncoder) {
|
|
431
|
+
const size = boundedSize(frame.displayWidth, frame.displayHeight);
|
|
432
|
+
encodedSize = {
|
|
433
|
+
width: Math.max(2, size.width - size.width % 2),
|
|
434
|
+
height: Math.max(2, size.height - size.height % 2)
|
|
435
|
+
};
|
|
436
|
+
videoEncoder = new g.VideoEncoder({
|
|
437
|
+
output: onEncoded(video),
|
|
438
|
+
error: (error) => scope.postMessage({
|
|
439
|
+
type: "record-error",
|
|
440
|
+
message: String(error)
|
|
441
|
+
})
|
|
442
|
+
});
|
|
443
|
+
videoEncoder.configure({
|
|
444
|
+
codec: config.codec,
|
|
445
|
+
width: encodedSize.width,
|
|
446
|
+
height: encodedSize.height,
|
|
447
|
+
bitrate: config.bitrate,
|
|
448
|
+
framerate: config.fps,
|
|
449
|
+
latencyMode: "realtime"
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (videoEncoder.state !== "configured" || videoEncoder.encodeQueueSize > 4) {
|
|
453
|
+
frame.close();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const us = anchor(video, frame.timestamp);
|
|
457
|
+
const keyFrame = forceKey || us - lastKeyUs >= config.timesliceMs * 1e3;
|
|
458
|
+
if (keyFrame) {
|
|
459
|
+
lastKeyUs = us;
|
|
460
|
+
forceKey = false;
|
|
461
|
+
}
|
|
462
|
+
const rebased = new g.VideoFrame(frame, { timestamp: us });
|
|
463
|
+
frame.close();
|
|
464
|
+
videoEncoder.encode(rebased, { keyFrame });
|
|
465
|
+
rebased.close();
|
|
466
|
+
};
|
|
467
|
+
/** AudioData cannot be re-timestamped in place; copying 10 ms is cheap. */
|
|
468
|
+
const retimed = (data, us) => {
|
|
469
|
+
const planes = data.format.endsWith("-planar") ? data.numberOfChannels : 1;
|
|
470
|
+
const sizes = [];
|
|
471
|
+
for (let plane = 0; plane < planes; plane += 1) sizes.push(data.allocationSize({ planeIndex: plane }));
|
|
472
|
+
const buffer = new Uint8Array(sizes.reduce((sum, size) => sum + size, 0));
|
|
473
|
+
let offset = 0;
|
|
474
|
+
for (let plane = 0; plane < planes; plane += 1) {
|
|
475
|
+
data.copyTo(buffer.subarray(offset, offset + sizes[plane]), { planeIndex: plane });
|
|
476
|
+
offset += sizes[plane];
|
|
477
|
+
}
|
|
478
|
+
return new g.AudioData({
|
|
479
|
+
format: data.format,
|
|
480
|
+
sampleRate: data.sampleRate,
|
|
481
|
+
numberOfFrames: data.numberOfFrames,
|
|
482
|
+
numberOfChannels: data.numberOfChannels,
|
|
483
|
+
timestamp: us,
|
|
484
|
+
data: buffer
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
const bytesPerSample = (format) => format.startsWith("u8") ? 1 : format.startsWith("s16") ? 2 : 4;
|
|
488
|
+
/** Zero-filled AudioData (silence for every signed / float format). */
|
|
489
|
+
const silence = (like, us, durationUs) => {
|
|
490
|
+
const frames = Math.max(1, Math.round(durationUs * like.sampleRate / 1e6));
|
|
491
|
+
return new g.AudioData({
|
|
492
|
+
format: like.format,
|
|
493
|
+
sampleRate: like.sampleRate,
|
|
494
|
+
numberOfFrames: frames,
|
|
495
|
+
numberOfChannels: like.numberOfChannels,
|
|
496
|
+
timestamp: us,
|
|
497
|
+
data: new Uint8Array(frames * like.numberOfChannels * bytesPerSample(like.format))
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
const encodeAudio = (data) => {
|
|
501
|
+
const config = encodeConfig;
|
|
502
|
+
if (!config || !audioWanted) {
|
|
503
|
+
data.close();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!audioEncoder) try {
|
|
507
|
+
const encoderInstance = new g.AudioEncoder({
|
|
508
|
+
output: onEncoded(audio),
|
|
509
|
+
error: (error) => {
|
|
510
|
+
audioWanted = false;
|
|
511
|
+
audioEncoder = null;
|
|
512
|
+
scope.postMessage({
|
|
513
|
+
type: "record-error",
|
|
514
|
+
message: `audio encoder: ${String(error)}`
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
encoderInstance.configure({
|
|
519
|
+
codec: "opus",
|
|
520
|
+
sampleRate: data.sampleRate,
|
|
521
|
+
numberOfChannels: data.numberOfChannels,
|
|
522
|
+
bitrate: config.audioBitrate ?? 64e3
|
|
523
|
+
});
|
|
524
|
+
audioEncoder = encoderInstance;
|
|
525
|
+
audioFormat = {
|
|
526
|
+
sampleRate: data.sampleRate,
|
|
527
|
+
channels: data.numberOfChannels
|
|
528
|
+
};
|
|
529
|
+
} catch (error) {
|
|
530
|
+
audioWanted = false;
|
|
531
|
+
scope.postMessage({
|
|
532
|
+
type: "record-error",
|
|
533
|
+
message: `audio configure: ${String(error)}`
|
|
534
|
+
});
|
|
535
|
+
data.close();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (audioEncoder.state !== "configured" || audioEncoder.encodeQueueSize > 50) {
|
|
539
|
+
data.close();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const us = anchor(audio, data.timestamp);
|
|
543
|
+
audioClockStartUs ??= us;
|
|
544
|
+
const driftUs = us - (audioClockStartUs + audioFedUs);
|
|
545
|
+
if (driftUs < -2e5) {
|
|
546
|
+
data.close();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (driftUs > 1e5) {
|
|
550
|
+
const fillUs = Math.min(driftUs, 5e6);
|
|
551
|
+
const filler = silence(data, audioClockStartUs + audioFedUs, fillUs);
|
|
552
|
+
audioEncoder.encode(filler);
|
|
553
|
+
filler.close();
|
|
554
|
+
audioFedUs += filler.numberOfFrames * 1e6 / filler.sampleRate;
|
|
555
|
+
}
|
|
556
|
+
audioFedUs += data.numberOfFrames * 1e6 / data.sampleRate;
|
|
557
|
+
const copy = retimed(data, us);
|
|
558
|
+
data.close();
|
|
559
|
+
audioEncoder.encode(copy);
|
|
560
|
+
copy.close();
|
|
561
|
+
};
|
|
562
|
+
const pumpAudio = async (readable) => {
|
|
563
|
+
const reader = readable.getReader();
|
|
564
|
+
try {
|
|
565
|
+
for (;;) {
|
|
566
|
+
const { value, done } = await reader.read();
|
|
567
|
+
if (done) return;
|
|
568
|
+
if (paused || recording !== "on") value.close();
|
|
569
|
+
else encodeAudio(value);
|
|
570
|
+
}
|
|
571
|
+
} catch (error) {
|
|
572
|
+
audioWanted = false;
|
|
573
|
+
scope.postMessage({
|
|
574
|
+
type: "record-error",
|
|
575
|
+
message: `audio source: ${String(error)}`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const stopRecording = async () => {
|
|
580
|
+
recording = "off";
|
|
581
|
+
for (const current of [videoEncoder, audioEncoder]) try {
|
|
582
|
+
if (current && current.state === "configured") await current.flush();
|
|
583
|
+
} catch {}
|
|
584
|
+
flushBefore(Infinity, true);
|
|
585
|
+
for (const current of [videoEncoder, audioEncoder]) try {
|
|
586
|
+
current?.close();
|
|
587
|
+
} catch {}
|
|
588
|
+
resetRecording();
|
|
589
|
+
scope.postMessage({ type: "record-stopped" });
|
|
590
|
+
};
|
|
107
591
|
const pump = async (readable, gen) => {
|
|
108
592
|
const reader = readable.getReader();
|
|
109
593
|
activeReader = reader;
|
|
@@ -118,10 +602,21 @@ function cameraCompositorWorkerMain(scope) {
|
|
|
118
602
|
frame.close();
|
|
119
603
|
return;
|
|
120
604
|
}
|
|
121
|
-
if (
|
|
605
|
+
if (encodeConfig) {
|
|
606
|
+
if (paused || recording !== "on") frame.close();
|
|
607
|
+
else encodeVideo(frame);
|
|
608
|
+
if (announceNextFrame && !paused) {
|
|
609
|
+
announceNextFrame = false;
|
|
610
|
+
scope.postMessage({ type: "frame" });
|
|
611
|
+
}
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const sinceLastUs = frame.timestamp - lastWrittenUs;
|
|
615
|
+
if (paused || !writer || sinceLastUs >= 0 && sinceLastUs < minFrameIntervalUs) {
|
|
122
616
|
frame.close();
|
|
123
617
|
continue;
|
|
124
618
|
}
|
|
619
|
+
lastWrittenUs = frame.timestamp;
|
|
125
620
|
await writer.write(scaled(frame));
|
|
126
621
|
if (announceNextFrame) {
|
|
127
622
|
announceNextFrame = false;
|
|
@@ -137,6 +632,8 @@ function cameraCompositorWorkerMain(scope) {
|
|
|
137
632
|
};
|
|
138
633
|
const switchSource = (readable) => {
|
|
139
634
|
generation += 1;
|
|
635
|
+
lastWrittenUs = -Infinity;
|
|
636
|
+
reanchor();
|
|
140
637
|
const previous = activeReader;
|
|
141
638
|
activeReader = null;
|
|
142
639
|
previous?.cancel().catch(() => {});
|
|
@@ -148,6 +645,12 @@ function cameraCompositorWorkerMain(scope) {
|
|
|
148
645
|
case "start":
|
|
149
646
|
maxLongEdge = message.maxLongEdge;
|
|
150
647
|
maxShortEdge = message.maxShortEdge;
|
|
648
|
+
minFrameIntervalUs = message.minFrameIntervalUs ?? 0;
|
|
649
|
+
encodeConfig = message.encode ?? null;
|
|
650
|
+
if (encodeConfig && message.audio) {
|
|
651
|
+
hasAudioSource = true;
|
|
652
|
+
pumpAudio(message.audio);
|
|
653
|
+
}
|
|
151
654
|
canvas = new g.OffscreenCanvas(1, 1);
|
|
152
655
|
ctx = canvas.getContext("2d");
|
|
153
656
|
writer = message.writable.getWriter();
|
|
@@ -163,6 +666,27 @@ function cameraCompositorWorkerMain(scope) {
|
|
|
163
666
|
case "resume":
|
|
164
667
|
paused = false;
|
|
165
668
|
announceNextFrame = true;
|
|
669
|
+
reanchor();
|
|
670
|
+
break;
|
|
671
|
+
case "record-start":
|
|
672
|
+
resetRecording();
|
|
673
|
+
recording = "on";
|
|
674
|
+
break;
|
|
675
|
+
case "record-pause":
|
|
676
|
+
if (recording === "on") {
|
|
677
|
+
recording = "paused";
|
|
678
|
+
pauseStartedMs = performance.now();
|
|
679
|
+
}
|
|
680
|
+
break;
|
|
681
|
+
case "record-resume":
|
|
682
|
+
if (recording === "paused") {
|
|
683
|
+
pausedMs += performance.now() - pauseStartedMs;
|
|
684
|
+
recording = "on";
|
|
685
|
+
reanchor();
|
|
686
|
+
}
|
|
687
|
+
break;
|
|
688
|
+
case "record-stop":
|
|
689
|
+
stopRecording();
|
|
166
690
|
break;
|
|
167
691
|
default: break;
|
|
168
692
|
}
|
|
@@ -179,6 +703,26 @@ const MAX_COMPOSITE_SHORT_EDGE = 720;
|
|
|
179
703
|
const FRAME_INTERVAL_TOLERANCE_RATIO = .9;
|
|
180
704
|
const DEFAULT_RESUME_TIMEOUT_MS = 2e3;
|
|
181
705
|
/**
|
|
706
|
+
* Compositor for a multipart recording (ID, Selfie, Video Selfie): encodes
|
|
707
|
+
* inside its worker at the device's {@link getWorkerRecordingProfile} tier.
|
|
708
|
+
* Pair it with
|
|
709
|
+
* `WorkerEncoderRecordingProvider`, which falls back to `MediaRecorder` when
|
|
710
|
+
* in-worker encoding is unavailable.
|
|
711
|
+
*/
|
|
712
|
+
function createRecordingCompositor() {
|
|
713
|
+
const profile = getWorkerRecordingProfile();
|
|
714
|
+
return new CameraCompositor({
|
|
715
|
+
fps: profile.fps,
|
|
716
|
+
maxLongEdge: profile.maxLongEdge,
|
|
717
|
+
maxShortEdge: profile.maxShortEdge,
|
|
718
|
+
paceWorkerFrames: profile.fps < 30,
|
|
719
|
+
encodeInWorker: {
|
|
720
|
+
bitrate: profile.bitrate,
|
|
721
|
+
timesliceMs: profile.timesliceMs
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
182
726
|
* Composites the *active* camera into a single, stable `MediaStream` to
|
|
183
727
|
* record. Switching cameras (front ↔ back) just repoints the source — the
|
|
184
728
|
* output stream and its audio track are never replaced — so a single
|
|
@@ -196,6 +740,10 @@ const DEFAULT_RESUME_TIMEOUT_MS = 2e3;
|
|
|
196
740
|
* `captureStream`, for Safari, Firefox and strict-CSP hosts that block blob
|
|
197
741
|
* workers.
|
|
198
742
|
*
|
|
743
|
+
* With `encodeInWorker`, the worker transport also records: it encodes and
|
|
744
|
+
* muxes the WebM itself (see `startWorkerRecording`), so no `MediaRecorder`
|
|
745
|
+
* — which needs the main thread for every frame — is involved.
|
|
746
|
+
*
|
|
199
747
|
* A worker that dies mid-recording (GPU-process crash, OOM) is released at
|
|
200
748
|
* once; the composite track stays valid but receives no further frames, and
|
|
201
749
|
* the next `resume()` reports `timed-out` so the caller reacquires the camera
|
|
@@ -206,6 +754,10 @@ const DEFAULT_RESUME_TIMEOUT_MS = 2e3;
|
|
|
206
754
|
*/
|
|
207
755
|
var CameraCompositor = class {
|
|
208
756
|
constructor(options = {}) {
|
|
757
|
+
this.workerEncoding = false;
|
|
758
|
+
this.onWorkerPart = null;
|
|
759
|
+
this.workerRecordingError = null;
|
|
760
|
+
this.workerRecordingStopped = null;
|
|
209
761
|
this.canvas = null;
|
|
210
762
|
this.ctx = null;
|
|
211
763
|
this.video = null;
|
|
@@ -220,6 +772,10 @@ var CameraCompositor = class {
|
|
|
220
772
|
this.videoTrack = null;
|
|
221
773
|
this.composite = null;
|
|
222
774
|
this.fps = options.fps ?? 30;
|
|
775
|
+
this.maxLongEdge = options.maxLongEdge ?? MAX_COMPOSITE_LONG_EDGE;
|
|
776
|
+
this.maxShortEdge = options.maxShortEdge ?? MAX_COMPOSITE_SHORT_EDGE;
|
|
777
|
+
this.paceWorkerFrames = options.paceWorkerFrames ?? false;
|
|
778
|
+
this.encodeInWorker = options.encodeInWorker;
|
|
223
779
|
this.frameIntervalMs = this.fps > 0 ? 1e3 / this.fps : 0;
|
|
224
780
|
this.resumeTimeoutMs = options.resumeTimeoutMs ?? DEFAULT_RESUME_TIMEOUT_MS;
|
|
225
781
|
}
|
|
@@ -258,6 +814,66 @@ var CameraCompositor = class {
|
|
|
258
814
|
} else this.videoTrack = stream.getVideoTracks()[0] ?? null;
|
|
259
815
|
if (previousVideoTrack) previousVideoTrack.stop();
|
|
260
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* True when this compositor records by itself: the worker transport started
|
|
819
|
+
* with `encodeInWorker` and the browser has `VideoEncoder`. Use the
|
|
820
|
+
* `*WorkerRecording` methods instead of a `MediaRecorder` then; the
|
|
821
|
+
* composite stream receives no frames.
|
|
822
|
+
*/
|
|
823
|
+
get encodesInWorker() {
|
|
824
|
+
return this.workerEncoding && this.transport !== null;
|
|
825
|
+
}
|
|
826
|
+
/** Last in-worker encoder error, if any. */
|
|
827
|
+
get workerRecordingErrorMessage() {
|
|
828
|
+
return this.workerRecordingError;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Start a new WebM recording inside the worker. `onPart` receives whole
|
|
832
|
+
* WebM clusters (~`timesliceMs` each, keyframe first; the first part also
|
|
833
|
+
* carries the header), in order. Returns false when not encoding in-worker.
|
|
834
|
+
*/
|
|
835
|
+
startWorkerRecording(onPart) {
|
|
836
|
+
if (!this.encodesInWorker) return false;
|
|
837
|
+
this.onWorkerPart = onPart;
|
|
838
|
+
this.workerRecordingError = null;
|
|
839
|
+
this.postToWorker({ type: "record-start" });
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
pauseWorkerRecording() {
|
|
843
|
+
if (!this.encodesInWorker || !this.onWorkerPart) return false;
|
|
844
|
+
this.postToWorker({ type: "record-pause" });
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
resumeWorkerRecording() {
|
|
848
|
+
if (!this.encodesInWorker || !this.onWorkerPart) return false;
|
|
849
|
+
this.postToWorker({ type: "record-resume" });
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Flush the encoder and resolve once every part has reached `onPart`.
|
|
854
|
+
* Resolves at once if the worker is gone (its buffered frames are lost).
|
|
855
|
+
*/
|
|
856
|
+
stopWorkerRecording() {
|
|
857
|
+
if (!this.onWorkerPart) return Promise.resolve();
|
|
858
|
+
if (!this.transport) {
|
|
859
|
+
this.onWorkerPart = null;
|
|
860
|
+
return Promise.resolve();
|
|
861
|
+
}
|
|
862
|
+
return new Promise((resolve) => {
|
|
863
|
+
this.workerRecordingStopped = () => {
|
|
864
|
+
this.workerRecordingStopped = null;
|
|
865
|
+
this.onWorkerPart = null;
|
|
866
|
+
resolve();
|
|
867
|
+
};
|
|
868
|
+
this.postToWorker({ type: "record-stop" });
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
/** Stop in-worker recording and drop whatever it still emits. */
|
|
872
|
+
cancelWorkerRecording() {
|
|
873
|
+
if (this.onWorkerPart && this.transport) this.postToWorker({ type: "record-stop" });
|
|
874
|
+
this.onWorkerPart = null;
|
|
875
|
+
this.workerRecordingStopped?.();
|
|
876
|
+
}
|
|
261
877
|
/** The composite stream being recorded, if compositing is active. */
|
|
262
878
|
get stream() {
|
|
263
879
|
return this.composite ?? void 0;
|
|
@@ -349,6 +965,13 @@ var CameraCompositor = class {
|
|
|
349
965
|
let worker = null;
|
|
350
966
|
let generator = null;
|
|
351
967
|
let blobUrl = null;
|
|
968
|
+
const codecs = globalThis;
|
|
969
|
+
const encode = this.encodeInWorker && typeof codecs.VideoEncoder === "function" && (!this.audioTrack || typeof codecs.AudioEncoder === "function") ? {
|
|
970
|
+
codec: "vp8",
|
|
971
|
+
bitrate: this.encodeInWorker.bitrate,
|
|
972
|
+
fps: this.fps,
|
|
973
|
+
timesliceMs: this.encodeInWorker.timesliceMs
|
|
974
|
+
} : null;
|
|
352
975
|
try {
|
|
353
976
|
const processor = createTrackProcessor(track);
|
|
354
977
|
generator = createTrackGenerator();
|
|
@@ -359,14 +982,22 @@ var CameraCompositor = class {
|
|
|
359
982
|
type: "fatal",
|
|
360
983
|
message: event?.message || "worker error"
|
|
361
984
|
});
|
|
985
|
+
const audioReadable = encode && this.audioTrack ? createTrackProcessor(this.audioTrack).readable : void 0;
|
|
362
986
|
const message = {
|
|
363
987
|
type: "start",
|
|
988
|
+
audio: audioReadable,
|
|
364
989
|
readable: processor.readable,
|
|
365
990
|
writable: generator.writable,
|
|
366
|
-
maxLongEdge:
|
|
367
|
-
maxShortEdge:
|
|
991
|
+
maxLongEdge: this.maxLongEdge,
|
|
992
|
+
maxShortEdge: this.maxShortEdge,
|
|
993
|
+
minFrameIntervalUs: this.paceWorkerFrames && this.frameIntervalMs > 0 ? this.frameIntervalMs * 1e3 * FRAME_INTERVAL_TOLERANCE_RATIO : 0,
|
|
994
|
+
encode: encode ?? void 0
|
|
368
995
|
};
|
|
369
|
-
worker.postMessage(message, [
|
|
996
|
+
worker.postMessage(message, [
|
|
997
|
+
processor.readable,
|
|
998
|
+
generator.writable,
|
|
999
|
+
...audioReadable ? [audioReadable] : []
|
|
1000
|
+
]);
|
|
370
1001
|
} catch {
|
|
371
1002
|
worker?.terminate();
|
|
372
1003
|
generator?.stop();
|
|
@@ -379,6 +1010,7 @@ var CameraCompositor = class {
|
|
|
379
1010
|
blobUrl
|
|
380
1011
|
};
|
|
381
1012
|
this.generator = generator;
|
|
1013
|
+
this.workerEncoding = encode !== null;
|
|
382
1014
|
return new MediaStream([generator]);
|
|
383
1015
|
}
|
|
384
1016
|
/** Terminates the worker and frees its blob; idempotent. */
|
|
@@ -387,6 +1019,7 @@ var CameraCompositor = class {
|
|
|
387
1019
|
this.transport.worker.terminate();
|
|
388
1020
|
URL.revokeObjectURL(this.transport.blobUrl);
|
|
389
1021
|
this.transport = null;
|
|
1022
|
+
this.workerRecordingStopped?.();
|
|
390
1023
|
}
|
|
391
1024
|
startMainThread(stream) {
|
|
392
1025
|
this.canvas = document.createElement("canvas");
|
|
@@ -411,6 +1044,15 @@ var CameraCompositor = class {
|
|
|
411
1044
|
case "ended":
|
|
412
1045
|
this.finishResume("video-ended");
|
|
413
1046
|
break;
|
|
1047
|
+
case "part":
|
|
1048
|
+
this.onWorkerPart?.(new Blob([message.data], { type: "video/webm" }));
|
|
1049
|
+
break;
|
|
1050
|
+
case "record-stopped":
|
|
1051
|
+
this.workerRecordingStopped?.();
|
|
1052
|
+
break;
|
|
1053
|
+
case "record-error":
|
|
1054
|
+
this.workerRecordingError = message.message;
|
|
1055
|
+
break;
|
|
414
1056
|
case "fatal":
|
|
415
1057
|
this.releaseWorker();
|
|
416
1058
|
this.finishResume("timed-out");
|
|
@@ -484,7 +1126,7 @@ var CameraCompositor = class {
|
|
|
484
1126
|
getBoundedDimensions(width, height) {
|
|
485
1127
|
const longEdge = Math.max(width, height);
|
|
486
1128
|
const shortEdge = Math.min(width, height);
|
|
487
|
-
const scale = Math.min(1,
|
|
1129
|
+
const scale = Math.min(1, this.maxLongEdge / longEdge, this.maxShortEdge / shortEdge);
|
|
488
1130
|
return {
|
|
489
1131
|
width: Math.max(1, Math.round(width * scale)),
|
|
490
1132
|
height: Math.max(1, Math.round(height * scale))
|
|
@@ -636,6 +1278,82 @@ var MultipartRecordingProvider = class {
|
|
|
636
1278
|
}
|
|
637
1279
|
};
|
|
638
1280
|
//#endregion
|
|
1281
|
+
//#region ../infra/src/providers/browser/WorkerEncoderRecordingProvider.ts
|
|
1282
|
+
/**
|
|
1283
|
+
* Multipart recorder for a `CameraCompositor` that encodes inside its worker
|
|
1284
|
+
* (ENG-55397): emits the worker's WebM clusters as parts, in order. When the
|
|
1285
|
+
* compositor could not start in-worker encoding (no insertable streams or
|
|
1286
|
+
* WebCodecs, strict CSP), it records the composite stream with
|
|
1287
|
+
* `MediaRecorder` instead, so callers wire it unconditionally.
|
|
1288
|
+
*
|
|
1289
|
+
* Why not always `MediaRecorder`: Chrome hands every recorded frame through
|
|
1290
|
+
* the page main thread and drops frames while it is blocked. On a Galaxy A01
|
|
1291
|
+
* Core the ID flow blocks it for 3–5 s (WASM + 1080p readbacks); a
|
|
1292
|
+
* `MediaRecorder` fed by the worker kept 74 of 149 frames under 1.5 s
|
|
1293
|
+
* freezes, the in-worker encoder 150 of 151.
|
|
1294
|
+
*/
|
|
1295
|
+
var WorkerEncoderRecordingProvider = class {
|
|
1296
|
+
constructor(compositor) {
|
|
1297
|
+
this.compositor = compositor;
|
|
1298
|
+
this._isRecording = false;
|
|
1299
|
+
this._error = null;
|
|
1300
|
+
this.partNumber = 0;
|
|
1301
|
+
this.fallback = null;
|
|
1302
|
+
}
|
|
1303
|
+
get isRecording() {
|
|
1304
|
+
return this.fallback ? this.fallback.isRecording : this._isRecording;
|
|
1305
|
+
}
|
|
1306
|
+
get hasError() {
|
|
1307
|
+
return this.error !== null;
|
|
1308
|
+
}
|
|
1309
|
+
get error() {
|
|
1310
|
+
if (this.fallback) return this.fallback.error;
|
|
1311
|
+
return this._error ?? this.compositor.workerRecordingErrorMessage;
|
|
1312
|
+
}
|
|
1313
|
+
startRecording(stream, onPart) {
|
|
1314
|
+
this.reset();
|
|
1315
|
+
if (!this.compositor.encodesInWorker) {
|
|
1316
|
+
this.fallback = new MultipartRecordingProvider();
|
|
1317
|
+
this.fallback.startRecording(stream, onPart);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
this.partNumber = 0;
|
|
1321
|
+
const started = this.compositor.startWorkerRecording((chunk) => {
|
|
1322
|
+
this.partNumber += 1;
|
|
1323
|
+
onPart({
|
|
1324
|
+
chunk,
|
|
1325
|
+
partNumber: this.partNumber
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
this._isRecording = started;
|
|
1329
|
+
this._error = started ? null : "Failed to start recording: no encoder";
|
|
1330
|
+
}
|
|
1331
|
+
pauseRecording() {
|
|
1332
|
+
if (this.fallback) return this.fallback.pauseRecording();
|
|
1333
|
+
return this._isRecording && this.compositor.pauseWorkerRecording();
|
|
1334
|
+
}
|
|
1335
|
+
resumeRecording() {
|
|
1336
|
+
if (this.fallback) return this.fallback.resumeRecording();
|
|
1337
|
+
return this._isRecording && this.compositor.resumeWorkerRecording();
|
|
1338
|
+
}
|
|
1339
|
+
async stopRecording() {
|
|
1340
|
+
if (this.fallback) {
|
|
1341
|
+
await this.fallback.stopRecording();
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (!this._isRecording) return;
|
|
1345
|
+
this._isRecording = false;
|
|
1346
|
+
await this.compositor.stopWorkerRecording();
|
|
1347
|
+
}
|
|
1348
|
+
reset() {
|
|
1349
|
+
this.fallback?.reset();
|
|
1350
|
+
this.fallback = null;
|
|
1351
|
+
this.compositor.cancelWorkerRecording();
|
|
1352
|
+
this._isRecording = false;
|
|
1353
|
+
this._error = null;
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
//#endregion
|
|
639
1357
|
//#region ../infra/src/providers/browser/BrowserNetworkStatusProvider.ts
|
|
640
1358
|
var BrowserNetworkStatusProvider = class {
|
|
641
1359
|
onOnline(listener) {
|
|
@@ -920,15 +1638,15 @@ function httpStatusOf(error) {
|
|
|
920
1638
|
* (S3 SSE-AES-256).
|
|
921
1639
|
*/
|
|
922
1640
|
var MultipartRecordingService = class {
|
|
923
|
-
constructor(scope = "videoselfie") {
|
|
1641
|
+
constructor(scope = "videoselfie", recorder = new MultipartRecordingProvider()) {
|
|
924
1642
|
this.scope = scope;
|
|
925
|
-
this.recorder = new MultipartRecordingProvider();
|
|
926
1643
|
this.timer = BrowserTimerProvider.getInstance();
|
|
927
1644
|
this.session = null;
|
|
928
1645
|
this.uploader = null;
|
|
929
1646
|
this.chunksEmitted = 0;
|
|
930
1647
|
this.stopPromise = null;
|
|
931
1648
|
this.abandonRequested = false;
|
|
1649
|
+
this.recorder = recorder;
|
|
932
1650
|
}
|
|
933
1651
|
get isRecording() {
|
|
934
1652
|
return this.recorder.isRecording;
|
|
@@ -1047,4 +1765,4 @@ var MultipartRecordingService = class {
|
|
|
1047
1765
|
}
|
|
1048
1766
|
};
|
|
1049
1767
|
//#endregion
|
|
1050
|
-
export {
|
|
1768
|
+
export { getAdaptiveMediaRecorderOptions as a, createRecordingCompositor as i, WorkerEncoderRecordingProvider as n, StreamCanvasProcessingSession as o, CameraCompositor as r, MultipartRecordingService as t };
|