@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.
Files changed (29) hide show
  1. package/dist/esm/components/webrtc-stream-view.d.ts +19 -0
  2. package/dist/esm/components/webrtc-stream-view.d.ts.map +1 -1
  3. package/dist/esm/components/webrtc-stream-view.js +107 -30
  4. package/dist/esm/components/webrtc-stream-view.js.map +1 -1
  5. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  6. package/dist/esm/pages/components/node-details.js +0 -1
  7. package/dist/esm/pages/components/node-details.js.map +1 -1
  8. package/dist/esm/util/camera-stream-budget.d.ts +51 -0
  9. package/dist/esm/util/camera-stream-budget.d.ts.map +1 -0
  10. package/dist/esm/util/camera-stream-budget.js +34 -0
  11. package/dist/esm/util/camera-stream-budget.js.map +6 -0
  12. package/dist/web/js/{attribute-write-dialog-CCOGtCsc.js → attribute-write-dialog-GOHez9ID.js} +1 -1
  13. package/dist/web/js/{command-invoke-dialog-DineKhxs.js → command-invoke-dialog-cN8yz0uL.js} +1 -1
  14. package/dist/web/js/{commission-node-dialog-BbyLvbCF.js → commission-node-dialog-DlTbsP25.js} +5 -5
  15. package/dist/web/js/{commission-node-existing-C0EQcHOJ.js → commission-node-existing-CObdE63L.js} +1 -1
  16. package/dist/web/js/{commission-node-thread-C_xYn78v.js → commission-node-thread-BUAmDLZC.js} +1 -1
  17. package/dist/web/js/{commission-node-wifi-mRvgT2Ws.js → commission-node-wifi-CNEBEc_C.js} +1 -1
  18. package/dist/web/js/{dialog-box-CcKXxq-S.js → dialog-box-V0om09Bd.js} +1 -1
  19. package/dist/web/js/{fabric-label-dialog-3xl50UuY.js → fabric-label-dialog-BKI2qEOw.js} +1 -1
  20. package/dist/web/js/main.js +1 -1
  21. package/dist/web/js/{matter-dashboard-app-DiMb3vGL.js → matter-dashboard-app-B3BC4HU2.js} +140 -31
  22. package/dist/web/js/{node-acl-add-dialog-SMiX8hhL.js → node-acl-add-dialog-DHEm34fQ.js} +1 -1
  23. package/dist/web/js/{node-binding-dialog-XRgshO0p.js → node-binding-dialog-NdTWDtWj.js} +1 -1
  24. package/dist/web/js/{node-label-dialog-hLkCmvNT.js → node-label-dialog-BElnoFMC.js} +1 -1
  25. package/dist/web/js/{settings-dialog-nBQaH_2q.js → settings-dialog-CmgvIfKt.js} +1 -1
  26. package/package.json +3 -3
  27. package/src/components/webrtc-stream-view.ts +159 -44
  28. package/src/pages/components/node-details.ts +0 -1
  29. 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): WMARK=6 enables watermark overlay,
33
- // OSD=7 enables on-screen display. When advertised, VideoStreamAllocate and
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
- function parseSnapshotCapabilitiesFromList(list: unknown[]): SnapshotCapability {
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 responses are name-based.
76
- // Prefer the highest-resolution entry — Aqara G350 ships [VGA, 1080p] and 1080p is the useful one.
77
- const candidates = list.map(asObject).filter((c): c is Record<string, unknown> => c !== undefined);
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
- return parsed.reduce((best, cur) =>
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, e.g. Aqara's
295
- // AqaraVideoStream/AqaraAudioStream), so ev.streams[0] differs per track. Assigning it
296
- // to srcObject directly lets the audio track's stream clobber the video track's.
297
- // Aggregate every received track into one element-owned MediaStream instead.
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: 30,
315
- maxFrameRate: 120,
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
- const reusedVideoId = this._findMatchingVideoStream({
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 || this._snapshotStreamId === null) throw err;
358
- console.info(
359
- "[webrtc-stream-view] VideoStreamAllocate ResourceExhausted; freeing snapshot stream and retrying",
360
- );
361
- await this.deallocateSnapshot();
362
- videoAlloc = await this.client.deviceCommand(
363
- this.nodeId,
364
- this.endpointId,
365
- CAMERA_AV_STREAM_MANAGEMENT_CLUSTER_ID,
366
- "VideoStreamAllocate",
367
- videoAllocPayload,
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
- const videoStreamId = parseStreamAllocate(videoAlloc, "videoStreamId");
371
- if (videoStreamId === null) {
372
- throw new Error("VideoStreamAllocate did not return a videoStreamId");
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. Aqara G350 advertises SNP via
721
- // FeatureMap bit 2 but ships an empty capabilities list, so fall back to sensible
722
- // defaults (matching the resolution range our VideoStream uses) when the list is empty.
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
- const targetResolution = this.snapshotResolution ?? cap.resolution;
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
 
@@ -199,7 +199,6 @@ export class NodeDetails extends LitElement {
199
199
  title: "Reinterview node",
200
200
  text: "Success!",
201
201
  });
202
- location.hash = "#";
203
202
  } catch (err: any) {
204
203
  showAlertDialog({
205
204
  title: "Failed to reinterview node",
@@ -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
+ }