@wcstack/camera 4.4.0 → 4.5.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/index.d.ts CHANGED
@@ -246,6 +246,8 @@ declare class CameraCore extends EventTarget {
246
246
  private _camWatcher;
247
247
  private _micWatcher;
248
248
  private _ready;
249
+ /** Whether `[wcs/camera-keep-alive-missing]` was said for this element (once per element). */
250
+ private _toldKeepAlive;
249
251
  constructor(target?: EventTarget);
250
252
  get active(): boolean;
251
253
  get permission(): MediaPermissionState;
@@ -303,13 +305,19 @@ declare class CameraCore extends EventTarget {
303
305
  * acquire stops its just-acquired orphan stream on resolve (see `_acquire`).
304
306
  */
305
307
  suspend(): void;
308
+ private _warnKeepAlive;
306
309
  /** Re-acquire if the camera is desired but not currently active — for page-visible. */
307
310
  resume(): void;
308
- /** Tear down: stop the stream and detach permission listeners. */
311
+ /**
312
+ * Tear down: stop the stream and detach permission listeners. `active` goes back to false
313
+ * with its event: a route the element leaves keeps its nodes (and their bindings) for the
314
+ * next entry, so a reset nobody heard would leave the page showing a camera that is off.
315
+ */
309
316
  dispose(): void;
310
317
  private _initPermissions;
311
318
  private _onDeviceChange;
312
319
  private _reconcileAudioWatcher;
320
+ private _watchMicrophone;
313
321
  private _restart;
314
322
  private _acquire;
315
323
  private _release;
@@ -430,10 +438,11 @@ declare class RecorderCore extends EventTarget {
430
438
  private _errorInfo;
431
439
  private _recorder;
432
440
  private _stream;
433
- private _chunks;
441
+ private _recordingStream;
434
442
  private _timeslice;
435
443
  private _startTime;
436
444
  private _gen;
445
+ private _closing;
437
446
  private _ready;
438
447
  /**
439
448
  * Whether `[wcs/command-not-ready]` was said for this element: a `start()` refused because the
@@ -489,7 +498,16 @@ declare class RecorderCore extends EventTarget {
489
498
  * resolves once ready. dispose() is its teardown counterpart.
490
499
  */
491
500
  observe(): Promise<void>;
492
- /** Stop in-flight recording, revoke the last object URL, drop the borrowed stream. */
501
+ /**
502
+ * Stop in-flight recording, revoke the last object URL, drop the borrowed stream.
503
+ *
504
+ * A recording in progress is not thrown away: it stops, and the clip arrives as
505
+ * `wcs-recorder:recorded` when the MediaRecorder delivers it (later, in a browser) — as it
506
+ * does when the stream's tracks end. `recording` / `paused` go back to false at once, with
507
+ * their events: a route the element leaves keeps its nodes (and their bindings) for the
508
+ * next entry, so a reset nobody heard would leave the page showing a recording that is
509
+ * not there.
510
+ */
493
511
  dispose(): void;
494
512
  private _assembleBlob;
495
513
  private _revokeUrl;
package/dist/index.esm.js CHANGED
@@ -62,6 +62,41 @@ function setConfig(partialConfig) {
62
62
  frozenConfig = null;
63
63
  }
64
64
 
65
+ // ===========================================================================
66
+ // AUTO-GENERATED FILE - DO NOT EDIT.
67
+ // Generated from /io-core/platform-capability.ts by scripts/sync-io-core.mjs.
68
+ // Run `node scripts/sync-io-core.mjs` after editing the source.
69
+ // ===========================================================================
70
+ /** Permissions-Policy が制御する機能(permission 名と同じ文字列)。 */
71
+ const POLICY_CONTROLLED = new Set([
72
+ "camera", "microphone", "geolocation", "midi", "clipboard-read", "clipboard-write",
73
+ "screen-wake-lock", "idle-detection", "accelerometer", "gyroscope", "magnetometer",
74
+ "ambient-light-sensor", "display-capture",
75
+ ]);
76
+ function featureUnavailable(feature) {
77
+ const g = globalThis;
78
+ if (g.isSecureContext === false)
79
+ return "insecure-context";
80
+ if (!POLICY_CONTROLLED.has(feature))
81
+ return null;
82
+ const policy = g.document?.permissionsPolicy ?? g.document?.featurePolicy;
83
+ if (policy === undefined || typeof policy.allowsFeature !== "function")
84
+ return null;
85
+ try {
86
+ return policy.allowsFeature(feature) ? null : "policy-blocked";
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /** `featureUnavailable` の理由を、作者が直し方を読める文にする。`what` は文頭(「The camera」など)。 */
93
+ function featureUnavailableMessage(what, feature, reason) {
94
+ return reason === "insecure-context"
95
+ ? `${what} needs a secure context (https:, or http://localhost), and this page is not one.`
96
+ : `${what} is turned off for this page by a Permissions-Policy: the server's Permissions-Policy header, ` +
97
+ `or an <iframe> without allow="${feature}". The browser's site settings cannot change that.`;
98
+ }
99
+
65
100
  /** True when getUserMedia is reachable (secure context with a media-devices impl). */
66
101
  function hasMediaDevices() {
67
102
  return typeof navigator !== "undefined"
@@ -107,17 +142,39 @@ function normalizeMediaError(error) {
107
142
  }
108
143
  return { name: "Error", message: "Media request failed." };
109
144
  }
110
- const UNSUPPORTED_ERROR = {
111
- name: "unsupported",
112
- message: "getUserMedia is not available (requires a secure context).",
113
- };
145
+ // getUserMedia is absent on an insecure origin, and in a browser without it: say which.
146
+ function unsupportedError() {
147
+ const reason = featureUnavailable("camera");
148
+ return {
149
+ name: "unsupported",
150
+ message: reason === "insecure-context"
151
+ ? featureUnavailableMessage("getUserMedia", "camera", reason)
152
+ : "getUserMedia is not available in this browser.",
153
+ };
154
+ }
155
+ /**
156
+ * Why this page cannot use the camera (or, with `audio`, the microphone): not a secure
157
+ * context, or turned off by a Permissions-Policy. `null` when it can, as far as the
158
+ * platform tells. `device` says which one, for the permission output it makes unsupported.
159
+ */
160
+ function mediaUnavailable(audio) {
161
+ const camera = featureUnavailable("camera");
162
+ if (camera !== null) {
163
+ return { device: "camera", error: { name: "unsupported", message: featureUnavailableMessage("The camera", "camera", camera) } };
164
+ }
165
+ const microphone = audio ? featureUnavailable("microphone") : null;
166
+ if (microphone !== null) {
167
+ return { device: "microphone", error: { name: "unsupported", message: featureUnavailableMessage("The microphone", "microphone", microphone) } };
168
+ }
169
+ return null;
170
+ }
114
171
  /**
115
172
  * Request a media stream. Never throws — resolves with `{ stream }` on success or
116
173
  * `{ error }` (normalized) on failure / when the API is unavailable.
117
174
  */
118
175
  async function requestUserMedia(constraints) {
119
176
  if (!hasMediaDevices()) {
120
- return { error: UNSUPPORTED_ERROR };
177
+ return { error: unsupportedError() };
121
178
  }
122
179
  try {
123
180
  const stream = await navigator.mediaDevices.getUserMedia(constraints);
@@ -220,6 +277,27 @@ class MediaPermissionWatcher {
220
277
  };
221
278
  }
222
279
 
280
+ /**
281
+ * Which streams a recorder is recording right now. A recorder borrows its stream and a
282
+ * camera owns it, and neither holds the other; but a camera that stops its stream while
283
+ * it is hidden ends a recording of it, so it asks here first
284
+ * (`[wcs/camera-keep-alive-missing]`). Counted, since two recorders may record one stream.
285
+ */
286
+ const recording = new WeakMap();
287
+ function beginRecording(stream) {
288
+ recording.set(stream, (recording.get(stream) ?? 0) + 1);
289
+ }
290
+ function endRecording(stream) {
291
+ const n = (recording.get(stream) ?? 0) - 1;
292
+ if (n > 0)
293
+ recording.set(stream, n);
294
+ else
295
+ recording.delete(stream);
296
+ }
297
+ function isRecording(stream) {
298
+ return recording.has(stream);
299
+ }
300
+
223
301
  /**
224
302
  * mediaCapabilities.ts
225
303
  *
@@ -364,6 +442,8 @@ class CameraCore extends EventTarget {
364
442
  _camWatcher = null;
365
443
  _micWatcher = null;
366
444
  _ready = Promise.resolve();
445
+ /** Whether `[wcs/camera-keep-alive-missing]` was said for this element (once per element). */
446
+ _toldKeepAlive = false;
367
447
  constructor(target) {
368
448
  super();
369
449
  this._target = target ?? this;
@@ -476,7 +556,7 @@ class CameraCore extends EventTarget {
476
556
  /** Release the camera (sets desired=false), stopping all tracks. */
477
557
  stop() {
478
558
  this._desired = false;
479
- this._release(false);
559
+ this._release();
480
560
  }
481
561
  /**
482
562
  * Toggle facingMode (user ↔ environment) and re-acquire if active. This is the
@@ -508,21 +588,37 @@ class CameraCore extends EventTarget {
508
588
  suspend() {
509
589
  this._gen++;
510
590
  if (this._stream) {
511
- this._release(false);
591
+ if (isRecording(this._stream))
592
+ this._warnKeepAlive();
593
+ this._release();
512
594
  }
513
595
  }
596
+ // A recording of the stream ends with it: what was recorded becomes the clip, and nothing
597
+ // said why the take was short.
598
+ _warnKeepAlive() {
599
+ if (this._toldKeepAlive)
600
+ return;
601
+ this._toldKeepAlive = true;
602
+ console.warn("[@wcstack/camera] [wcs/camera-keep-alive-missing] The page was hidden while this camera's stream was " +
603
+ "being recorded, so the camera stopped the stream and the recording ended with it. Keep the camera " +
604
+ "on while recording: bind keepAlive to the recording flag (keepAlive: <recording path>). (Said once per element.)", this._target);
605
+ }
514
606
  /** Re-acquire if the camera is desired but not currently active — for page-visible. */
515
607
  resume() {
516
608
  if (this._desired && !this._active) {
517
609
  this._restart();
518
610
  }
519
611
  }
520
- /** Tear down: stop the stream and detach permission listeners. */
612
+ /**
613
+ * Tear down: stop the stream and detach permission listeners. `active` goes back to false
614
+ * with its event: a route the element leaves keeps its nodes (and their bindings) for the
615
+ * next entry, so a reset nobody heard would leave the page showing a camera that is off.
616
+ */
521
617
  dispose() {
522
618
  this._subscribed = false;
523
619
  this._gen++;
524
620
  this._desired = false;
525
- this._release(true);
621
+ this._release();
526
622
  if (hasMediaDevices() && typeof navigator.mediaDevices.removeEventListener === "function") {
527
623
  navigator.mediaDevices.removeEventListener("devicechange", this._onDeviceChange);
528
624
  }
@@ -537,11 +633,19 @@ class CameraCore extends EventTarget {
537
633
  this._setPermission("unsupported");
538
634
  return Promise.resolve();
539
635
  }
540
- this._camWatcher = new MediaPermissionWatcher("camera", (s) => this._setPermission(s));
541
- const tasks = [this._camWatcher.observe()];
636
+ // A device this page cannot use (not a secure context, or a Permissions-Policy) is
637
+ // "unsupported", not what the Permissions API answers: Chromium says "granted" in a
638
+ // policy-blocked frame, which would read as "allowed" next to a start that fails.
639
+ const tasks = [];
640
+ if (featureUnavailable("camera") !== null) {
641
+ this._setPermission("unsupported");
642
+ }
643
+ else {
644
+ this._camWatcher = new MediaPermissionWatcher("camera", (s) => this._setPermission(s));
645
+ tasks.push(this._camWatcher.observe());
646
+ }
542
647
  if (this._constraints.audio) {
543
- this._micWatcher = new MediaPermissionWatcher("microphone", (s) => this._setAudioPermission(s));
544
- tasks.push(this._micWatcher.observe());
648
+ tasks.push(this._watchMicrophone());
545
649
  }
546
650
  // Track hot-plug: refresh the device list when a camera is added/removed.
547
651
  if (typeof navigator.mediaDevices.addEventListener === "function") {
@@ -562,22 +666,42 @@ class CameraCore extends EventTarget {
562
666
  // nothing started.
563
667
  _reconcileAudioWatcher() {
564
668
  if (this._constraints.audio && !this._micWatcher && hasMediaDevices()) {
565
- this._micWatcher = new MediaPermissionWatcher("microphone", (s) => this._setAudioPermission(s));
566
- return this._micWatcher.observe();
669
+ return this._watchMicrophone();
567
670
  }
568
- else if (!this._constraints.audio && this._micWatcher) {
569
- this._micWatcher.dispose();
671
+ else if (!this._constraints.audio && (this._micWatcher || this._audioPermission !== null)) {
672
+ this._micWatcher?.dispose();
570
673
  this._micWatcher = null;
571
674
  this._setAudioPermission(null);
572
675
  }
573
676
  return Promise.resolve();
574
677
  }
678
+ _watchMicrophone() {
679
+ if (featureUnavailable("microphone") !== null) {
680
+ this._setAudioPermission("unsupported");
681
+ return Promise.resolve();
682
+ }
683
+ this._micWatcher = new MediaPermissionWatcher("microphone", (s) => this._setAudioPermission(s));
684
+ return this._micWatcher.observe();
685
+ }
575
686
  _restart() {
576
- this._release(false);
687
+ this._release();
577
688
  void this._acquire();
578
689
  }
579
690
  async _acquire() {
580
691
  const gen = ++this._gen;
692
+ // Not asked of the browser when the page cannot use the device: its NotAllowedError
693
+ // would read as the user's refusal, which the site settings would fix and these cannot.
694
+ const off = hasMediaDevices() ? mediaUnavailable(!!this._constraints.audio) : null;
695
+ if (off !== null) {
696
+ this._setError(off.error);
697
+ if (off.device === "camera")
698
+ this._setPermission("unsupported");
699
+ else
700
+ this._setAudioPermission("unsupported");
701
+ this._desired = false;
702
+ this._setActive(false);
703
+ return;
704
+ }
581
705
  const constraints = buildConstraints(this._constraints);
582
706
  const { stream, error } = await requestUserMedia(constraints);
583
707
  // Superseded by a newer acquire (rapid restart) or disposed while in flight:
@@ -630,7 +754,7 @@ class CameraCore extends EventTarget {
630
754
  this._setDevices(devices);
631
755
  }
632
756
  }
633
- _release(silent) {
757
+ _release() {
634
758
  if (!this._stream)
635
759
  return;
636
760
  for (const track of this._stream.getTracks()) {
@@ -638,17 +762,12 @@ class CameraCore extends EventTarget {
638
762
  }
639
763
  stopAllTracks(this._stream);
640
764
  this._stream = null;
641
- if (silent) {
642
- this._active = false;
643
- }
644
- else {
645
- this._setActive(false);
646
- }
765
+ this._setActive(false);
647
766
  }
648
767
  _onTrackEnded = () => {
649
768
  // OS revoked a track (unplug / taken by another app). actual drops; desired
650
769
  // stays true so resume()/visibility-restore can re-acquire.
651
- this._release(false);
770
+ this._release();
652
771
  this._dispatch("wcs-camera:ended", null);
653
772
  };
654
773
  _updateDeviceId(stream) {
@@ -757,6 +876,30 @@ function reflectBooleanAttribute(el, name, value) {
757
876
  writeAttribute(el, name, value === undefined ? initial : value ? "" : null);
758
877
  }
759
878
 
879
+ const PREVIEW_CSS = ":host{display:inline-block}video{display:block;width:100%;height:100%}";
880
+ // One constructed sheet for every preview. A Content-Security-Policy's `style-src` governs
881
+ // `<style>` elements (a shadow root's included) but not constructed sheets, so a page whose
882
+ // policy has no 'unsafe-inline' still gets the preview's layout. A `<style>` is the fallback
883
+ // where constructed sheets are not available.
884
+ let previewSheet;
885
+ function adoptPreviewStyles(root) {
886
+ if (previewSheet === undefined) {
887
+ try {
888
+ previewSheet = new CSSStyleSheet();
889
+ previewSheet.replaceSync(PREVIEW_CSS);
890
+ }
891
+ catch {
892
+ previewSheet = null;
893
+ }
894
+ }
895
+ if (previewSheet !== null && Array.isArray(root.adoptedStyleSheets)) {
896
+ root.adoptedStyleSheets = [previewSheet];
897
+ return;
898
+ }
899
+ const style = document.createElement("style");
900
+ style.textContent = PREVIEW_CSS;
901
+ root.append(style);
902
+ }
760
903
  /**
761
904
  * `<wcs-camera>` — declarative camera capture with a built-in preview.
762
905
  *
@@ -808,14 +951,13 @@ class WcsCamera extends HTMLElement {
808
951
  super();
809
952
  this._core = new CameraCore(this);
810
953
  const root = this.attachShadow({ mode: "open" });
811
- const style = document.createElement("style");
812
- style.textContent = ":host{display:inline-block}video{display:block;width:100%;height:100%}";
954
+ adoptPreviewStyles(root);
813
955
  this._video = document.createElement("video");
814
956
  this._video.autoplay = true;
815
957
  this._video.muted = true;
816
958
  this._video.setAttribute("playsinline", "");
817
959
  this._video.setAttribute("part", "video");
818
- root.append(style, this._video);
960
+ root.append(this._video);
819
961
  // Bind the live handle to the preview internally — never through state. These
820
962
  // self-listeners are intentionally not removed on disconnect (asymmetric with the
821
963
  // document-level `visibilitychange` in connect/disconnectedCallback): the target
@@ -1092,13 +1234,18 @@ class RecorderCore extends EventTarget {
1092
1234
  _errorInfo = null;
1093
1235
  _recorder = null;
1094
1236
  _stream = null; // borrowed — never stopped here
1095
- _chunks = [];
1237
+ // the stream the live MediaRecorder records (attachStream may swap _stream mid-recording)
1238
+ _recordingStream = null;
1096
1239
  _timeslice = false;
1097
1240
  _startTime = 0;
1098
1241
  // Monotonic id of the current recording lifecycle. Bumped by every start() and
1099
1242
  // dispose(); each MediaRecorder callback bails if stale so a torn-down/restarted
1100
1243
  // recorder's late event cannot mutate state.
1101
1244
  _gen = 0;
1245
+ // The generation of a recording that dispose() stopped: its MediaRecorder still delivers
1246
+ // the clip (onstop comes later in a browser), and only that clip gets through — with the
1247
+ // type and duration it had when the element went away.
1248
+ _closing = null;
1102
1249
  // SSR: recording is command-driven (attachStream/start), so there is no
1103
1250
  // asynchronous probe to await — readiness is immediate.
1104
1251
  _ready = Promise.resolve();
@@ -1222,19 +1369,43 @@ class RecorderCore extends EventTarget {
1222
1369
  return;
1223
1370
  }
1224
1371
  const gen = ++this._gen;
1225
- this._chunks = [];
1372
+ // per recording: a clip finishing after a disconnect must not take a later one's chunks
1373
+ const chunks = [];
1374
+ const stream = this._stream;
1226
1375
  this._timeslice = typeof options.timeslice === "number" && options.timeslice > 0;
1376
+ const closing = () => this._closing !== null && this._closing.gen === gen;
1227
1377
  recorder.ondataavailable = (event) => {
1228
- if (gen !== this._gen)
1378
+ if (gen !== this._gen && !closing())
1229
1379
  return;
1230
1380
  if (event.data && event.data.size > 0) {
1231
- this._chunks.push(event.data);
1381
+ chunks.push(event.data);
1382
+ if (gen !== this._gen)
1383
+ return;
1384
+ // a browser that resolved no type at start still types the chunks
1385
+ if (!this._mimeType && event.data.type)
1386
+ this._setMimeType(event.data.type);
1232
1387
  if (this._timeslice) {
1233
1388
  this._dispatch("wcs-recorder:dataavailable", event.data);
1234
1389
  }
1235
1390
  }
1236
1391
  };
1392
+ // Chromium resolves the type only when recording starts (`mimeType` is "" until then
1393
+ // when none was requested): the resolved type is the one the clip has.
1394
+ recorder.onstart = () => {
1395
+ if (gen === this._gen && recorder.mimeType)
1396
+ this._setMimeType(recorder.mimeType);
1397
+ };
1237
1398
  recorder.onstop = () => {
1399
+ endRecording(stream);
1400
+ if (this._recordingStream === stream)
1401
+ this._recordingStream = null;
1402
+ if (closing()) {
1403
+ // stopped by dispose(): recording / paused / duration were published then
1404
+ const { mimeType, duration } = this._closing;
1405
+ this._closing = null;
1406
+ this._assembleBlob(chunks, mimeType, duration);
1407
+ return;
1408
+ }
1238
1409
  if (gen !== this._gen)
1239
1410
  return;
1240
1411
  // When stopped while paused, `_duration` was already finalized in onpause —
@@ -1242,7 +1413,7 @@ class RecorderCore extends EventTarget {
1242
1413
  // clock kept running but `_startTime` was not advanced). Keep the held value.
1243
1414
  if (!this._paused)
1244
1415
  this._setDuration(this._elapsed());
1245
- this._assembleBlob();
1416
+ this._assembleBlob(chunks, this._mimeType, this._duration);
1246
1417
  this._setPaused(false);
1247
1418
  this._setRecording(false);
1248
1419
  };
@@ -1270,6 +1441,8 @@ class RecorderCore extends EventTarget {
1270
1441
  this._startTime = this._now();
1271
1442
  this._setDuration(0);
1272
1443
  recorder.start(this._timeslice ? options.timeslice : undefined);
1444
+ beginRecording(stream);
1445
+ this._recordingStream = stream;
1273
1446
  this._setRecording(true);
1274
1447
  }
1275
1448
  _warnNotReady() {
@@ -1304,31 +1477,50 @@ class RecorderCore extends EventTarget {
1304
1477
  observe() {
1305
1478
  return this._ready;
1306
1479
  }
1307
- /** Stop in-flight recording, revoke the last object URL, drop the borrowed stream. */
1480
+ /**
1481
+ * Stop in-flight recording, revoke the last object URL, drop the borrowed stream.
1482
+ *
1483
+ * A recording in progress is not thrown away: it stops, and the clip arrives as
1484
+ * `wcs-recorder:recorded` when the MediaRecorder delivers it (later, in a browser) — as it
1485
+ * does when the stream's tracks end. `recording` / `paused` go back to false at once, with
1486
+ * their events: a route the element leaves keeps its nodes (and their bindings) for the
1487
+ * next entry, so a reset nobody heard would leave the page showing a recording that is
1488
+ * not there.
1489
+ */
1308
1490
  dispose() {
1309
- // Bump the generation first so the native stop()'s onstop (gen-guarded) does
1310
- // not run on a disposed Core — then reset the recording flags directly here.
1491
+ const recorder = this._recorder;
1492
+ const inFlight = recorder !== null && recorder.state !== "inactive";
1493
+ if (inFlight) {
1494
+ if (!this._paused)
1495
+ this._setDuration(this._elapsed());
1496
+ this._closing = { gen: this._gen, mimeType: this._mimeType, duration: this._duration };
1497
+ }
1498
+ // Bump the generation so no other callback of this recorder reaches the Core.
1311
1499
  this._gen++;
1312
- if (this._recorder && this._recorder.state !== "inactive") {
1500
+ this._recorder = null;
1501
+ this._revokeUrl();
1502
+ // Drop the borrowed reference WITHOUT stopping its tracks — the camera owns it.
1503
+ this._stream = null;
1504
+ this._setPaused(false);
1505
+ this._setRecording(false);
1506
+ if (inFlight) {
1313
1507
  try {
1314
- this._recorder.stop();
1508
+ recorder.stop();
1315
1509
  }
1316
1510
  catch {
1317
- // A recorder already torn down by the environment must not throw here.
1511
+ // A recorder already torn down by the environment must not throw here; no clip comes.
1512
+ this._closing = null;
1513
+ if (this._recordingStream)
1514
+ endRecording(this._recordingStream);
1515
+ this._recordingStream = null;
1318
1516
  }
1319
1517
  }
1320
- this._recorder = null;
1321
- this._revokeUrl();
1322
- // Drop the borrowed reference WITHOUT stopping its tracks — the camera owns it.
1323
- this._stream = null;
1324
- // Reset transient recording state silently (onstop was gen-guarded out above).
1325
- this._recording = false;
1326
- this._paused = false;
1327
1518
  }
1328
1519
  // --- Internal ---
1329
- _assembleBlob() {
1330
- const blob = new Blob(this._chunks, this._mimeType ? { type: this._mimeType } : undefined);
1331
- this._chunks = [];
1520
+ _assembleBlob(chunks, mimeType, duration) {
1521
+ // the chunks carry the type when the recorder resolved none
1522
+ const type = mimeType || chunks[0]?.type || "";
1523
+ const blob = new Blob(chunks, type ? { type } : undefined);
1332
1524
  // The PREVIOUS clip's object URL is revoked here, before minting the new one — so
1333
1525
  // any consumer still pointing a `<video src>` at the old URL will break once a new
1334
1526
  // recording completes. Consumers must follow the latest `objectURL`/`recorded`
@@ -1340,8 +1532,8 @@ class RecorderCore extends EventTarget {
1340
1532
  const detail = {
1341
1533
  blob,
1342
1534
  objectURL,
1343
- mimeType: this._mimeType,
1344
- duration: this._duration,
1535
+ mimeType: type,
1536
+ duration,
1345
1537
  };
1346
1538
  this._dispatch("wcs-recorder:recorded", detail);
1347
1539
  }