@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/README.ja.md +14 -6
- package/README.md +14 -6
- package/dist/auto.d.ts +2 -0
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/index.d.ts +21 -3
- package/dist/index.esm.js +264 -52
- package/dist/index.esm.js.map +1 -1
- package/package.json +5 -2
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
return this._micWatcher.observe();
|
|
669
|
+
return this._watchMicrophone();
|
|
567
670
|
}
|
|
568
|
-
else if (!this._constraints.audio && this._micWatcher) {
|
|
569
|
-
this._micWatcher
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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:
|
|
1344
|
-
duration
|
|
1555
|
+
mimeType: type,
|
|
1556
|
+
duration,
|
|
1345
1557
|
};
|
|
1346
1558
|
this._dispatch("wcs-recorder:recorded", detail);
|
|
1347
1559
|
}
|