@matter-server/dashboard 1.2.2 → 1.2.3
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/esm/components/webrtc-stream-view.d.ts +19 -0
- package/dist/esm/components/webrtc-stream-view.d.ts.map +1 -1
- package/dist/esm/components/webrtc-stream-view.js +107 -30
- package/dist/esm/components/webrtc-stream-view.js.map +1 -1
- package/dist/esm/pages/components/node-details.d.ts.map +1 -1
- package/dist/esm/pages/components/node-details.js +0 -1
- package/dist/esm/pages/components/node-details.js.map +1 -1
- package/dist/esm/util/camera-stream-budget.d.ts +51 -0
- package/dist/esm/util/camera-stream-budget.d.ts.map +1 -0
- package/dist/esm/util/camera-stream-budget.js +34 -0
- package/dist/esm/util/camera-stream-budget.js.map +6 -0
- package/dist/web/js/{attribute-write-dialog-CCOGtCsc.js → attribute-write-dialog-GOHez9ID.js} +1 -1
- package/dist/web/js/{command-invoke-dialog-DineKhxs.js → command-invoke-dialog-cN8yz0uL.js} +1 -1
- package/dist/web/js/{commission-node-dialog-BbyLvbCF.js → commission-node-dialog-DlTbsP25.js} +5 -5
- package/dist/web/js/{commission-node-existing-C0EQcHOJ.js → commission-node-existing-CObdE63L.js} +1 -1
- package/dist/web/js/{commission-node-thread-C_xYn78v.js → commission-node-thread-BUAmDLZC.js} +1 -1
- package/dist/web/js/{commission-node-wifi-mRvgT2Ws.js → commission-node-wifi-CNEBEc_C.js} +1 -1
- package/dist/web/js/{dialog-box-CcKXxq-S.js → dialog-box-V0om09Bd.js} +1 -1
- package/dist/web/js/{fabric-label-dialog-3xl50UuY.js → fabric-label-dialog-BKI2qEOw.js} +1 -1
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/{matter-dashboard-app-DiMb3vGL.js → matter-dashboard-app-B3BC4HU2.js} +140 -31
- package/dist/web/js/{node-acl-add-dialog-SMiX8hhL.js → node-acl-add-dialog-DHEm34fQ.js} +1 -1
- package/dist/web/js/{node-binding-dialog-XRgshO0p.js → node-binding-dialog-NdTWDtWj.js} +1 -1
- package/dist/web/js/{node-label-dialog-hLkCmvNT.js → node-label-dialog-BElnoFMC.js} +1 -1
- package/dist/web/js/{settings-dialog-nBQaH_2q.js → settings-dialog-CmgvIfKt.js} +1 -1
- package/package.json +3 -3
- package/src/components/webrtc-stream-view.ts +159 -44
- package/src/pages/components/node-details.ts +0 -1
- package/src/util/camera-stream-budget.ts +74 -0
|
@@ -17,6 +17,12 @@ import { LitElement, css, html } from "lit";
|
|
|
17
17
|
import { customElement, property, query, state } from "lit/decorators.js";
|
|
18
18
|
import { clientContext } from "../client/client-context.js";
|
|
19
19
|
import { asObject, pickNumber } from "../util/attribute-shapes.js";
|
|
20
|
+
import {
|
|
21
|
+
pixelRate,
|
|
22
|
+
planVideoMaxFrameRate,
|
|
23
|
+
VIDEO_MIN_FRAME_RATE,
|
|
24
|
+
videoStreamFitsSnapshotBudget,
|
|
25
|
+
} from "../util/camera-stream-budget.js";
|
|
20
26
|
import "./ha-svg-icon.js";
|
|
21
27
|
|
|
22
28
|
// Spec values from @matter/types globals/StreamUsage.ts and
|
|
@@ -29,12 +35,16 @@ const END_REASON_USER_HANGUP = 2;
|
|
|
29
35
|
export const CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID = 0x551;
|
|
30
36
|
const WEBRTC_TRANSPORT_PROVIDER_CLUSTER_ID = 0x553;
|
|
31
37
|
|
|
32
|
-
// AVSM FeatureMap bits (spec §11.2.4):
|
|
33
|
-
// OSD=7 enables on-screen display. When advertised,
|
|
34
|
-
// SnapshotStreamAllocate REQUIRE watermarkEnabled / osdEnabled fields.
|
|
38
|
+
// AVSM FeatureMap bits (spec §11.2.4): SNP=2 enables snapshot streams, WMARK=6
|
|
39
|
+
// enables watermark overlay, OSD=7 enables on-screen display. When advertised,
|
|
40
|
+
// VideoStreamAllocate and SnapshotStreamAllocate REQUIRE watermarkEnabled / osdEnabled fields.
|
|
41
|
+
export const AVSM_FEAT_SNP = 1 << 2;
|
|
35
42
|
export const AVSM_FEAT_WMARK = 1 << 6;
|
|
36
43
|
export const AVSM_FEAT_OSD = 1 << 7;
|
|
37
44
|
export const AVSM_FEATURE_MAP_ATTR_ID = 0xfffc;
|
|
45
|
+
// MaxEncodedPixelRate (attr 0x0001): shared encoder budget in px/s that video and
|
|
46
|
+
// snapshot streams draw from.
|
|
47
|
+
const AVSM_MAX_ENCODED_PIXEL_RATE_ATTR_ID = 0x1;
|
|
38
48
|
|
|
39
49
|
const DEFAULT_MAX_RESOLUTION = { width: 1920, height: 1080 };
|
|
40
50
|
const DEFAULT_MIN_RESOLUTION = { width: 640, height: 480 };
|
|
@@ -61,20 +71,34 @@ interface SnapshotCapability {
|
|
|
61
71
|
resolution: { width: number; height: number };
|
|
62
72
|
maxFrameRate: number;
|
|
63
73
|
imageCodec: number;
|
|
74
|
+
/** RequiresEncodedPixels — whether this capability draws from MaxEncodedPixelRate. */
|
|
75
|
+
requiresEncodedPixels: boolean;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
const SNAPSHOT_DEFAULTS: SnapshotCapability = {
|
|
67
79
|
resolution: { width: 1920, height: 1080 },
|
|
68
80
|
maxFrameRate: 30,
|
|
69
81
|
imageCodec: 0,
|
|
82
|
+
requiresEncodedPixels: true,
|
|
70
83
|
};
|
|
71
84
|
|
|
72
|
-
|
|
85
|
+
/** Clamp a requested resolution down to a capability's max, per dimension (never increases either). */
|
|
86
|
+
function clampToCapability(
|
|
87
|
+
requested: { width: number; height: number },
|
|
88
|
+
cap: { width: number; height: number },
|
|
89
|
+
): { width: number; height: number } {
|
|
90
|
+
return {
|
|
91
|
+
width: Math.min(requested.width, cap.width),
|
|
92
|
+
height: Math.min(requested.height, cap.height),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseSnapshotCapabilitiesFromList(list: unknown[], preferEncoderFree: boolean): SnapshotCapability {
|
|
73
97
|
// SnapshotCapabilitiesStruct field IDs per the Matter spec:
|
|
74
|
-
// 0=resolution (VideoResolutionStruct {0=width, 1=height}), 1=maxFrameRate, 2=imageCodec
|
|
75
|
-
// Cached attributes are tag-based (numeric keys); read_attribute
|
|
76
|
-
//
|
|
77
|
-
const candidates = list.map(asObject).filter((c): c is Record<string, unknown> => c !==
|
|
98
|
+
// 0=resolution (VideoResolutionStruct {0=width, 1=height}), 1=maxFrameRate, 2=imageCodec,
|
|
99
|
+
// 3=requiresEncodedPixels. Cached attributes are tag-based (numeric keys); read_attribute
|
|
100
|
+
// responses are name-based.
|
|
101
|
+
const candidates = list.map(asObject).filter((c): c is Record<string, unknown> => c !== null);
|
|
78
102
|
if (candidates.length === 0) return SNAPSHOT_DEFAULTS;
|
|
79
103
|
const parsed = candidates.map(cap => {
|
|
80
104
|
const res = asObject(cap["resolution"] ?? cap["0"]);
|
|
@@ -82,6 +106,7 @@ function parseSnapshotCapabilitiesFromList(list: unknown[]): SnapshotCapability
|
|
|
82
106
|
const height = res ? pickNumber(res, "height", "1") : null;
|
|
83
107
|
const maxFrameRate = pickNumber(cap, "maxFrameRate", "1");
|
|
84
108
|
const imageCodec = pickNumber(cap, "imageCodec", "2");
|
|
109
|
+
const requiresEncodedPixels = cap["requiresEncodedPixels"] ?? cap["3"];
|
|
85
110
|
return {
|
|
86
111
|
resolution: {
|
|
87
112
|
width: width ?? SNAPSHOT_DEFAULTS.resolution.width,
|
|
@@ -89,9 +114,17 @@ function parseSnapshotCapabilitiesFromList(list: unknown[]): SnapshotCapability
|
|
|
89
114
|
},
|
|
90
115
|
maxFrameRate: maxFrameRate ?? SNAPSHOT_DEFAULTS.maxFrameRate,
|
|
91
116
|
imageCodec: imageCodec ?? SNAPSHOT_DEFAULTS.imageCodec,
|
|
117
|
+
requiresEncodedPixels: requiresEncodedPixels !== false,
|
|
92
118
|
};
|
|
93
119
|
});
|
|
94
|
-
|
|
120
|
+
// When preferEncoderFree (a video stream is live), restrict to encoder-free capabilities
|
|
121
|
+
// (RequiresEncodedPixels=false): a camera with MaxConcurrentEncoders=1 holds its sole encoder
|
|
122
|
+
// for the video stream, so only an encoder-free capability can be captured concurrently (often
|
|
123
|
+
// a lower resolution). When idle, use the full set so a capture can use the higher-resolution
|
|
124
|
+
// (encoder) capability. Within the chosen set, take the highest resolution.
|
|
125
|
+
const encoderFree = parsed.filter(cap => !cap.requiresEncodedPixels);
|
|
126
|
+
const pool = preferEncoderFree && encoderFree.length > 0 ? encoderFree : parsed;
|
|
127
|
+
return pool.reduce((best, cur) =>
|
|
95
128
|
cur.resolution.width * cur.resolution.height > best.resolution.width * best.resolution.height ? cur : best,
|
|
96
129
|
);
|
|
97
130
|
}
|
|
@@ -291,10 +324,10 @@ export class WebRtcStreamView extends LitElement {
|
|
|
291
324
|
console.warn("[webrtc-stream-view] ontrack fired but <video> query is null");
|
|
292
325
|
return;
|
|
293
326
|
}
|
|
294
|
-
// Cameras may put each track in its own MediaStream (distinct msid,
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
327
|
+
// Cameras may put each track in its own MediaStream (distinct msid), so
|
|
328
|
+
// ev.streams[0] differs per track. Assigning it to srcObject directly lets the audio
|
|
329
|
+
// track's stream clobber the video track's. Aggregate every received track into one
|
|
330
|
+
// element-owned MediaStream instead.
|
|
298
331
|
const existing = video.srcObject instanceof MediaStream ? video.srcObject : null;
|
|
299
332
|
const stream = existing ?? new MediaStream();
|
|
300
333
|
if (!stream.getTracks().includes(ev.track)) {
|
|
@@ -308,11 +341,21 @@ export class WebRtcStreamView extends LitElement {
|
|
|
308
341
|
const minResolution = this.resolution ?? DEFAULT_MIN_RESOLUTION;
|
|
309
342
|
const maxResolution = this.resolution ?? DEFAULT_MAX_RESOLUTION;
|
|
310
343
|
const avsmFeatures = this._readAvsmFeatures();
|
|
344
|
+
const maxEncodedPixelRate = this._readMaxEncodedPixelRate();
|
|
345
|
+
const snapshotReservation = this._snapshotBudgetReservation();
|
|
346
|
+
// Reserving the whole MaxEncodedPixelRate (e.g. 1080p@120) starves a concurrent
|
|
347
|
+
// SnapshotStreamAllocate (ResourceExhausted); size the video rate to leave the
|
|
348
|
+
// snapshot stream room within the shared encoder budget.
|
|
349
|
+
const videoMaxFrameRate = planVideoMaxFrameRate({
|
|
350
|
+
maxEncodedPixelRate,
|
|
351
|
+
videoResolution: maxResolution,
|
|
352
|
+
snapshotReservation,
|
|
353
|
+
});
|
|
311
354
|
const videoAllocPayload: Record<string, unknown> = {
|
|
312
355
|
streamUsage: STREAM_USAGE_LIVE_VIEW,
|
|
313
356
|
videoCodec: 0,
|
|
314
|
-
minFrameRate:
|
|
315
|
-
maxFrameRate:
|
|
357
|
+
minFrameRate: Math.min(VIDEO_MIN_FRAME_RATE, videoMaxFrameRate),
|
|
358
|
+
maxFrameRate: videoMaxFrameRate,
|
|
316
359
|
minResolution,
|
|
317
360
|
maxResolution,
|
|
318
361
|
minBitRate: 10000,
|
|
@@ -324,16 +367,21 @@ export class WebRtcStreamView extends LitElement {
|
|
|
324
367
|
|
|
325
368
|
// Spec §11.2.1.2.1 — server SHALL reuse an existing stream that covers our
|
|
326
369
|
// request. Search AllocatedVideoStreams (attr 0x000F) first to avoid even
|
|
327
|
-
// sending VideoStreamAllocate when a usable stream is already in place.
|
|
328
|
-
|
|
370
|
+
// sending VideoStreamAllocate when a usable stream is already in place. Skip
|
|
371
|
+
// candidates that over-reserve the encoder budget so reuse can't reintroduce
|
|
372
|
+
// the snapshot ResourceExhausted a fresh allocation avoids.
|
|
373
|
+
const videoWant = {
|
|
329
374
|
streamUsage: STREAM_USAGE_LIVE_VIEW,
|
|
330
375
|
videoCodec: 0,
|
|
331
376
|
minRes: minResolution,
|
|
332
377
|
maxRes: maxResolution,
|
|
333
378
|
watermarkEnabled: avsmFeatures.wmark ? this.watermarkEnabled : undefined,
|
|
334
379
|
osdEnabled: avsmFeatures.osd ? this.osdEnabled : undefined,
|
|
335
|
-
|
|
380
|
+
budget: { maxEncodedPixelRate, snapshotReservation },
|
|
381
|
+
};
|
|
382
|
+
const reusedVideoId = this._findMatchingVideoStream(videoWant);
|
|
336
383
|
let videoAlloc: unknown = null;
|
|
384
|
+
let usedFallbackReuse = false;
|
|
337
385
|
if (reusedVideoId !== null) {
|
|
338
386
|
console.info("[webrtc-stream-view] reusing existing video stream", reusedVideoId);
|
|
339
387
|
this._videoStreamId = reusedVideoId;
|
|
@@ -348,31 +396,48 @@ export class WebRtcStreamView extends LitElement {
|
|
|
348
396
|
videoAllocPayload,
|
|
349
397
|
);
|
|
350
398
|
} catch (err) {
|
|
351
|
-
// Cameras with shared encoder pools (Aqara G350) refuse VideoStreamAllocate
|
|
352
|
-
// while a snapshot stream is held. Detect ResourceExhausted (Matter Status 137),
|
|
353
|
-
// free the snapshot stream, retry once.
|
|
354
399
|
const message = err instanceof Error ? err.message : String(err);
|
|
355
400
|
const isResourceExhausted =
|
|
356
401
|
message.includes("Resource exhausted") || message.includes("(code 137)");
|
|
357
|
-
if (!isResourceExhausted
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
402
|
+
if (!isResourceExhausted) throw err;
|
|
403
|
+
// Cameras with a shared encoder pool refuse VideoStreamAllocate while a snapshot
|
|
404
|
+
// stream is held — free ours and retry once.
|
|
405
|
+
if (this._snapshotStreamId !== null) {
|
|
406
|
+
console.info(
|
|
407
|
+
"[webrtc-stream-view] VideoStreamAllocate ResourceExhausted; freeing snapshot stream and retrying",
|
|
408
|
+
);
|
|
409
|
+
await this.deallocateSnapshot();
|
|
410
|
+
videoAlloc = await this.client.deviceCommand(
|
|
411
|
+
this.nodeId,
|
|
412
|
+
this.endpointId,
|
|
413
|
+
CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID,
|
|
414
|
+
"VideoStreamAllocate",
|
|
415
|
+
videoAllocPayload,
|
|
416
|
+
);
|
|
417
|
+
} else {
|
|
418
|
+
// The budget is held by a stream we didn't allocate (e.g. another
|
|
419
|
+
// controller's 120 fps stream). Fall back to reusing it so live view
|
|
420
|
+
// still works; a concurrent snapshot may then be refused.
|
|
421
|
+
const fallbackId = this._findMatchingVideoStream({ ...videoWant, budget: undefined });
|
|
422
|
+
if (fallbackId === null) throw err;
|
|
423
|
+
console.warn(
|
|
424
|
+
"[webrtc-stream-view] VideoStreamAllocate ResourceExhausted; reusing over-budget stream",
|
|
425
|
+
fallbackId,
|
|
426
|
+
"(concurrent snapshot may be unavailable)",
|
|
427
|
+
);
|
|
428
|
+
this._videoStreamId = fallbackId;
|
|
429
|
+
this._videoStreamOwned = false;
|
|
430
|
+
usedFallbackReuse = true;
|
|
431
|
+
}
|
|
369
432
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
433
|
+
if (!usedFallbackReuse) {
|
|
434
|
+
const videoStreamId = parseStreamAllocate(videoAlloc, "videoStreamId");
|
|
435
|
+
if (videoStreamId === null) {
|
|
436
|
+
throw new Error("VideoStreamAllocate did not return a videoStreamId");
|
|
437
|
+
}
|
|
438
|
+
this._videoStreamId = videoStreamId;
|
|
439
|
+
this._videoStreamOwned = true;
|
|
373
440
|
}
|
|
374
|
-
this._videoStreamId = videoStreamId;
|
|
375
|
-
this._videoStreamOwned = true;
|
|
376
441
|
}
|
|
377
442
|
|
|
378
443
|
// Audio is best-effort: not all cameras expose it. Reuse if a matching
|
|
@@ -564,7 +629,7 @@ export class WebRtcStreamView extends LitElement {
|
|
|
564
629
|
};
|
|
565
630
|
}
|
|
566
631
|
|
|
567
|
-
private _readAvsmFeatures(): { wmark: boolean; osd: boolean } {
|
|
632
|
+
private _readAvsmFeatures(): { snp: boolean; wmark: boolean; osd: boolean } {
|
|
568
633
|
const node = this.client?.nodes[String(this.nodeId)];
|
|
569
634
|
const raw =
|
|
570
635
|
node?.attributes[
|
|
@@ -572,11 +637,41 @@ export class WebRtcStreamView extends LitElement {
|
|
|
572
637
|
];
|
|
573
638
|
const bits = typeof raw === "number" ? raw : 0;
|
|
574
639
|
return {
|
|
640
|
+
snp: (bits & AVSM_FEAT_SNP) !== 0,
|
|
575
641
|
wmark: (bits & AVSM_FEAT_WMARK) !== 0,
|
|
576
642
|
osd: (bits & AVSM_FEAT_OSD) !== 0,
|
|
577
643
|
};
|
|
578
644
|
}
|
|
579
645
|
|
|
646
|
+
private _readMaxEncodedPixelRate(): number | null {
|
|
647
|
+
const node = this.client?.nodes[String(this.nodeId)];
|
|
648
|
+
const raw =
|
|
649
|
+
node?.attributes[
|
|
650
|
+
`${this.endpointId}/${CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID}/${AVSM_MAX_ENCODED_PIXEL_RATE_ATTR_ID}`
|
|
651
|
+
];
|
|
652
|
+
return typeof raw === "number" && raw > 0 ? raw : null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* px/s a concurrent snapshot stream would reserve from MaxEncodedPixelRate, so the
|
|
657
|
+
* video stream can be sized to leave room. Zero when the camera has no snapshot feature
|
|
658
|
+
* or the chosen capability does not draw from the encoder budget.
|
|
659
|
+
*/
|
|
660
|
+
private _snapshotBudgetReservation(): number {
|
|
661
|
+
if (!this._readAvsmFeatures().snp) return 0;
|
|
662
|
+
const node = this.client?.nodes[String(this.nodeId)];
|
|
663
|
+
const capsRaw = node?.attributes[`${this.endpointId}/${CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID}/10`];
|
|
664
|
+
const cap =
|
|
665
|
+
Array.isArray(capsRaw) && capsRaw.length > 0
|
|
666
|
+
? parseSnapshotCapabilitiesFromList(capsRaw, true)
|
|
667
|
+
: SNAPSHOT_DEFAULTS;
|
|
668
|
+
if (!cap.requiresEncodedPixels) return 0;
|
|
669
|
+
// Match the clamp _ensureSnapshotStream applies so the reservation reflects what will
|
|
670
|
+
// actually be requested, not an over-large snapshotResolution the capability can't provide.
|
|
671
|
+
const resolution = clampToCapability(this.snapshotResolution ?? cap.resolution, cap.resolution);
|
|
672
|
+
return pixelRate(resolution, cap.maxFrameRate);
|
|
673
|
+
}
|
|
674
|
+
|
|
580
675
|
async deallocateSnapshot(): Promise<void> {
|
|
581
676
|
if (this._snapshotStreamId === null) return;
|
|
582
677
|
const id = this._snapshotStreamId;
|
|
@@ -611,6 +706,7 @@ export class WebRtcStreamView extends LitElement {
|
|
|
611
706
|
maxRes: { width: number; height: number };
|
|
612
707
|
watermarkEnabled?: boolean;
|
|
613
708
|
osdEnabled?: boolean;
|
|
709
|
+
budget?: { maxEncodedPixelRate: number | null; snapshotReservation: number };
|
|
614
710
|
}): number | null {
|
|
615
711
|
const node = this.client?.nodes[String(this.nodeId)];
|
|
616
712
|
const list = node?.attributes[`${this.endpointId}/${CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID}/15`];
|
|
@@ -630,6 +726,19 @@ export class WebRtcStreamView extends LitElement {
|
|
|
630
726
|
const eMaxH = pickNumber(maxRes, "height", "1") ?? 0;
|
|
631
727
|
if (eMinW > want.minRes.width || eMaxW < want.maxRes.width) continue;
|
|
632
728
|
if (eMinH > want.minRes.height || eMaxH < want.maxRes.height) continue;
|
|
729
|
+
if (want.budget) {
|
|
730
|
+
// Unknown frame rate under a real budget can't be shown to fit → treat as infinite
|
|
731
|
+
// (skip). When the budget is unknown, videoStreamFitsSnapshotBudget short-circuits to
|
|
732
|
+
// true regardless, so an unparseable frame rate doesn't wrongly block reuse.
|
|
733
|
+
const eMaxFrameRate = pickNumber(obj, "maxFrameRate", "4") ?? Number.POSITIVE_INFINITY;
|
|
734
|
+
const fits = videoStreamFitsSnapshotBudget({
|
|
735
|
+
maxEncodedPixelRate: want.budget.maxEncodedPixelRate,
|
|
736
|
+
candidateResolution: { width: eMaxW, height: eMaxH },
|
|
737
|
+
candidateFrameRate: eMaxFrameRate,
|
|
738
|
+
snapshotReservation: want.budget.snapshotReservation,
|
|
739
|
+
});
|
|
740
|
+
if (!fits) continue;
|
|
741
|
+
}
|
|
633
742
|
if (want.watermarkEnabled !== undefined) {
|
|
634
743
|
const v = obj["watermarkEnabled"] ?? obj["10"];
|
|
635
744
|
if (v !== want.watermarkEnabled) continue;
|
|
@@ -717,15 +826,21 @@ export class WebRtcStreamView extends LitElement {
|
|
|
717
826
|
|
|
718
827
|
const node = this.client.nodes[String(this.nodeId)];
|
|
719
828
|
|
|
720
|
-
// SnapshotCapabilities (attr id 10) — preferred source.
|
|
721
|
-
//
|
|
722
|
-
//
|
|
829
|
+
// SnapshotCapabilities (attr id 10) — preferred source. Some cameras advertise the SNP
|
|
830
|
+
// feature but ship an empty capabilities list, so fall back to sensible defaults when it
|
|
831
|
+
// is empty.
|
|
832
|
+
// Once a video stream is allocated the sole encoder (MaxConcurrentEncoders=1) is held —
|
|
833
|
+
// through the whole connecting/streaming phase, not just once "streaming" — so a concurrent
|
|
834
|
+
// snapshot must use an encoder-free capability (typically a lower resolution).
|
|
835
|
+
const encoderBusy = this._videoStreamId !== null;
|
|
723
836
|
const capsRaw = node?.attributes[`${this.endpointId}/${CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID}/10`];
|
|
724
837
|
const cap: SnapshotCapability =
|
|
725
838
|
Array.isArray(capsRaw) && capsRaw.length > 0
|
|
726
|
-
? parseSnapshotCapabilitiesFromList(capsRaw)
|
|
839
|
+
? parseSnapshotCapabilitiesFromList(capsRaw, encoderBusy)
|
|
727
840
|
: SNAPSHOT_DEFAULTS;
|
|
728
|
-
|
|
841
|
+
// Never request more than the chosen capability provides: a larger resolution would map
|
|
842
|
+
// to an encoder-requiring capability the busy encoder can't satisfy (ResourceExhausted).
|
|
843
|
+
const targetResolution = clampToCapability(this.snapshotResolution ?? cap.resolution, cap.resolution);
|
|
729
844
|
|
|
730
845
|
const avsmFeatures = this._readAvsmFeatures();
|
|
731
846
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025-2026 Open Home Foundation
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Encoder-budget planning for concurrent camera video + snapshot streams.
|
|
9
|
+
*
|
|
10
|
+
* A Matter camera reserves encoder capacity per allocated stream as
|
|
11
|
+
* `maxFrameRate × width × height` (px/s) and enforces a ceiling advertised as
|
|
12
|
+
* the CameraAVStreamManagement `MaxEncodedPixelRate` attribute (0x0001). A live
|
|
13
|
+
* video stream that reserves the entire budget makes a concurrent
|
|
14
|
+
* SnapshotStreamAllocate fail with `ResourceExhausted` (status 0x89). These
|
|
15
|
+
* helpers choose a video frame rate that leaves room for a snapshot stream.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface Resolution {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Live view is capped at 60 fps — higher rates buy nothing and starve the snapshot budget. */
|
|
24
|
+
export const VIDEO_MAX_FRAME_RATE = 60;
|
|
25
|
+
/** Never propose a video frame rate below this while a snapshot can still fit. */
|
|
26
|
+
export const VIDEO_MIN_FRAME_RATE = 30;
|
|
27
|
+
|
|
28
|
+
export function pixelRate(res: Resolution, frameRate: number): number {
|
|
29
|
+
return res.width * res.height * frameRate;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface VideoFrameRatePlan {
|
|
33
|
+
/** MaxEncodedPixelRate in px/s, or null when the camera doesn't advertise it. */
|
|
34
|
+
maxEncodedPixelRate: number | null;
|
|
35
|
+
/** MaxResolution the video stream will reserve. */
|
|
36
|
+
videoResolution: Resolution;
|
|
37
|
+
/** px/s a concurrent snapshot stream needs; 0 when no snapshot draws from the budget. */
|
|
38
|
+
snapshotReservation: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pick a video `maxFrameRate` that keeps `snapshotReservation` px/s free within the
|
|
43
|
+
* camera's `MaxEncodedPixelRate`. Falls back to {@link VIDEO_MAX_FRAME_RATE} when the
|
|
44
|
+
* budget is unknown. When video + snapshot cannot both fit, video is given the whole
|
|
45
|
+
* budget (best effort — a concurrent snapshot may then still be refused).
|
|
46
|
+
*/
|
|
47
|
+
export function planVideoMaxFrameRate(plan: VideoFrameRatePlan): number {
|
|
48
|
+
const { maxEncodedPixelRate: budget, videoResolution, snapshotReservation } = plan;
|
|
49
|
+
const videoPixels = videoResolution.width * videoResolution.height;
|
|
50
|
+
if (!budget || budget <= 0 || videoPixels <= 0) return VIDEO_MAX_FRAME_RATE;
|
|
51
|
+
|
|
52
|
+
const withSnapshot = Math.floor((budget - snapshotReservation) / videoPixels);
|
|
53
|
+
if (withSnapshot >= VIDEO_MIN_FRAME_RATE) {
|
|
54
|
+
return Math.min(withSnapshot, VIDEO_MAX_FRAME_RATE);
|
|
55
|
+
}
|
|
56
|
+
const videoOnly = Math.floor(budget / videoPixels);
|
|
57
|
+
return Math.min(Math.max(videoOnly, 1), VIDEO_MAX_FRAME_RATE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether reserving a candidate video stream still leaves room for a concurrent snapshot.
|
|
62
|
+
* Used to reject reuse of an over-budget (e.g. 120 fps) stream. Unknown budget, or no
|
|
63
|
+
* snapshot reservation, accepts any candidate.
|
|
64
|
+
*/
|
|
65
|
+
export function videoStreamFitsSnapshotBudget(args: {
|
|
66
|
+
maxEncodedPixelRate: number | null;
|
|
67
|
+
candidateResolution: Resolution;
|
|
68
|
+
candidateFrameRate: number;
|
|
69
|
+
snapshotReservation: number;
|
|
70
|
+
}): boolean {
|
|
71
|
+
const { maxEncodedPixelRate: budget, candidateResolution, candidateFrameRate, snapshotReservation } = args;
|
|
72
|
+
if (!budget || budget <= 0 || snapshotReservation <= 0) return true;
|
|
73
|
+
return pixelRate(candidateResolution, candidateFrameRate) + snapshotReservation <= budget;
|
|
74
|
+
}
|