@wcstack/camera 1.14.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.
@@ -0,0 +1,1154 @@
1
+ const _config = {
2
+ tagNames: {
3
+ camera: "wcs-camera",
4
+ recorder: "wcs-recorder",
5
+ },
6
+ };
7
+ function deepFreeze(obj) {
8
+ if (obj === null || typeof obj !== "object")
9
+ return obj;
10
+ Object.freeze(obj);
11
+ for (const key of Object.keys(obj)) {
12
+ deepFreeze(obj[key]);
13
+ }
14
+ return obj;
15
+ }
16
+ function deepClone(obj) {
17
+ if (obj === null || typeof obj !== "object")
18
+ return obj;
19
+ const clone = {};
20
+ for (const key of Object.keys(obj)) {
21
+ clone[key] = deepClone(obj[key]);
22
+ }
23
+ return clone;
24
+ }
25
+ let frozenConfig = null;
26
+ // Internal, mutable live config used by registerComponents (read at call time so
27
+ // setConfig() takes effect without re-import). Public consumers get the deep-frozen
28
+ // clone from getConfig().
29
+ const config = _config;
30
+ function getConfig() {
31
+ if (!frozenConfig) {
32
+ frozenConfig = deepFreeze(deepClone(_config));
33
+ }
34
+ return frozenConfig;
35
+ }
36
+ function setConfig(partialConfig) {
37
+ if (partialConfig.tagNames) {
38
+ Object.assign(_config.tagNames, partialConfig.tagNames);
39
+ }
40
+ frozenConfig = null;
41
+ }
42
+
43
+ /** True when getUserMedia is reachable (secure context with a media-devices impl). */
44
+ function hasMediaDevices() {
45
+ return typeof navigator !== "undefined"
46
+ && !!navigator.mediaDevices
47
+ && typeof navigator.mediaDevices.getUserMedia === "function";
48
+ }
49
+ /** True when MediaRecorder is available in this environment. */
50
+ function hasMediaRecorder() {
51
+ return typeof globalThis !== "undefined"
52
+ && typeof globalThis.MediaRecorder === "function";
53
+ }
54
+ /**
55
+ * Translate a getUserMedia constraints object from the declarative
56
+ * CameraConstraints surface. Always requests a video track; `audio` opts the
57
+ * microphone in. `deviceId` (exact) takes precedence over `facingMode`.
58
+ */
59
+ function buildConstraints(c) {
60
+ const video = {};
61
+ if (c.deviceId) {
62
+ video.deviceId = { exact: c.deviceId };
63
+ }
64
+ else if (c.facingMode) {
65
+ video.facingMode = c.facingMode;
66
+ }
67
+ if (typeof c.width === "number")
68
+ video.width = c.width;
69
+ if (typeof c.height === "number")
70
+ video.height = c.height;
71
+ const hasVideoConstraint = Object.keys(video).length > 0;
72
+ return {
73
+ video: hasVideoConstraint ? video : true,
74
+ audio: c.audio === true,
75
+ };
76
+ }
77
+ /** Normalize any thrown getUserMedia / MediaRecorder failure into a flat detail. */
78
+ function normalizeMediaError(error) {
79
+ if (error && typeof error === "object" && "name" in error) {
80
+ const name = String(error.name) || "Error";
81
+ const message = "message" in error && error.message
82
+ ? String(error.message)
83
+ : `Media request failed: ${name}.`;
84
+ return { name, message };
85
+ }
86
+ return { name: "Error", message: "Media request failed." };
87
+ }
88
+ const UNSUPPORTED_ERROR = {
89
+ name: "unsupported",
90
+ message: "getUserMedia is not available (requires a secure context).",
91
+ };
92
+ /**
93
+ * Request a media stream. Never throws — resolves with `{ stream }` on success or
94
+ * `{ error }` (normalized) on failure / when the API is unavailable.
95
+ */
96
+ async function requestUserMedia(constraints) {
97
+ if (!hasMediaDevices()) {
98
+ return { error: UNSUPPORTED_ERROR };
99
+ }
100
+ try {
101
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
102
+ return { stream };
103
+ }
104
+ catch (error) {
105
+ return { error: normalizeMediaError(error) };
106
+ }
107
+ }
108
+ /** Stop every track of a stream, releasing the camera/microphone hardware. */
109
+ function stopAllTracks(stream) {
110
+ if (!stream)
111
+ return;
112
+ for (const track of stream.getTracks()) {
113
+ track.stop();
114
+ }
115
+ }
116
+ /**
117
+ * Enumerate video input devices as plain snapshots. Labels are only populated
118
+ * after a grant, so this is refreshed post-acquisition. Never throws.
119
+ */
120
+ async function enumerateVideoDevices() {
121
+ if (!hasMediaDevices() || typeof navigator.mediaDevices.enumerateDevices !== "function") {
122
+ return [];
123
+ }
124
+ try {
125
+ const devices = await navigator.mediaDevices.enumerateDevices();
126
+ return devices
127
+ .filter((d) => d.kind === "videoinput")
128
+ .map((d) => ({
129
+ deviceId: d.deviceId,
130
+ label: d.label,
131
+ groupId: d.groupId,
132
+ kind: d.kind,
133
+ }));
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Live monitor for a single media permission (`camera` or `microphone`) via the
142
+ * Permissions API. Mirrors PermissionCore's two-phase pattern: an initial query
143
+ * plus a live `change` subscription, guarded by a monotonic generation so a query
144
+ * superseded by a rapid dispose/observe never attaches a stale listener.
145
+ *
146
+ * When the Permissions API is absent or rejects the descriptor (e.g. Firefox does
147
+ * not accept the `camera` / `microphone` descriptor), the watcher reports
148
+ * `"unsupported"`. The CameraCore then refines the state from the getUserMedia
149
+ * outcome (granted on success, denied on NotAllowedError).
150
+ */
151
+ class MediaPermissionWatcher {
152
+ _name;
153
+ _onChange;
154
+ _status = null;
155
+ _gen = 0;
156
+ _subscribed = false;
157
+ constructor(name, onChange) {
158
+ this._name = name;
159
+ this._onChange = onChange;
160
+ }
161
+ /** Issue the initial query and subscribe to live changes. Resolves when settled. */
162
+ observe() {
163
+ if (this._subscribed)
164
+ return Promise.resolve();
165
+ if (typeof navigator === "undefined" || !navigator.permissions
166
+ || typeof navigator.permissions.query !== "function") {
167
+ this._onChange("unsupported");
168
+ return Promise.resolve();
169
+ }
170
+ this._subscribed = true;
171
+ const gen = ++this._gen;
172
+ return navigator.permissions
173
+ .query({ name: this._name })
174
+ .then((status) => {
175
+ if (gen !== this._gen)
176
+ return;
177
+ this._status = status;
178
+ this._onChange(status.state);
179
+ status.addEventListener("change", this._onStatusChange);
180
+ }, () => {
181
+ if (gen !== this._gen)
182
+ return;
183
+ this._onChange("unsupported");
184
+ });
185
+ }
186
+ /** Detach the live listener and invalidate any in-flight query. */
187
+ dispose() {
188
+ this._subscribed = false;
189
+ this._gen++;
190
+ if (this._status) {
191
+ this._status.removeEventListener("change", this._onStatusChange);
192
+ this._status = null;
193
+ }
194
+ }
195
+ _onStatusChange = (event) => {
196
+ const status = event.target;
197
+ this._onChange(status.state);
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Headless camera-capture primitive. Wraps getUserMedia + the Permissions API and
203
+ * exposes a `MediaStream` through the wc-bindable protocol — but the live stream is
204
+ * NEVER published as a reactive value. It is a non-serializable live handle: it
205
+ * flows out only via the `wcs-camera:stream-ready` event so a consumer (the preview
206
+ * `<video>`, a `<wcs-recorder>`) can bind it directly to an element property,
207
+ * bypassing serializable state. See docs/camera-recorder-tag-design.md §1/§2.
208
+ *
209
+ * The observable value surface is strictly derived data: `active` (is a stream
210
+ * live — the "actual" half of the desired/actual pair), `permission` /
211
+ * `audioPermission` (two-phase: Permissions API monitor + getUserMedia outcome),
212
+ * `deviceId` / `devices`, and `error`. Failures never throw — they surface through
213
+ * `error`.
214
+ */
215
+ class CameraCore extends EventTarget {
216
+ static wcBindable = {
217
+ protocol: "wc-bindable",
218
+ version: 1,
219
+ properties: [
220
+ { name: "active", event: "wcs-camera:active-changed" },
221
+ { name: "permission", event: "wcs-camera:permission-changed" },
222
+ { name: "audioPermission", event: "wcs-camera:audio-permission-changed" },
223
+ { name: "deviceId", event: "wcs-camera:device-changed" },
224
+ { name: "devices", event: "wcs-camera:devices-changed" },
225
+ { name: "error", event: "wcs-camera:error" },
226
+ // Direct-channel handle: event-token only — never bound as a reactive value.
227
+ { name: "streamReady", event: "wcs-camera:stream-ready", getter: (e) => e.detail },
228
+ // event-token: a bare signal (detail is always null) — surface detail, not the raw Event.
229
+ { name: "ended", event: "wcs-camera:ended", getter: (e) => e.detail },
230
+ ],
231
+ commands: [
232
+ { name: "start" },
233
+ { name: "stop" },
234
+ { name: "switchCamera" },
235
+ ],
236
+ };
237
+ _target;
238
+ _active = false;
239
+ _permission = "prompt";
240
+ _audioPermission = null;
241
+ _deviceId = null;
242
+ _devices = [];
243
+ _error = null;
244
+ // The live stream — internal only, never a reactive value (see class docs).
245
+ _stream = null;
246
+ // desired/actual split (wakelock-style): `_desired` is whether the user wants the
247
+ // camera on; `_active` is whether a stream is actually live. The OS can revoke a
248
+ // track (device unplugged / taken by another app) — actual drops while desired
249
+ // stays true, so a later resume()/visibility-restore can re-acquire.
250
+ _desired = false;
251
+ _constraints = {};
252
+ // Monotonic id of the current acquisition lifecycle. Bumped by every acquire and
253
+ // by dispose(). Each in-flight getUserMedia captures it and, on resolve, bails
254
+ // (stopping the just-acquired stream) when superseded — so a constraints change
255
+ // mid-acquire (switchMap-style restart) cannot leave an orphaned stream live.
256
+ _gen = 0;
257
+ _subscribed = false;
258
+ _camWatcher = null;
259
+ _micWatcher = null;
260
+ _ready = Promise.resolve();
261
+ constructor(target) {
262
+ super();
263
+ this._target = target ?? this;
264
+ }
265
+ get active() { return this._active; }
266
+ get permission() { return this._permission; }
267
+ get audioPermission() { return this._audioPermission; }
268
+ get deviceId() { return this._deviceId; }
269
+ get devices() { return this._devices; }
270
+ get error() { return this._error; }
271
+ get ready() { return this._ready; }
272
+ // --- State setters with event dispatch (same-value guarded) ---
273
+ _setActive(active) {
274
+ if (this._active === active)
275
+ return;
276
+ this._active = active;
277
+ this._dispatch("wcs-camera:active-changed", active);
278
+ }
279
+ _setPermission(state) {
280
+ if (this._permission === state)
281
+ return;
282
+ this._permission = state;
283
+ this._dispatch("wcs-camera:permission-changed", state);
284
+ }
285
+ _setAudioPermission(state) {
286
+ if (this._audioPermission === state)
287
+ return;
288
+ this._audioPermission = state;
289
+ this._dispatch("wcs-camera:audio-permission-changed", state);
290
+ }
291
+ _setDeviceId(id) {
292
+ if (this._deviceId === id)
293
+ return;
294
+ this._deviceId = id;
295
+ this._dispatch("wcs-camera:device-changed", id);
296
+ }
297
+ _setDevices(devices) {
298
+ if (this._devicesEqual(this._devices, devices))
299
+ return;
300
+ this._devices = devices;
301
+ this._dispatch("wcs-camera:devices-changed", devices);
302
+ }
303
+ // Errors are dispatched on EVERY non-null occurrence by design — each failure is a
304
+ // distinct event (e.g. retrying getUserMedia and failing again must re-notify), so
305
+ // unlike the value setters this is not content-deduped. Only the null→null
306
+ // transition is collapsed (clearing an already-clear error stays silent). The guard
307
+ // is written on null explicitly so it does NOT depend on callers passing a fresh
308
+ // object: a reused/cached non-null detail would still re-notify.
309
+ _setError(error) {
310
+ if (error === null && this._error === null)
311
+ return;
312
+ this._error = error;
313
+ this._dispatch("wcs-camera:error", error);
314
+ }
315
+ _dispatch(type, detail) {
316
+ this._target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
317
+ }
318
+ _devicesEqual(a, b) {
319
+ if (a.length !== b.length)
320
+ return false;
321
+ for (let i = 0; i < a.length; i++) {
322
+ if (a[i].deviceId !== b[i].deviceId || a[i].label !== b[i].label)
323
+ return false;
324
+ }
325
+ return true;
326
+ }
327
+ // --- Public API ---
328
+ /**
329
+ * Begin observing permissions for the given constraints. Idempotent while
330
+ * already subscribed. The first call (or one after dispose()) starts the
331
+ * camera/microphone permission monitors. Acquisition itself is driven separately
332
+ * by start() / autostart — observing does not prompt.
333
+ */
334
+ observe(constraints) {
335
+ this._constraints = { ...constraints };
336
+ if (!this._subscribed) {
337
+ this._subscribed = true;
338
+ this._ready = this._initPermissions();
339
+ }
340
+ else {
341
+ // Already live: track the latest constraints and fold any newly-started
342
+ // microphone query into `ready` so awaiting observe() guarantees its initial
343
+ // permission state — symmetric with _initPermissions() on the first observe.
344
+ this._ready = this._ready.then(() => this._reconcileAudioWatcher());
345
+ }
346
+ return this._ready;
347
+ }
348
+ /** Acquire the camera (sets desired=true). Prompts on first use. */
349
+ start() {
350
+ this._desired = true;
351
+ this._restart();
352
+ }
353
+ /** Release the camera (sets desired=false), stopping all tracks. */
354
+ stop() {
355
+ this._desired = false;
356
+ this._release(false);
357
+ }
358
+ /**
359
+ * Toggle facingMode (user ↔ environment) and re-acquire if active. This is the
360
+ * headless, DOM-free path: it flips the Core's internal `_constraints` (the single
361
+ * source of truth for a standalone Core) and re-acquires while desired.
362
+ *
363
+ * Note: the `<wcs-camera>` Shell does NOT delegate to this — it keeps the DOM
364
+ * attributes authoritative and drives its own single re-acquire (see Camera.ts
365
+ * switchCamera). Both reach the same end state; the split exists because the Shell
366
+ * must keep its declared attributes in sync, which a Core has no notion of.
367
+ */
368
+ switchCamera() {
369
+ const next = this._constraints.facingMode === "environment" ? "user" : "environment";
370
+ this._constraints = { ...this._constraints, facingMode: next, deviceId: undefined };
371
+ if (this._desired) {
372
+ this._restart();
373
+ }
374
+ }
375
+ /**
376
+ * Suspend the live stream while keeping `desired` — for page-hidden. Stops tracks
377
+ * (clearing the hardware indicator) but remembers that the camera should resume.
378
+ *
379
+ * Bumps `_gen` to supersede any in-flight acquire: without this, an acquire that
380
+ * resolves *after* the page went hidden would assign `_stream` and set active —
381
+ * re-lighting the camera behind a no-op suspend (the stream had not been assigned
382
+ * yet, so the `if (_stream)` release below could not reach it). The superseded
383
+ * acquire stops its just-acquired orphan stream on resolve (see `_acquire`).
384
+ */
385
+ suspend() {
386
+ this._gen++;
387
+ if (this._stream) {
388
+ this._release(false);
389
+ }
390
+ }
391
+ /** Re-acquire if the camera is desired but not currently active — for page-visible. */
392
+ resume() {
393
+ if (this._desired && !this._active) {
394
+ this._restart();
395
+ }
396
+ }
397
+ /** Tear down: stop the stream and detach permission listeners. */
398
+ dispose() {
399
+ this._subscribed = false;
400
+ this._gen++;
401
+ this._desired = false;
402
+ this._release(true);
403
+ if (hasMediaDevices() && typeof navigator.mediaDevices.removeEventListener === "function") {
404
+ navigator.mediaDevices.removeEventListener("devicechange", this._onDeviceChange);
405
+ }
406
+ this._camWatcher?.dispose();
407
+ this._micWatcher?.dispose();
408
+ this._camWatcher = null;
409
+ this._micWatcher = null;
410
+ }
411
+ // --- Internal ---
412
+ _initPermissions() {
413
+ if (!hasMediaDevices()) {
414
+ this._setPermission("unsupported");
415
+ return Promise.resolve();
416
+ }
417
+ this._camWatcher = new MediaPermissionWatcher("camera", (s) => this._setPermission(s));
418
+ const tasks = [this._camWatcher.observe()];
419
+ if (this._constraints.audio) {
420
+ this._micWatcher = new MediaPermissionWatcher("microphone", (s) => this._setAudioPermission(s));
421
+ tasks.push(this._micWatcher.observe());
422
+ }
423
+ // Track hot-plug: refresh the device list when a camera is added/removed.
424
+ if (typeof navigator.mediaDevices.addEventListener === "function") {
425
+ navigator.mediaDevices.addEventListener("devicechange", this._onDeviceChange);
426
+ }
427
+ return Promise.all(tasks).then(() => undefined);
428
+ }
429
+ _onDeviceChange = () => {
430
+ void enumerateVideoDevices().then((devices) => {
431
+ // Guard against a late resolution after dispose.
432
+ if (this._subscribed)
433
+ this._setDevices(devices);
434
+ });
435
+ };
436
+ // Bring the microphone watcher in line with the latest `audio` constraint when
437
+ // observe() is called again on an already-live Core. Returns the new watcher's
438
+ // initial-query promise (so observe() can fold it into `ready`); resolved when
439
+ // nothing started.
440
+ _reconcileAudioWatcher() {
441
+ if (this._constraints.audio && !this._micWatcher && hasMediaDevices()) {
442
+ this._micWatcher = new MediaPermissionWatcher("microphone", (s) => this._setAudioPermission(s));
443
+ return this._micWatcher.observe();
444
+ }
445
+ else if (!this._constraints.audio && this._micWatcher) {
446
+ this._micWatcher.dispose();
447
+ this._micWatcher = null;
448
+ this._setAudioPermission(null);
449
+ }
450
+ return Promise.resolve();
451
+ }
452
+ _restart() {
453
+ this._release(false);
454
+ void this._acquire();
455
+ }
456
+ async _acquire() {
457
+ const gen = ++this._gen;
458
+ const constraints = buildConstraints(this._constraints);
459
+ const { stream, error } = await requestUserMedia(constraints);
460
+ // Superseded by a newer acquire (rapid restart) or disposed while in flight:
461
+ // stop the orphan stream and bail without mutating state. The `ended` listeners
462
+ // are attached only AFTER this gen check (below), so stopping the orphan here
463
+ // cannot fire _onTrackEnded — no spurious `ended` event / state mutation. Keep
464
+ // this stop strictly before listener attachment if the order is ever refactored.
465
+ if (gen !== this._gen) {
466
+ stopAllTracks(stream ?? null);
467
+ return;
468
+ }
469
+ if (error) {
470
+ this._setError(error);
471
+ if (error.name === "NotAllowedError") {
472
+ this._setPermission("denied");
473
+ // Hard denial: drop `desired` so a later visibility-restore (resume()) does
474
+ // not silently re-attempt getUserMedia on every page-visible. A transient
475
+ // failure (NotReadableError = device busy) keeps `desired` so it can recover.
476
+ this._desired = false;
477
+ }
478
+ else if (error.name === "unsupported") {
479
+ this._setPermission("unsupported");
480
+ this._desired = false;
481
+ }
482
+ this._setActive(false);
483
+ return;
484
+ }
485
+ const live = stream;
486
+ this._stream = live;
487
+ for (const track of live.getTracks()) {
488
+ track.addEventListener("ended", this._onTrackEnded);
489
+ }
490
+ this._setError(null);
491
+ // getUserMedia success is authoritative for permission.
492
+ this._setPermission("granted");
493
+ // Only assert mic-granted when the grant actually produced an audio track. With
494
+ // today's boolean `audio` this is equivalent to `_constraints.audio`, but it stays
495
+ // correct under a future non-mandatory `{ audio: {...} }` constraint where the
496
+ // browser may grant video while omitting audio.
497
+ if (this._constraints.audio && live.getAudioTracks().length > 0) {
498
+ this._setAudioPermission("granted");
499
+ }
500
+ this._updateDeviceId(live);
501
+ this._setActive(true);
502
+ // Publish the live handle for the direct element→element channel.
503
+ this._dispatch("wcs-camera:stream-ready", live);
504
+ // Labels become available after a grant; refresh the device list.
505
+ const devices = await enumerateVideoDevices();
506
+ if (gen === this._gen) {
507
+ this._setDevices(devices);
508
+ }
509
+ }
510
+ _release(silent) {
511
+ if (!this._stream)
512
+ return;
513
+ for (const track of this._stream.getTracks()) {
514
+ track.removeEventListener("ended", this._onTrackEnded);
515
+ }
516
+ stopAllTracks(this._stream);
517
+ this._stream = null;
518
+ if (silent) {
519
+ this._active = false;
520
+ }
521
+ else {
522
+ this._setActive(false);
523
+ }
524
+ }
525
+ _onTrackEnded = () => {
526
+ // OS revoked a track (unplug / taken by another app). actual drops; desired
527
+ // stays true so resume()/visibility-restore can re-acquire.
528
+ this._release(false);
529
+ this._dispatch("wcs-camera:ended", null);
530
+ };
531
+ _updateDeviceId(stream) {
532
+ const videoTrack = stream.getVideoTracks()[0];
533
+ if (videoTrack && typeof videoTrack.getSettings === "function") {
534
+ const settings = videoTrack.getSettings();
535
+ this._setDeviceId(settings.deviceId ?? null);
536
+ }
537
+ }
538
+ }
539
+
540
+ /**
541
+ * `<wcs-camera>` — declarative camera capture with a built-in preview.
542
+ *
543
+ * The element owns a `<video>` in its shadow root and assigns the live
544
+ * `MediaStream` to `video.srcObject` internally, so the non-serializable handle
545
+ * never crosses the state boundary (design §1, case B). For consumers (a
546
+ * `<wcs-recorder>`, an external `<video>`), the stream is also published via the
547
+ * `wcs-camera:stream-ready` event-token for the direct element→element channel
548
+ * (design §2).
549
+ *
550
+ * Acquisition is explicit: `start()` / the `autostart` attribute prompt and
551
+ * acquire; merely connecting does not. While the page is hidden the stream is
552
+ * suspended (clearing the camera indicator) and re-acquired on return, unless
553
+ * `keep-alive` is set (e.g. during recording).
554
+ */
555
+ class WcsCamera extends HTMLElement {
556
+ static hasConnectedCallbackPromise = true;
557
+ static wcBindable = {
558
+ ...CameraCore.wcBindable,
559
+ inputs: [
560
+ { name: "audio", attribute: "audio" },
561
+ { name: "facingMode", attribute: "facing-mode" },
562
+ { name: "deviceId", attribute: "device-id" },
563
+ { name: "width", attribute: "width" },
564
+ { name: "height", attribute: "height" },
565
+ { name: "autostart", attribute: "autostart" },
566
+ { name: "keepAlive", attribute: "keep-alive" },
567
+ ],
568
+ commands: CameraCore.wcBindable.commands,
569
+ };
570
+ // `autostart` and `keep-alive` are intentionally NOT observed: `autostart` is a
571
+ // connect-time-only acquire trigger (read once in connectedCallback; flipping it
572
+ // later is meaningless), and `keep-alive` is read fresh on every visibilitychange
573
+ // (_onVisibilityChange), so it never needs to drive a re-acquire. The observed set
574
+ // is exactly the constraints that reshape the requested track.
575
+ static observedAttributes = ["facing-mode", "device-id", "audio", "width", "height"];
576
+ _core;
577
+ _video;
578
+ _connectedCallbackPromise = Promise.resolve();
579
+ _connected = false;
580
+ // True while switchCamera() rewrites several attributes at once. Each setAttribute /
581
+ // removeAttribute fires its own attributeChangedCallback synchronously; without this
582
+ // guard the FIRST change would re-acquire with the not-yet-updated constraints (and
583
+ // tear active down so the later change's re-acquire is skipped). We suppress the
584
+ // per-attribute re-acquire and drive a single one with the final constraints.
585
+ _batchingAttrs = false;
586
+ constructor() {
587
+ super();
588
+ this._core = new CameraCore(this);
589
+ const root = this.attachShadow({ mode: "open" });
590
+ const style = document.createElement("style");
591
+ style.textContent = ":host{display:inline-block}video{display:block;width:100%;height:100%}";
592
+ this._video = document.createElement("video");
593
+ this._video.autoplay = true;
594
+ this._video.muted = true;
595
+ this._video.setAttribute("playsinline", "");
596
+ this._video.setAttribute("part", "video");
597
+ root.append(style, this._video);
598
+ // Bind the live handle to the preview internally — never through state. These
599
+ // self-listeners are intentionally not removed on disconnect (asymmetric with the
600
+ // document-level `visibilitychange` in connect/disconnectedCallback): the target
601
+ // is `this`, so the listeners are collected together with the element — there is
602
+ // no external reference to leak. The visibility listener, by contrast, lives on
603
+ // `document` (outlives the element) and MUST be detached.
604
+ this.addEventListener("wcs-camera:stream-ready", this._onStreamReady);
605
+ this.addEventListener("wcs-camera:active-changed", this._onActiveChanged);
606
+ }
607
+ // --- Attribute accessors ---
608
+ get audio() { return this.hasAttribute("audio"); }
609
+ set audio(value) { this._toggleAttr("audio", value); }
610
+ get facingMode() {
611
+ return this.getAttribute("facing-mode") === "environment" ? "environment" : "user";
612
+ }
613
+ set facingMode(value) { this.setAttribute("facing-mode", value); }
614
+ get deviceId() { return this.getAttribute("device-id") ?? ""; }
615
+ set deviceId(value) { this.setAttribute("device-id", value); }
616
+ get width() { return this._numberAttr("width"); }
617
+ set width(value) { this.setAttribute("width", String(value)); }
618
+ get height() { return this._numberAttr("height"); }
619
+ set height(value) { this.setAttribute("height", String(value)); }
620
+ get autostart() { return this.hasAttribute("autostart"); }
621
+ set autostart(value) { this._toggleAttr("autostart", value); }
622
+ get keepAlive() { return this.hasAttribute("keep-alive"); }
623
+ set keepAlive(value) { this._toggleAttr("keep-alive", value); }
624
+ /** The internal preview `<video>` (for advanced styling/measurement). */
625
+ get videoElement() { return this._video; }
626
+ // --- Core delegated getters ---
627
+ get active() { return this._core.active; }
628
+ get permission() { return this._core.permission; }
629
+ get audioPermission() { return this._core.audioPermission; }
630
+ get devices() { return this._core.devices; }
631
+ get error() { return this._core.error; }
632
+ get connectedCallbackPromise() { return this._connectedCallbackPromise; }
633
+ // --- Commands ---
634
+ start() { this._core.start(); }
635
+ stop() { this._core.stop(); }
636
+ /**
637
+ * Toggle the front/back camera by updating the DOM attributes (the single source
638
+ * of truth), not just the Core's internal constraints. Deliberately does NOT call
639
+ * `CameraCore.switchCamera()` (which would mutate the Core's constraints behind the
640
+ * DOM's back, leaving the declared attributes stale). `device-id` is removed because
641
+ * it would otherwise take precedence over `facing-mode` (see buildConstraints) —
642
+ * leaving it pinned would silently undo the switch on the next re-acquire. Both
643
+ * attribute writes are batched (see `_batchingAttrs`) so they drive exactly ONE
644
+ * re-acquire here, with the final constraints — never an early acquire on a
645
+ * half-updated state. The DOM and the live camera stay in agreement.
646
+ */
647
+ switchCamera() {
648
+ const next = this.facingMode === "environment" ? "user" : "environment";
649
+ this._batchingAttrs = true;
650
+ try {
651
+ this.removeAttribute("device-id");
652
+ this.setAttribute("facing-mode", next);
653
+ }
654
+ finally {
655
+ this._batchingAttrs = false;
656
+ }
657
+ // Sync the (now-final) constraints and re-acquire once when a stream is live —
658
+ // the same `active`-guarded restart attributeChangedCallback performs.
659
+ this._core.observe(this._constraints());
660
+ if (this._core.active) {
661
+ this._core.start();
662
+ }
663
+ }
664
+ // --- Internal ---
665
+ _toggleAttr(name, value) {
666
+ if (value) {
667
+ this.setAttribute(name, "");
668
+ }
669
+ else {
670
+ this.removeAttribute(name);
671
+ }
672
+ }
673
+ _numberAttr(name) {
674
+ const attr = this.getAttribute(name);
675
+ if (attr === null || attr.trim() === "")
676
+ return NaN;
677
+ const parsed = Number(attr);
678
+ return Number.isFinite(parsed) ? parsed : NaN;
679
+ }
680
+ _constraints() {
681
+ const c = { audio: this.audio, facingMode: this.facingMode };
682
+ if (this.deviceId)
683
+ c.deviceId = this.deviceId;
684
+ if (Number.isFinite(this.width))
685
+ c.width = this.width;
686
+ if (Number.isFinite(this.height))
687
+ c.height = this.height;
688
+ return c;
689
+ }
690
+ _onStreamReady = (event) => {
691
+ this._video.srcObject = event.detail;
692
+ };
693
+ _onActiveChanged = (event) => {
694
+ // Clear the preview when the stream is released so the last frame does not stick.
695
+ if (event.detail === false) {
696
+ this._video.srcObject = null;
697
+ }
698
+ };
699
+ // --- Lifecycle ---
700
+ connectedCallback() {
701
+ this._connected = true;
702
+ this._connectedCallbackPromise = this._core.observe(this._constraints());
703
+ if (this.autostart) {
704
+ this._core.start();
705
+ }
706
+ document.addEventListener("visibilitychange", this._onVisibilityChange);
707
+ }
708
+ disconnectedCallback() {
709
+ this._connected = false;
710
+ document.removeEventListener("visibilitychange", this._onVisibilityChange);
711
+ this._core.dispose();
712
+ }
713
+ attributeChangedCallback(_name, oldValue, newValue) {
714
+ if (!this._connected || oldValue === newValue)
715
+ return;
716
+ // While switchCamera() batches several attribute writes, defer to its single
717
+ // post-batch re-acquire — otherwise the first write would acquire on stale
718
+ // constraints (see `_batchingAttrs`).
719
+ if (this._batchingAttrs)
720
+ return;
721
+ // Track the new constraints (and reconcile the microphone watcher).
722
+ this._core.observe(this._constraints());
723
+ // A constraints change re-acquires (switchMap-style restart) only when a stream
724
+ // is actually live. `active` is the deliberate guard (not `desired`): `active`
725
+ // implies `desired` (a stream only goes live under desired), so re-acquiring
726
+ // cannot spuriously re-`desired` a stopped camera. Guarding on `active` rather
727
+ // than `desired` also avoids force-acquiring while suspended/hidden (desired but
728
+ // not active) — the visibility handler owns resume there.
729
+ if (this._core.active) {
730
+ this._core.start();
731
+ }
732
+ }
733
+ _onVisibilityChange = () => {
734
+ if (this.keepAlive)
735
+ return;
736
+ if (document.visibilityState === "hidden") {
737
+ this._core.suspend();
738
+ }
739
+ else {
740
+ this._core.resume();
741
+ }
742
+ };
743
+ }
744
+
745
+ /**
746
+ * Headless media-recording primitive. Wraps MediaRecorder, consuming a borrowed
747
+ * `MediaStream` (received via `attachStream` over the direct channel — see
748
+ * docs/camera-recorder-tag-design.md §2) and producing a `Blob` clip.
749
+ *
750
+ * Ownership: the stream is BORROWED, never owned. The Core never stops its tracks
751
+ * — that is the camera's job (the acquirer owns release). Stopping here would tear
752
+ * down a stream that may still be previewing.
753
+ *
754
+ * Non-goal: the Core does NOT subscribe to the borrowed tracks' `ended` and does NOT
755
+ * auto-terminate a recording when the underlying stream dies (camera stop / OS
756
+ * revoke / switchCamera re-acquire). Reacting would mean reaching into a stream we do
757
+ * not own. The MediaRecorder keeps running against the now-dead source until an
758
+ * explicit stop(); the consumer (which owns the camera lifecycle) is responsible for
759
+ * stopping the recording when it tears the stream down. stop() then assembles
760
+ * whatever chunks were captured — never throws.
761
+ *
762
+ * Output: `dataavailable` chunks are collected and assembled into one `Blob` on
763
+ * stop, published via `wcs-recorder:recorded`. The `Blob` is structured-clone
764
+ * friendly (a settled value, unlike MediaStream) so it may flow through state.
765
+ * `objectURL` is a managed string — the Core revokes the previous URL before
766
+ * issuing a new one and on dispose. Failures never throw.
767
+ */
768
+ class RecorderCore extends EventTarget {
769
+ static wcBindable = {
770
+ protocol: "wc-bindable",
771
+ version: 1,
772
+ properties: [
773
+ { name: "recording", event: "wcs-recorder:recording-changed" },
774
+ { name: "paused", event: "wcs-recorder:paused-changed" },
775
+ { name: "duration", event: "wcs-recorder:duration-changed" },
776
+ { name: "mimeType", event: "wcs-recorder:mimetype-changed" },
777
+ { name: "blob", event: "wcs-recorder:recorded", getter: (e) => e.detail?.blob ?? null },
778
+ { name: "objectURL", event: "wcs-recorder:recorded", getter: (e) => e.detail?.objectURL ?? null },
779
+ { name: "error", event: "wcs-recorder:error" },
780
+ // event-token: detail = the assembled clip { blob, objectURL, mimeType, duration }.
781
+ { name: "recorded", event: "wcs-recorder:recorded", getter: (e) => e.detail },
782
+ // event-token (timeslice mode): detail = the streamed Blob chunk.
783
+ { name: "dataavailable", event: "wcs-recorder:dataavailable", getter: (e) => e.detail },
784
+ ],
785
+ commands: [
786
+ { name: "attachStream" },
787
+ { name: "start" },
788
+ { name: "stop" },
789
+ { name: "pause" },
790
+ { name: "resume" },
791
+ ],
792
+ };
793
+ _target;
794
+ _recording = false;
795
+ _paused = false;
796
+ _duration = 0;
797
+ _mimeType = "";
798
+ _blob = null;
799
+ _objectURL = null;
800
+ _error = null;
801
+ _recorder = null;
802
+ _stream = null; // borrowed — never stopped here
803
+ _chunks = [];
804
+ _timeslice = false;
805
+ _startTime = 0;
806
+ // Monotonic id of the current recording lifecycle. Bumped by every start() and
807
+ // dispose(); each MediaRecorder callback bails if stale so a torn-down/restarted
808
+ // recorder's late event cannot mutate state.
809
+ _gen = 0;
810
+ constructor(target) {
811
+ super();
812
+ this._target = target ?? this;
813
+ }
814
+ get recording() { return this._recording; }
815
+ get paused() { return this._paused; }
816
+ // Finalized at stop/pause only — there is no live ticking timer, so this stays 0
817
+ // from start() until the first pause()/stop() (see _elapsed / onstop / onpause).
818
+ get duration() { return this._duration; }
819
+ get mimeType() { return this._mimeType; }
820
+ get blob() { return this._blob; }
821
+ get objectURL() { return this._objectURL; }
822
+ get error() { return this._error; }
823
+ // --- State setters ---
824
+ _setRecording(v) {
825
+ if (this._recording === v)
826
+ return;
827
+ this._recording = v;
828
+ this._dispatch("wcs-recorder:recording-changed", v);
829
+ }
830
+ _setPaused(v) {
831
+ if (this._paused === v)
832
+ return;
833
+ this._paused = v;
834
+ this._dispatch("wcs-recorder:paused-changed", v);
835
+ }
836
+ _setDuration(v) {
837
+ if (this._duration === v)
838
+ return;
839
+ this._duration = v;
840
+ this._dispatch("wcs-recorder:duration-changed", v);
841
+ }
842
+ _setMimeType(v) {
843
+ if (this._mimeType === v)
844
+ return;
845
+ this._mimeType = v;
846
+ this._dispatch("wcs-recorder:mimetype-changed", v);
847
+ }
848
+ // Errors are dispatched on EVERY non-null occurrence by design — each failure is a
849
+ // distinct event, so unlike the value setters this is not content-deduped. Only the
850
+ // null→null transition is collapsed (clearing an already-clear error stays silent).
851
+ // The guard is written on null explicitly so it does NOT depend on callers passing a
852
+ // fresh object: a reused/cached non-null detail would still re-notify.
853
+ _setError(error) {
854
+ if (error === null && this._error === null)
855
+ return;
856
+ this._error = error;
857
+ this._dispatch("wcs-recorder:error", error);
858
+ }
859
+ _dispatch(type, detail) {
860
+ this._target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
861
+ }
862
+ // --- Public API ---
863
+ /**
864
+ * Borrow a stream for recording (the direct-channel sink). Synchronous, no
865
+ * await: the live handle is captured by reference and never stored in state.
866
+ * Does NOT stop any previously-borrowed stream — ownership stays with the camera.
867
+ *
868
+ * Re-attaching mid-recording takes effect on the NEXT start(), not the current
869
+ * recording: the live MediaRecorder was already constructed around the previous
870
+ * stream and keeps recording it until stop(). The borrowed reference is swapped so
871
+ * the following start() uses the new stream.
872
+ */
873
+ attachStream(stream) {
874
+ this._stream = stream;
875
+ }
876
+ /** Start recording the borrowed stream. Never throws — surfaces `error`. */
877
+ start(options = {}) {
878
+ if (!hasMediaRecorder()) {
879
+ this._setError({ name: "unsupported", message: "MediaRecorder is not available in this environment." });
880
+ return;
881
+ }
882
+ if (!this._stream) {
883
+ this._setError({ name: "NoStreamError", message: "No stream attached. Wire a camera's stream-ready to attachStream first." });
884
+ return;
885
+ }
886
+ if (this._recording)
887
+ return;
888
+ const recOptions = {};
889
+ if (options.mimeType && this._isTypeSupported(options.mimeType)) {
890
+ recOptions.mimeType = options.mimeType;
891
+ }
892
+ if (typeof options.audioBitsPerSecond === "number")
893
+ recOptions.audioBitsPerSecond = options.audioBitsPerSecond;
894
+ if (typeof options.videoBitsPerSecond === "number")
895
+ recOptions.videoBitsPerSecond = options.videoBitsPerSecond;
896
+ let recorder;
897
+ try {
898
+ recorder = new MediaRecorder(this._stream, recOptions);
899
+ }
900
+ catch (error) {
901
+ this._setError(normalizeMediaError(error));
902
+ return;
903
+ }
904
+ const gen = ++this._gen;
905
+ this._chunks = [];
906
+ this._timeslice = typeof options.timeslice === "number" && options.timeslice > 0;
907
+ recorder.ondataavailable = (event) => {
908
+ if (gen !== this._gen)
909
+ return;
910
+ if (event.data && event.data.size > 0) {
911
+ this._chunks.push(event.data);
912
+ if (this._timeslice) {
913
+ this._dispatch("wcs-recorder:dataavailable", event.data);
914
+ }
915
+ }
916
+ };
917
+ recorder.onstop = () => {
918
+ if (gen !== this._gen)
919
+ return;
920
+ // When stopped while paused, `_duration` was already finalized in onpause —
921
+ // recomputing _elapsed() here would wrongly include the paused gap (the
922
+ // clock kept running but `_startTime` was not advanced). Keep the held value.
923
+ if (!this._paused)
924
+ this._setDuration(this._elapsed());
925
+ this._assembleBlob();
926
+ this._setPaused(false);
927
+ this._setRecording(false);
928
+ };
929
+ recorder.onerror = (event) => {
930
+ if (gen !== this._gen)
931
+ return;
932
+ const err = event.error;
933
+ this._setError(normalizeMediaError(err ?? { name: "RecorderError" }));
934
+ };
935
+ recorder.onpause = () => {
936
+ if (gen !== this._gen)
937
+ return;
938
+ this._setDuration(this._elapsed());
939
+ this._setPaused(true);
940
+ };
941
+ recorder.onresume = () => {
942
+ if (gen !== this._gen)
943
+ return;
944
+ this._startTime = this._now() - this._duration;
945
+ this._setPaused(false);
946
+ };
947
+ this._recorder = recorder;
948
+ this._setError(null);
949
+ this._setMimeType(recorder.mimeType || recOptions.mimeType || "");
950
+ this._startTime = this._now();
951
+ this._setDuration(0);
952
+ recorder.start(this._timeslice ? options.timeslice : undefined);
953
+ this._setRecording(true);
954
+ }
955
+ /** Stop recording; the assembled Blob is published from the recorder's onstop. */
956
+ stop() {
957
+ if (this._recorder && this._recorder.state !== "inactive") {
958
+ this._recorder.stop();
959
+ }
960
+ }
961
+ pause() {
962
+ if (this._recorder && this._recorder.state === "recording") {
963
+ this._recorder.pause();
964
+ }
965
+ }
966
+ resume() {
967
+ if (this._recorder && this._recorder.state === "paused") {
968
+ this._recorder.resume();
969
+ }
970
+ }
971
+ /** Stop in-flight recording, revoke the last object URL, drop the borrowed stream. */
972
+ dispose() {
973
+ // Bump the generation first so the native stop()'s onstop (gen-guarded) does
974
+ // not run on a disposed Core — then reset the recording flags directly here.
975
+ this._gen++;
976
+ if (this._recorder && this._recorder.state !== "inactive") {
977
+ try {
978
+ this._recorder.stop();
979
+ }
980
+ catch {
981
+ // A recorder already torn down by the environment must not throw here.
982
+ }
983
+ }
984
+ this._recorder = null;
985
+ this._revokeUrl();
986
+ // Drop the borrowed reference WITHOUT stopping its tracks — the camera owns it.
987
+ this._stream = null;
988
+ // Reset transient recording state silently (onstop was gen-guarded out above).
989
+ this._recording = false;
990
+ this._paused = false;
991
+ }
992
+ // --- Internal ---
993
+ _assembleBlob() {
994
+ const blob = new Blob(this._chunks, this._mimeType ? { type: this._mimeType } : undefined);
995
+ this._chunks = [];
996
+ // The PREVIOUS clip's object URL is revoked here, before minting the new one — so
997
+ // any consumer still pointing a `<video src>` at the old URL will break once a new
998
+ // recording completes. Consumers must follow the latest `objectURL`/`recorded`
999
+ // value and not pin a stale URL. (The `blob` is unaffected — prefer flowing it.)
1000
+ this._revokeUrl();
1001
+ const objectURL = this._createUrl(blob);
1002
+ this._blob = blob;
1003
+ this._objectURL = objectURL;
1004
+ const detail = {
1005
+ blob,
1006
+ objectURL,
1007
+ mimeType: this._mimeType,
1008
+ duration: this._duration,
1009
+ };
1010
+ this._dispatch("wcs-recorder:recorded", detail);
1011
+ }
1012
+ _revokeUrl() {
1013
+ if (this._objectURL && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
1014
+ URL.revokeObjectURL(this._objectURL);
1015
+ }
1016
+ this._objectURL = null;
1017
+ }
1018
+ _createUrl(blob) {
1019
+ if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
1020
+ return URL.createObjectURL(blob);
1021
+ }
1022
+ return "";
1023
+ }
1024
+ // Only called after hasMediaRecorder() has confirmed the API, so MediaRecorder
1025
+ // and its isTypeSupported are present.
1026
+ _isTypeSupported(type) {
1027
+ const MR = globalThis.MediaRecorder;
1028
+ return MR.isTypeSupported(type);
1029
+ }
1030
+ // performance.now() is universally available wherever MediaRecorder runs
1031
+ // (browsers and the happy-dom test env), so no fallback guard is needed.
1032
+ _now() {
1033
+ return performance.now();
1034
+ }
1035
+ _elapsed() {
1036
+ return Math.max(0, Math.round(this._now() - this._startTime));
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * `<wcs-recorder>` — declarative media recording. Wraps RecorderCore and records a
1042
+ * borrowed `MediaStream` received via the `attachStream` command (the direct
1043
+ * channel from `<wcs-camera>`'s `stream-ready`). It never owns or stops the stream.
1044
+ *
1045
+ * Recording parameters (`mime-type` / `timeslice` / bitrates) are mirrored
1046
+ * attributes. The assembled clip is published as `wcs-recorder:recorded`
1047
+ * (`{ blob, objectURL, mimeType, duration }`) and the `blob` / `objectURL` value
1048
+ * properties — a settled `Blob` is a value and may flow through state.
1049
+ */
1050
+ class WcsRecorder extends HTMLElement {
1051
+ static wcBindable = {
1052
+ ...RecorderCore.wcBindable,
1053
+ // `mimeType` deliberately appears on TWO surfaces: as an output `property`
1054
+ // (inherited from RecorderCore — the browser-resolved recording type, event
1055
+ // `mimetype-changed`) and as an `input` (the `mime-type` request attribute). They
1056
+ // share a base name but are distinct directions: the property is read-only output
1057
+ // (getter → Core), the input is the write-only request (setter → attribute, read
1058
+ // back in _options()). See README "request vs. resolved".
1059
+ inputs: [
1060
+ { name: "mimeType", attribute: "mime-type" },
1061
+ { name: "timeslice", attribute: "timeslice" },
1062
+ { name: "audioBitsPerSecond", attribute: "audio-bits" },
1063
+ { name: "videoBitsPerSecond", attribute: "video-bits" },
1064
+ ],
1065
+ commands: RecorderCore.wcBindable.commands,
1066
+ };
1067
+ _core;
1068
+ constructor() {
1069
+ super();
1070
+ this._core = new RecorderCore(this);
1071
+ }
1072
+ // --- Attribute accessors ---
1073
+ // `mimeType` is an OUTPUT value property (the Core-resolved recording type,
1074
+ // published via `wcs-recorder:mimetype-changed`), so the getter delegates to the
1075
+ // Core — NOT the `mime-type` input attribute. The attribute is a *request*: the
1076
+ // browser may pick a different type, or fill one in when none was requested, and
1077
+ // bindings must read the actual value. The input side is read straight from the
1078
+ // attribute in `_options()`. The setter still writes the request attribute.
1079
+ get mimeType() { return this._core.mimeType; }
1080
+ set mimeType(value) { this.setAttribute("mime-type", value); }
1081
+ get timeslice() { return this._numberAttr("timeslice"); }
1082
+ set timeslice(value) { this.setAttribute("timeslice", String(value)); }
1083
+ get audioBitsPerSecond() { return this._numberAttr("audio-bits"); }
1084
+ set audioBitsPerSecond(value) { this.setAttribute("audio-bits", String(value)); }
1085
+ get videoBitsPerSecond() { return this._numberAttr("video-bits"); }
1086
+ set videoBitsPerSecond(value) { this.setAttribute("video-bits", String(value)); }
1087
+ // --- Core delegated getters ---
1088
+ get recording() { return this._core.recording; }
1089
+ get paused() { return this._core.paused; }
1090
+ get duration() { return this._core.duration; }
1091
+ get blob() { return this._core.blob; }
1092
+ get objectURL() { return this._core.objectURL; }
1093
+ get error() { return this._core.error; }
1094
+ // --- Commands ---
1095
+ /** Borrow a stream (the direct-channel sink). */
1096
+ attachStream(stream) {
1097
+ this._core.attachStream(stream);
1098
+ }
1099
+ start() {
1100
+ this._core.start(this._options());
1101
+ }
1102
+ stop() { this._core.stop(); }
1103
+ pause() { this._core.pause(); }
1104
+ resume() { this._core.resume(); }
1105
+ // --- Internal ---
1106
+ _numberAttr(name) {
1107
+ const attr = this.getAttribute(name);
1108
+ if (attr === null || attr.trim() === "")
1109
+ return NaN;
1110
+ const parsed = Number(attr);
1111
+ return Number.isFinite(parsed) ? parsed : NaN;
1112
+ }
1113
+ _options() {
1114
+ const o = {};
1115
+ // Read the requested type from the input attribute directly — `get mimeType()` is
1116
+ // the resolved OUTPUT value, not the request.
1117
+ const requested = this.getAttribute("mime-type") ?? "";
1118
+ if (requested)
1119
+ o.mimeType = requested;
1120
+ if (Number.isFinite(this.timeslice))
1121
+ o.timeslice = this.timeslice;
1122
+ if (Number.isFinite(this.audioBitsPerSecond))
1123
+ o.audioBitsPerSecond = this.audioBitsPerSecond;
1124
+ if (Number.isFinite(this.videoBitsPerSecond))
1125
+ o.videoBitsPerSecond = this.videoBitsPerSecond;
1126
+ return o;
1127
+ }
1128
+ // --- Lifecycle ---
1129
+ connectedCallback() {
1130
+ this.style.display = "none";
1131
+ }
1132
+ disconnectedCallback() {
1133
+ this._core.dispose();
1134
+ }
1135
+ }
1136
+
1137
+ function registerComponents() {
1138
+ if (!customElements.get(config.tagNames.camera)) {
1139
+ customElements.define(config.tagNames.camera, WcsCamera);
1140
+ }
1141
+ if (!customElements.get(config.tagNames.recorder)) {
1142
+ customElements.define(config.tagNames.recorder, WcsRecorder);
1143
+ }
1144
+ }
1145
+
1146
+ function bootstrapCamera(userConfig) {
1147
+ if (userConfig) {
1148
+ setConfig(userConfig);
1149
+ }
1150
+ registerComponents();
1151
+ }
1152
+
1153
+ export { CameraCore, RecorderCore, WcsCamera, WcsRecorder, bootstrapCamera, getConfig };
1154
+ //# sourceMappingURL=index.esm.js.map