@wcstack/camera 4.4.0 → 4.6.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) {
@@ -676,6 +795,21 @@ function hasAccessorOnPrototype(target, name) {
676
795
  }
677
796
  return false;
678
797
  }
798
+ // 属性になってしまったオブジェクトの入力(`files="[object File]"`)を、要素ごとに 1 回だけ言う。
799
+ // React 19 は定義前の要素にプロパティが無いのでオブジェクトを属性に書き、Vue の `.attr` も
800
+ // 書く。要素はその入力をプロパティでしか読まないので、何も受け取らず、何も言わなかった
801
+ // (docs/seam-apps.md、fw-inventory)。
802
+ const toldObjectAttribute = new WeakSet();
803
+ function warnObjectAttribute(element, name, value) {
804
+ if (toldObjectAttribute.has(element))
805
+ return;
806
+ toldObjectAttribute.add(element);
807
+ const tag = element.localName ?? "element";
808
+ console.warn(`[wcs/input-as-attribute] <${tag}> has the attribute ${name}="${value}": an object was written as an ` +
809
+ `attribute, so the element did not receive it. A framework does that when the property is set before the ` +
810
+ `element's definition loads (React 19), or when told to (Vue's .attr). Load the definition before rendering, ` +
811
+ `or assign the property through a ref (Vue: .prop). (Said once per element.)`, element);
812
+ }
679
813
  /**
680
814
  * `connectedCallback` の先頭で呼ぶ。宣言済み input のうち upgrade 前の代入で
681
815
  * accessor をシャドウしている own プロパティを、delete → 再代入で setter に通し直す。
@@ -683,14 +817,19 @@ function hasAccessorOnPrototype(target, name) {
683
817
  * - 冪等: 再代入は accessor を通るので own プロパティは残らず、2 回目以降は no-op。
684
818
  * - 宣言に `inputs` が無い要素、`wcBindable` を持たない要素では何もしない。
685
819
  * - 値の意味は変えない。今まで捨てられていた代入が届くようになる一方向の変化。
820
+ * - 値が `[object …]` の属性(属性に書かれてしまったオブジェクトの入力)は、要素ごとに 1 回警告する。
686
821
  */
687
822
  function upgradeProperties(element) {
688
823
  const declaration = element.constructor?.wcBindable;
689
824
  const inputs = declaration?.inputs;
690
825
  if (inputs === undefined)
691
826
  return;
827
+ const getAttribute = element.getAttribute;
692
828
  for (const input of inputs) {
693
829
  const name = input.name;
830
+ const attribute = typeof getAttribute === "function" ? getAttribute.call(element, name.toLowerCase()) : null;
831
+ if (attribute !== null && attribute.startsWith("[object "))
832
+ warnObjectAttribute(element, name, attribute);
694
833
  if (!Object.prototype.hasOwnProperty.call(element, name))
695
834
  continue;
696
835
  if (!hasAccessorOnPrototype(element, name))
@@ -757,6 +896,30 @@ function reflectBooleanAttribute(el, name, value) {
757
896
  writeAttribute(el, name, value === undefined ? initial : value ? "" : null);
758
897
  }
759
898
 
899
+ const PREVIEW_CSS = ":host{display:inline-block}video{display:block;width:100%;height:100%}";
900
+ // One constructed sheet for every preview. A Content-Security-Policy's `style-src` governs
901
+ // `<style>` elements (a shadow root's included) but not constructed sheets, so a page whose
902
+ // policy has no 'unsafe-inline' still gets the preview's layout. A `<style>` is the fallback
903
+ // where constructed sheets are not available.
904
+ let previewSheet;
905
+ function adoptPreviewStyles(root) {
906
+ if (previewSheet === undefined) {
907
+ try {
908
+ previewSheet = new CSSStyleSheet();
909
+ previewSheet.replaceSync(PREVIEW_CSS);
910
+ }
911
+ catch {
912
+ previewSheet = null;
913
+ }
914
+ }
915
+ if (previewSheet !== null && Array.isArray(root.adoptedStyleSheets)) {
916
+ root.adoptedStyleSheets = [previewSheet];
917
+ return;
918
+ }
919
+ const style = document.createElement("style");
920
+ style.textContent = PREVIEW_CSS;
921
+ root.append(style);
922
+ }
760
923
  /**
761
924
  * `<wcs-camera>` — declarative camera capture with a built-in preview.
762
925
  *
@@ -808,14 +971,13 @@ class WcsCamera extends HTMLElement {
808
971
  super();
809
972
  this._core = new CameraCore(this);
810
973
  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%}";
974
+ adoptPreviewStyles(root);
813
975
  this._video = document.createElement("video");
814
976
  this._video.autoplay = true;
815
977
  this._video.muted = true;
816
978
  this._video.setAttribute("playsinline", "");
817
979
  this._video.setAttribute("part", "video");
818
- root.append(style, this._video);
980
+ root.append(this._video);
819
981
  // Bind the live handle to the preview internally — never through state. These
820
982
  // self-listeners are intentionally not removed on disconnect (asymmetric with the
821
983
  // document-level `visibilitychange` in connect/disconnectedCallback): the target
@@ -1092,13 +1254,18 @@ class RecorderCore extends EventTarget {
1092
1254
  _errorInfo = null;
1093
1255
  _recorder = null;
1094
1256
  _stream = null; // borrowed — never stopped here
1095
- _chunks = [];
1257
+ // the stream the live MediaRecorder records (attachStream may swap _stream mid-recording)
1258
+ _recordingStream = null;
1096
1259
  _timeslice = false;
1097
1260
  _startTime = 0;
1098
1261
  // Monotonic id of the current recording lifecycle. Bumped by every start() and
1099
1262
  // dispose(); each MediaRecorder callback bails if stale so a torn-down/restarted
1100
1263
  // recorder's late event cannot mutate state.
1101
1264
  _gen = 0;
1265
+ // The generation of a recording that dispose() stopped: its MediaRecorder still delivers
1266
+ // the clip (onstop comes later in a browser), and only that clip gets through — with the
1267
+ // type and duration it had when the element went away.
1268
+ _closing = null;
1102
1269
  // SSR: recording is command-driven (attachStream/start), so there is no
1103
1270
  // asynchronous probe to await — readiness is immediate.
1104
1271
  _ready = Promise.resolve();
@@ -1222,19 +1389,43 @@ class RecorderCore extends EventTarget {
1222
1389
  return;
1223
1390
  }
1224
1391
  const gen = ++this._gen;
1225
- this._chunks = [];
1392
+ // per recording: a clip finishing after a disconnect must not take a later one's chunks
1393
+ const chunks = [];
1394
+ const stream = this._stream;
1226
1395
  this._timeslice = typeof options.timeslice === "number" && options.timeslice > 0;
1396
+ const closing = () => this._closing !== null && this._closing.gen === gen;
1227
1397
  recorder.ondataavailable = (event) => {
1228
- if (gen !== this._gen)
1398
+ if (gen !== this._gen && !closing())
1229
1399
  return;
1230
1400
  if (event.data && event.data.size > 0) {
1231
- this._chunks.push(event.data);
1401
+ chunks.push(event.data);
1402
+ if (gen !== this._gen)
1403
+ return;
1404
+ // a browser that resolved no type at start still types the chunks
1405
+ if (!this._mimeType && event.data.type)
1406
+ this._setMimeType(event.data.type);
1232
1407
  if (this._timeslice) {
1233
1408
  this._dispatch("wcs-recorder:dataavailable", event.data);
1234
1409
  }
1235
1410
  }
1236
1411
  };
1412
+ // Chromium resolves the type only when recording starts (`mimeType` is "" until then
1413
+ // when none was requested): the resolved type is the one the clip has.
1414
+ recorder.onstart = () => {
1415
+ if (gen === this._gen && recorder.mimeType)
1416
+ this._setMimeType(recorder.mimeType);
1417
+ };
1237
1418
  recorder.onstop = () => {
1419
+ endRecording(stream);
1420
+ if (this._recordingStream === stream)
1421
+ this._recordingStream = null;
1422
+ if (closing()) {
1423
+ // stopped by dispose(): recording / paused / duration were published then
1424
+ const { mimeType, duration } = this._closing;
1425
+ this._closing = null;
1426
+ this._assembleBlob(chunks, mimeType, duration);
1427
+ return;
1428
+ }
1238
1429
  if (gen !== this._gen)
1239
1430
  return;
1240
1431
  // When stopped while paused, `_duration` was already finalized in onpause —
@@ -1242,7 +1433,7 @@ class RecorderCore extends EventTarget {
1242
1433
  // clock kept running but `_startTime` was not advanced). Keep the held value.
1243
1434
  if (!this._paused)
1244
1435
  this._setDuration(this._elapsed());
1245
- this._assembleBlob();
1436
+ this._assembleBlob(chunks, this._mimeType, this._duration);
1246
1437
  this._setPaused(false);
1247
1438
  this._setRecording(false);
1248
1439
  };
@@ -1270,6 +1461,8 @@ class RecorderCore extends EventTarget {
1270
1461
  this._startTime = this._now();
1271
1462
  this._setDuration(0);
1272
1463
  recorder.start(this._timeslice ? options.timeslice : undefined);
1464
+ beginRecording(stream);
1465
+ this._recordingStream = stream;
1273
1466
  this._setRecording(true);
1274
1467
  }
1275
1468
  _warnNotReady() {
@@ -1304,31 +1497,50 @@ class RecorderCore extends EventTarget {
1304
1497
  observe() {
1305
1498
  return this._ready;
1306
1499
  }
1307
- /** Stop in-flight recording, revoke the last object URL, drop the borrowed stream. */
1500
+ /**
1501
+ * Stop in-flight recording, revoke the last object URL, drop the borrowed stream.
1502
+ *
1503
+ * A recording in progress is not thrown away: it stops, and the clip arrives as
1504
+ * `wcs-recorder:recorded` when the MediaRecorder delivers it (later, in a browser) — as it
1505
+ * does when the stream's tracks end. `recording` / `paused` go back to false at once, with
1506
+ * their events: a route the element leaves keeps its nodes (and their bindings) for the
1507
+ * next entry, so a reset nobody heard would leave the page showing a recording that is
1508
+ * not there.
1509
+ */
1308
1510
  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.
1511
+ const recorder = this._recorder;
1512
+ const inFlight = recorder !== null && recorder.state !== "inactive";
1513
+ if (inFlight) {
1514
+ if (!this._paused)
1515
+ this._setDuration(this._elapsed());
1516
+ this._closing = { gen: this._gen, mimeType: this._mimeType, duration: this._duration };
1517
+ }
1518
+ // Bump the generation so no other callback of this recorder reaches the Core.
1311
1519
  this._gen++;
1312
- if (this._recorder && this._recorder.state !== "inactive") {
1520
+ this._recorder = null;
1521
+ this._revokeUrl();
1522
+ // Drop the borrowed reference WITHOUT stopping its tracks — the camera owns it.
1523
+ this._stream = null;
1524
+ this._setPaused(false);
1525
+ this._setRecording(false);
1526
+ if (inFlight) {
1313
1527
  try {
1314
- this._recorder.stop();
1528
+ recorder.stop();
1315
1529
  }
1316
1530
  catch {
1317
- // A recorder already torn down by the environment must not throw here.
1531
+ // A recorder already torn down by the environment must not throw here; no clip comes.
1532
+ this._closing = null;
1533
+ if (this._recordingStream)
1534
+ endRecording(this._recordingStream);
1535
+ this._recordingStream = null;
1318
1536
  }
1319
1537
  }
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
1538
  }
1328
1539
  // --- Internal ---
1329
- _assembleBlob() {
1330
- const blob = new Blob(this._chunks, this._mimeType ? { type: this._mimeType } : undefined);
1331
- this._chunks = [];
1540
+ _assembleBlob(chunks, mimeType, duration) {
1541
+ // the chunks carry the type when the recorder resolved none
1542
+ const type = mimeType || chunks[0]?.type || "";
1543
+ const blob = new Blob(chunks, type ? { type } : undefined);
1332
1544
  // The PREVIOUS clip's object URL is revoked here, before minting the new one — so
1333
1545
  // any consumer still pointing a `<video src>` at the old URL will break once a new
1334
1546
  // recording completes. Consumers must follow the latest `objectURL`/`recorded`
@@ -1340,8 +1552,8 @@ class RecorderCore extends EventTarget {
1340
1552
  const detail = {
1341
1553
  blob,
1342
1554
  objectURL,
1343
- mimeType: this._mimeType,
1344
- duration: this._duration,
1555
+ mimeType: type,
1556
+ duration,
1345
1557
  };
1346
1558
  this._dispatch("wcs-recorder:recorded", detail);
1347
1559
  }