@camstack/types 1.2.42 → 1.2.43

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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_event_category = require("./event-category-BE4PDZ_3.js");
3
- const require_sleep = require("./sleep-Cvi1JxZp.js");
3
+ const require_sleep = require("./sleep-Bx9IIoT0.js");
4
4
  const require_canonical_hash = require("./canonical-hash-BcZHRHIx.js");
5
5
  const require_enums = require("./enums.js");
6
6
  const require_err_msg = require("./err-msg-COpsHMw2.js");
@@ -289,6 +289,8 @@ function buildInputArgs(input, decodeHwAccel) {
289
289
  const args = [];
290
290
  if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
291
291
  if (input.extraArgs?.length) args.push(...input.extraArgs);
292
+ if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
293
+ if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
292
294
  if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
293
295
  if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
294
296
  args.push("-i", input.url);
@@ -337,6 +339,7 @@ function buildVideoArgs(video, outputArgs) {
337
339
  if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
338
340
  if (video.fps !== void 0) args.push("-r", String(video.fps));
339
341
  if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
342
+ if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
340
343
  if (video.bf !== void 0) args.push("-bf", String(video.bf));
341
344
  args.push(...buildRateControlArgs(video));
342
345
  if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
@@ -372,6 +375,56 @@ function isElementaryVideoSink(sink) {
372
375
  return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
373
376
  }
374
377
  /**
378
+ * The fragmented-MP4 muxer flags, in the order the recorder has proven them
379
+ * (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
380
+ * `-segment_format_options`, across every vendor in the fleet):
381
+ *
382
+ * - `frag_keyframe` — cut a fragment at each key frame, so every fragment
383
+ * opens on a sync sample. HKSV's whole requirement.
384
+ * - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
385
+ * is what makes the head a standalone INITIALISATION segment.
386
+ * - `default_base_moof` — fragment offsets are self-relative, so a fragment is
387
+ * demuxable without the bytes that preceded it. D31's byte-range read path
388
+ * depends on exactly this property of the recorder's segments.
389
+ */
390
+ var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
391
+ /**
392
+ * The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
393
+ * union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
394
+ * which is what a plain `container` read would have done for `mp4` — a valid
395
+ * argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
396
+ * unusable byte stream.
397
+ */
398
+ function buildStdoutOrRtspSinkArgs(sink) {
399
+ if (sink.kind === "rtsp-listen") return [
400
+ "-f",
401
+ "rtsp",
402
+ "-rtsp_transport",
403
+ "tcp",
404
+ "-rtsp_flags",
405
+ "listen",
406
+ sink.url
407
+ ];
408
+ if (sink.kind === "rtp-outputs") return [];
409
+ return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
410
+ "-f",
411
+ sink.container,
412
+ "pipe:1"
413
+ ];
414
+ }
415
+ /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
416
+ function buildFmp4SinkArgs(sink) {
417
+ return [
418
+ "-movflags",
419
+ FMP4_MOVFLAGS,
420
+ "-min_frag_duration",
421
+ String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
422
+ "-f",
423
+ "mp4",
424
+ "pipe:1"
425
+ ];
426
+ }
427
+ /**
375
428
  * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
376
429
  * audio optional so a source with no audio skips it instead of failing the
377
430
  * whole invocation.
@@ -432,19 +485,7 @@ function buildFfmpegArgs(inv) {
432
485
  ];
433
486
  }
434
487
  const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
435
- const sinkArgs = inv.sink.kind === "stdout" ? [
436
- "-f",
437
- inv.sink.container,
438
- "pipe:1"
439
- ] : [
440
- "-f",
441
- "rtsp",
442
- "-rtsp_transport",
443
- "tcp",
444
- "-rtsp_flags",
445
- "listen",
446
- inv.sink.url
447
- ];
488
+ const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
448
489
  return [
449
490
  ...head,
450
491
  ...buildVideoArgs(inv.video, inv.outputArgs),
@@ -529,7 +570,10 @@ function invocationFromEncodeProfile(input) {
529
570
  height: v.height
530
571
  } : null;
531
572
  const target = v.codec === "h265" ? "h265" : "h264";
532
- const video = shouldCopy ? { kind: "copy" } : {
573
+ const video = shouldCopy ? {
574
+ kind: "copy",
575
+ ...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
576
+ } : {
533
577
  kind: "encode",
534
578
  encoder: pickVideoEncoder(target, input.decodeHwAccel, input.hardwareEncoders === true),
535
579
  scale,
@@ -537,10 +581,14 @@ function invocationFromEncodeProfile(input) {
537
581
  ...v.tune !== void 0 ? { tune: v.tune } : {},
538
582
  ...v.profile !== void 0 ? { profile: v.profile } : {},
539
583
  ...v.level !== void 0 ? { level: v.level } : {},
584
+ ...input.pixelFormat !== void 0 ? { pixelFormat: input.pixelFormat } : {},
540
585
  ...v.fps !== void 0 ? { fps: v.fps } : {},
541
586
  ...v.gopFrames !== void 0 ? { gopFrames: v.gopFrames } : {},
587
+ ...input.forceKeyFramesSeconds !== void 0 ? { forceKeyFramesSeconds: input.forceKeyFramesSeconds } : {},
542
588
  ...v.bf !== void 0 ? { bf: v.bf } : {},
543
- ...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {}
589
+ ...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {},
590
+ ...input.rateControl !== void 0 ? { rateControl: input.rateControl } : {},
591
+ ...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
544
592
  };
545
593
  return {
546
594
  logLevel: input.logLevel ?? "error",
@@ -604,9 +652,27 @@ var WEBRTC_EGRESS_PROFILE = {
604
652
  * audio plane (out-of-band), so this is `passthrough` exactly like the browser
605
653
  * — see the ADR for why the previous in-band Opus was encoded and discarded.
606
654
  */
655
+ /**
656
+ * Alexa's egress asks for OPUS, and that is a change with a history.
657
+ *
658
+ * The previous profile encoded Opus IN-BAND into an MPEG-TS, where it was
659
+ * discarded: Opus is `stream_type 0x06` and the broker's demuxer maps only
660
+ * `0x0f` (aac), `0x03`/`0x04` (mp2) and `0x81` (ac3). Every frame it produced
661
+ * died at the demuxer, so the encode was replaced with `passthrough` — correct,
662
+ * because paying libopus for nothing is worse than silence.
663
+ *
664
+ * But `passthrough` means the egress emits NO audio at all, and Alexa's audio
665
+ * does not arrive by magic: it rides the published stream, which is what the
666
+ * WebRTC session dials. So the Echo had video and silence either way.
667
+ *
668
+ * Opus here now reaches the egress AUDIO SIDECAR — a separate RTP leg the
669
+ * restreamer grafts onto its SDP — which never touches the MPEG-TS demuxer that
670
+ * killed the in-band attempt. Same codec, different plane, and this one the
671
+ * consumer can actually negotiate.
672
+ */
607
673
  var ALEXA_EGRESS_PROFILE = {
608
674
  ...BASE_LIVE_EGRESS_PROFILE,
609
- audio: "passthrough"
675
+ audio: { codec: "opus" }
610
676
  };
611
677
  /** VBV window for a consumer whose budget is enforced per second (HomeKit). */
612
678
  var RATE_CONTROL_TIGHT = {
@@ -727,6 +793,14 @@ async function resolveEgressDecodeHwAccel(deps) {
727
793
  * Here every knob is a named field, and defaults are APPLIED before hashing so
728
794
  * an omitted field and its explicit default land on the same key.
729
795
  */
796
+ /**
797
+ * The transport a CAP request describes. `fragments` is deliberately
798
+ * unreachable from here — the request schema has no way to ask for it, so the
799
+ * cap path can never be handed a fragment child by accident.
800
+ */
801
+ function egressTransportFromRequest(request) {
802
+ return { transport: request.publishLocally === true ? "push" : "dial" };
803
+ }
730
804
  /** Absent optional ⇒ this sentinel, so `undefined` and "not set" agree. */
731
805
  var UNSET = "\0unset";
732
806
  function canonicalVideo(video) {
@@ -758,10 +832,12 @@ function canonicalAudio(audio) {
758
832
  * future operator-facing "why are these two not sharing?" surface — can diff
759
833
  * two requests without reversing a hash.
760
834
  */
761
- function canonicalEgressPlan(request) {
835
+ function canonicalEgressPlan(request, delivery = egressTransportFromRequest(request)) {
762
836
  return {
763
837
  deviceId: request.deviceId,
764
838
  source: request.source.kind === "profile" ? `profile:${request.source.profile}` : `cam-stream:${request.source.camStreamId}`,
839
+ transport: delivery.transport,
840
+ fragmentMs: delivery.fragmentMs ?? -1,
765
841
  video: canonicalVideo(request.encode.video),
766
842
  audio: canonicalAudio(request.encode.audio),
767
843
  rateControl: request.rateControl ?? "relaxed",
@@ -781,8 +857,8 @@ function canonicalEgressPlan(request) {
781
857
  * exactly a mutable shared object, where one consumer's downgrade dragged
782
858
  * every other consumer to 360p.
783
859
  */
784
- function egressTranscodeSharingKey(request) {
785
- return `egress:${require_canonical_hash.canonicalHash(canonicalEgressPlan(request))}`;
860
+ function egressTranscodeSharingKey(request, delivery = egressTransportFromRequest(request)) {
861
+ return `egress:${require_canonical_hash.canonicalHash(canonicalEgressPlan(request, delivery))}`;
786
862
  }
787
863
  //#endregion
788
864
  //#region src/health/wiring-health.ts
@@ -902,7 +978,7 @@ var DEFAULT_RETENTION = {
902
978
  * ## This file adds no state
903
979
  *
904
980
  * Every switch here is a VIEW onto an authority that already existed
905
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
981
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
906
982
  * group is that there is exactly one place each function is turned off, and
907
983
  * the group routes to it:
908
984
  *
@@ -913,6 +989,40 @@ var DEFAULT_RETENTION = {
913
989
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
914
990
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
915
991
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
992
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
993
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
994
+ *
995
+ * ## The two switches whose authority is not on this server
996
+ *
997
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
998
+ * in "the group stores nothing" — it is the purest form of it: the camera
999
+ * holds the fact, every read is a read-through, and there is no server-side
1000
+ * copy that could drift. Their availability therefore cannot come from
1001
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
1002
+ * is filtered out there); it comes from the cap's own camera-probed
1003
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
1004
+ * THIS camera rather than for the device type
1005
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
1006
+ *
1007
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
1008
+ *
1009
+ * Every other switch means *this camera's function is doing its job*, so
1010
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
1011
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
1012
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
1013
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
1014
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
1015
+ * worse than either surface not having one.
1016
+ *
1017
+ * Two consequences follow and both are load-bearing:
1018
+ *
1019
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
1020
+ * exactly this row. With the polarity above, every camera that has NOT drawn
1021
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
1022
+ * normal, healthy state of most cameras rendered as an operator disablement.
1023
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
1024
+ * unconditionally by both clients, so for this row it has to read correctly
1025
+ * whichever way the switch is sitting.
916
1026
  *
917
1027
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
918
1028
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -931,22 +1041,38 @@ var DEFAULT_RETENTION = {
931
1041
  * `CameraStatus.switchedOff`.
932
1042
  */
933
1043
  /**
934
- * The five functions the operator named (2026-08-05). Deliberately NOT one id
935
- * per pipeline step: face recognition and plate/LPR are per-step toggles on
1044
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
1045
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
1046
+ * recognition and plate/LPR are per-step toggles on
936
1047
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
937
- * editor, not in a five-button safety group.
1048
+ * editor, not in a safety group.
938
1049
  */
939
1050
  var CameraSwitchIdSchema = zod.z.enum([
940
1051
  "stream-broker",
941
1052
  "object-detection",
1053
+ "privacy-mask",
1054
+ "device-audio",
942
1055
  "audio-analysis",
943
1056
  "recording",
944
1057
  "notifications"
945
1058
  ]);
946
- /** Stable render order — broadest blast radius first. */
1059
+ /**
1060
+ * Stable render order — broadest blast radius first, and a source before the
1061
+ * thing that consumes it. `device-audio` sits ABOVE `audio-analysis` because
1062
+ * turning the microphone off leaves the analyzer with nothing to analyse; the
1063
+ * reverse is not true.
1064
+ *
1065
+ * `privacy-mask` sits directly ABOVE `device-audio` because they are literal
1066
+ * siblings — one cap, one device plane, video then audio — and NOT above
1067
+ * `object-detection` despite feeding it: a mask blanks REGIONS, so its blast
1068
+ * radius is partial, and the "broadest first" rule does not rank a partial
1069
+ * control above a whole-function one.
1070
+ */
947
1071
  var CAMERA_SWITCH_ORDER = [
948
1072
  "stream-broker",
949
1073
  "object-detection",
1074
+ "privacy-mask",
1075
+ "device-audio",
950
1076
  "audio-analysis",
951
1077
  "recording",
952
1078
  "notifications"
@@ -964,14 +1090,26 @@ var CameraSwitchAuthoritySchema = zod.z.discriminatedUnion("kind", [
964
1090
  capName: zod.z.string()
965
1091
  }),
966
1092
  zod.z.object({ kind: zod.z.literal("recording-config") }),
967
- zod.z.object({ kind: zod.z.literal("notification-mute") })
1093
+ zod.z.object({ kind: zod.z.literal("notification-mute") }),
1094
+ zod.z.object({
1095
+ kind: zod.z.literal("camera-audio"),
1096
+ capName: zod.z.string()
1097
+ }),
1098
+ zod.z.object({
1099
+ kind: zod.z.literal("camera-mask"),
1100
+ capName: zod.z.string()
1101
+ })
968
1102
  ]);
969
1103
  /**
970
1104
  * Why a switch is not offered for this camera. Rendered instead of the
971
1105
  * control, never as a dead control — an absent function and a broken one must
972
1106
  * not look the same.
973
1107
  */
974
- var CameraSwitchUnavailableReasonSchema = zod.z.enum(["no-provider", "source-unreachable"]);
1108
+ var CameraSwitchUnavailableReasonSchema = zod.z.enum([
1109
+ "no-provider",
1110
+ "source-unreachable",
1111
+ "not-configured"
1112
+ ]);
975
1113
  /**
976
1114
  * One switch, resolved for one camera.
977
1115
  *
@@ -1011,6 +1149,13 @@ var CameraSwitchGroupSchema = zod.z.object({
1011
1149
  var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
1012
1150
  var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
1013
1151
  /**
1152
+ * The device-NATIVE cap that owns what the camera does not capture — masked
1153
+ * video regions and, since 2026-08-07, the microphone. Named here because the
1154
+ * `camera-audio` authority, the orchestrator's gather and the fake harness all
1155
+ * have to agree on it.
1156
+ */
1157
+ var PRIVACY_MASK_CAP_NAME = "privacy-mask";
1158
+ /**
1014
1159
  * THE catalog. One entry per switch; the cost lines are the operator-facing
1015
1160
  * contract and are written to be true rather than reassuring.
1016
1161
  */
@@ -1019,7 +1164,8 @@ var CAMERA_SWITCH_CATALOG = {
1019
1164
  id: "stream-broker",
1020
1165
  label: "Camera",
1021
1166
  costWhenOff: "Off: the whole camera stops. No live view, no recording, no detection and no notifications — its streams are released and nothing dials it again until you turn it back on.",
1022
- authority: { kind: "device-disabled" }
1167
+ authority: { kind: "device-disabled" },
1168
+ countsAsSwitchedOff: true
1023
1169
  },
1024
1170
  "object-detection": {
1025
1171
  id: "object-detection",
@@ -1028,7 +1174,28 @@ var CAMERA_SWITCH_CATALOG = {
1028
1174
  authority: {
1029
1175
  kind: "wrapper-binding",
1030
1176
  capName: DETECTION_PIPELINE_CAP_NAME
1031
- }
1177
+ },
1178
+ countsAsSwitchedOff: true
1179
+ },
1180
+ "privacy-mask": {
1181
+ id: "privacy-mask",
1182
+ label: "Privacy mask",
1183
+ costWhenOff: "On: the zones you drew on this camera are blacked out by the camera itself — live view, playback, exports and detection all see the black boxes, and nothing behind them was ever recorded. Off: the camera captures the whole frame, and your zones are kept for when you turn it back on.",
1184
+ authority: {
1185
+ kind: "camera-mask",
1186
+ capName: PRIVACY_MASK_CAP_NAME
1187
+ },
1188
+ countsAsSwitchedOff: false
1189
+ },
1190
+ "device-audio": {
1191
+ id: "device-audio",
1192
+ label: "Camera microphone",
1193
+ costWhenOff: "Off: the camera captures no sound at all. Live view and recordings become silent video, and audio detection and classification have nothing left to analyse — turning them back on will not recover it. The picture, motion and object detection are unaffected, and two-way talk still works. Applies at the camera, so every consumer sees the same silence.",
1194
+ authority: {
1195
+ kind: "camera-audio",
1196
+ capName: PRIVACY_MASK_CAP_NAME
1197
+ },
1198
+ countsAsSwitchedOff: true
1032
1199
  },
1033
1200
  "audio-analysis": {
1034
1201
  id: "audio-analysis",
@@ -1037,19 +1204,22 @@ var CAMERA_SWITCH_CATALOG = {
1037
1204
  authority: {
1038
1205
  kind: "wrapper-binding",
1039
1206
  capName: AUDIO_ANALYSIS_CAP_NAME
1040
- }
1207
+ },
1208
+ countsAsSwitchedOff: true
1041
1209
  },
1042
1210
  recording: {
1043
1211
  id: "recording",
1044
1212
  label: "Recording",
1045
1213
  costWhenOff: "Off: nothing new is written to disk. Footage already recorded stays, but retention keeps deleting it — so this camera’s history shrinks and is not replaced. Your recording schedule is kept and resumes when you turn it back on.",
1046
- authority: { kind: "recording-config" }
1214
+ authority: { kind: "recording-config" },
1215
+ countsAsSwitchedOff: true
1047
1216
  },
1048
1217
  notifications: {
1049
1218
  id: "notifications",
1050
1219
  label: "Notifications",
1051
1220
  costWhenOff: "Off: this camera never notifies anyone, on any rule, with no expiry. Detection, events and recording carry on exactly as before — you simply stop being told about them.",
1052
- authority: { kind: "notification-mute" }
1221
+ authority: { kind: "notification-mute" },
1222
+ countsAsSwitchedOff: true
1053
1223
  }
1054
1224
  };
1055
1225
  /** Resolve one switch's `{ available, enabled }` pair. */
@@ -1096,6 +1266,55 @@ function resolveState(descriptor, input) {
1096
1266
  available: true,
1097
1267
  enabled: !input.notificationsMuted
1098
1268
  };
1269
+ case "camera-audio": {
1270
+ const audio = input.deviceAudio;
1271
+ if (audio === null) return {
1272
+ available: false,
1273
+ enabled: true,
1274
+ unavailableReason: "source-unreachable"
1275
+ };
1276
+ if (!audio.supported) return {
1277
+ available: false,
1278
+ enabled: true,
1279
+ unavailableReason: "no-provider"
1280
+ };
1281
+ if (audio.enabled === null) return {
1282
+ available: false,
1283
+ enabled: true,
1284
+ unavailableReason: "source-unreachable"
1285
+ };
1286
+ return {
1287
+ available: true,
1288
+ enabled: audio.enabled
1289
+ };
1290
+ }
1291
+ case "camera-mask": {
1292
+ const mask = input.privacyMask;
1293
+ if (mask === null) return {
1294
+ available: false,
1295
+ enabled: false,
1296
+ unavailableReason: "source-unreachable"
1297
+ };
1298
+ if (!mask.supported) return {
1299
+ available: false,
1300
+ enabled: false,
1301
+ unavailableReason: "no-provider"
1302
+ };
1303
+ if (mask.configuredRegions === null || mask.enabled === null) return {
1304
+ available: false,
1305
+ enabled: false,
1306
+ unavailableReason: "source-unreachable"
1307
+ };
1308
+ if (mask.configuredRegions === 0) return {
1309
+ available: false,
1310
+ enabled: false,
1311
+ unavailableReason: "not-configured"
1312
+ };
1313
+ return {
1314
+ available: true,
1315
+ enabled: mask.enabled
1316
+ };
1317
+ }
1099
1318
  }
1100
1319
  }
1101
1320
  /**
@@ -1129,9 +1348,15 @@ function deriveCameraSwitches(input) {
1129
1348
  * `switchedOff: ['object-detection']` was turned off; the same camera with an
1130
1349
  * empty list is broken. Unavailable switches never appear — a function nobody
1131
1350
  * provides was not switched off by anyone.
1351
+ *
1352
+ * `privacy-mask` never appears either, whichever way it is sitting, because its
1353
+ * ON means "the mask is active" rather than "the function works"
1354
+ * ({@link CameraSwitchDescriptor.countsAsSwitchedOff}). Without that filter the
1355
+ * ordinary state of every camera nobody has masked would carry a
1356
+ * "switched off" badge, and the badge that matters would be lost in it.
1132
1357
  */
1133
1358
  function switchedOffIds(switches) {
1134
- return switches.filter((s) => s.available && !s.enabled).map((s) => s.id);
1359
+ return switches.filter((s) => CAMERA_SWITCH_CATALOG[s.id].countsAsSwitchedOff && s.available && !s.enabled).map((s) => s.id);
1135
1360
  }
1136
1361
  //#endregion
1137
1362
  //#region src/interfaces/device-capabilities/camera.ts
@@ -4167,6 +4392,26 @@ var EgressTranscodeRequestSchema = zod.z.object({
4167
4392
  "h264_mp4toannexb",
4168
4393
  "hevc_mp4toannexb"
4169
4394
  ]).optional(),
4395
+ /**
4396
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
4397
+ * consumer to dial the returned url. The broker picks the id and returns it
4398
+ * as `camStreamId` — a caller-supplied one would be circular, since the
4399
+ * sharing key is computed FROM this request.
4400
+ *
4401
+ * The url is still returned and still the contract for a transcode pinned to
4402
+ * another node. But dialling it locally costs an RTSP round trip that changes
4403
+ * the transport underneath the consumer: a dialled stream is an RTP source,
4404
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
4405
+ * repacketizer branch. The push branch — the one the derived mechanism has
4406
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
4407
+ * arriving, key frame arriving, black screen, on a chain healthy at every
4408
+ * other point.
4409
+ *
4410
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
4411
+ * gives priority to `hub-in-process` so a local call never leaves the node.
4412
+ * This is that rule for media.
4413
+ */
4414
+ publishLocally: zod.z.boolean().optional(),
4170
4415
  pixelFormat: zod.z.enum(["yuv420p", "nv12"]).optional(),
4171
4416
  /**
4172
4417
  * Operator/consumer override for decode hardware. ABSENT is the normal case
@@ -4211,7 +4456,13 @@ var EgressTranscodeSchema = zod.z.object({
4211
4456
  * Returned rather than assumed: a consumer that asked for hardware and got
4212
4457
  * software needs to be able to see that without reading the broker's logs.
4213
4458
  */
4214
- decodeHwAccel: zod.z.string().nullable()
4459
+ decodeHwAccel: zod.z.string().nullable(),
4460
+ /**
4461
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
4462
+ * `url`, and the session takes the push/deframe transport rather than the
4463
+ * RTP-passthrough one. `null` means the consumer must dial.
4464
+ */
4465
+ camStreamId: zod.z.string().nullable()
4215
4466
  });
4216
4467
  var streamBrokerCapability = {
4217
4468
  name: "stream-broker",
@@ -15863,9 +16114,15 @@ var snapshotCapability = {
15863
16114
  * Bypass the cache freshness check and fetch directly from the
15864
16115
  * native (or stream-broker fallback). Triggered by the UI's
15865
16116
  * "refresh" button so an operator can force a fresh frame
15866
- * even when the cache is well within `snapshotMaxAgeMs`.
15867
- * On battery cams this WILL wake the camera — accept the
15868
- * cost only when the user explicitly asks for it.
16117
+ * even when the cache is well within the device's
16118
+ * `snapshotMaxAgeS` window.
16119
+ *
16120
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
16121
+ * battery camera it is the one thing that walks past the wrapper's
16122
+ * sleep gate and wakes the camera, so a background caller — a poller,
16123
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
16124
+ * gets the cached frame, which on a sleeping battery camera is the
16125
+ * correct answer: stale but honest beats woken.
15869
16126
  */
15870
16127
  force: zod.z.boolean().optional()
15871
16128
  }), SnapshotImageSchema.nullable()),
@@ -22488,12 +22745,30 @@ var pressureSensorCapability = {
22488
22745
  //#endregion
22489
22746
  //#region src/capabilities/privacy-mask.cap.ts
22490
22747
  /**
22491
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
22492
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
22493
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
22494
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
22495
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
22496
- * normalized 0..1 (top-left origin).
22748
+ * PRIVACY what the camera deliberately does not capture. Two planes:
22749
+ *
22750
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
22751
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
22752
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
22753
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
22754
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
22755
+ * normalized 0..1 (top-left origin).
22756
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
22757
+ * camera encoding an audio track at all, so EVERY consumer — live view,
22758
+ * recording, the audio analyzer, an export — sees silent video. There is
22759
+ * no server-side copy of this fact; the camera is the store and every read
22760
+ * is a read-through, which is why a switch over it cannot drift
22761
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
22762
+ *
22763
+ * Both belong here for one reason: they are the two things an operator turns
22764
+ * off when the answer to "what is this camera allowed to record" changes, and
22765
+ * both are applied ON the device, before anything leaves it.
22766
+ *
22767
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
22768
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
22769
+ * by one provider — and it was removed when this landed. A second writer onto
22770
+ * one device register is the shape of every knob this repo has shipped that
22771
+ * disagreed with the one the reader read.
22497
22772
  */
22498
22773
  /** A privacy-mask region's geometry — rectangle or free polygon. */
22499
22774
  var PrivacyMaskShapeSchema = zod.z.discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -22505,21 +22780,45 @@ var PrivacyMaskRegionSchema = zod.z.object({
22505
22780
  enabled: zod.z.boolean(),
22506
22781
  shape: PrivacyMaskShapeSchema
22507
22782
  });
22508
- /** Current on-camera privacy-mask state — master enable + zones. */
22783
+ /** Current on-camera privacy state — mask master enable + zones + microphone. */
22509
22784
  var PrivacyMaskStatusSchema = zod.z.object({
22510
22785
  enabled: zod.z.boolean(),
22511
22786
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
22512
22787
  regions: zod.z.array(PrivacyMaskRegionSchema),
22788
+ /**
22789
+ * Is the camera capturing sound right now? Read from the camera, never from
22790
+ * a server-side mirror.
22791
+ *
22792
+ * `null` means "no answer" — either this camera exposes no controllable
22793
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
22794
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
22795
+ * "the microphone is off" and "we could not ask" look identical to an
22796
+ * operator only until one of them is wrong.
22797
+ *
22798
+ * On a camera whose profiles carry the flag independently (Reolink writes
22799
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
22800
+ * privacy is only satisfied when every one of them is silent.
22801
+ */
22802
+ audioEnabled: zod.z.boolean().nullable(),
22513
22803
  lastFetchedAt: zod.z.number()
22514
22804
  });
22515
- /** Per-camera availability. */
22805
+ /** Per-camera availability. Probed, never assumed from the model name. */
22516
22806
  var PrivacyMaskOptionsSchema = zod.z.object({
22517
22807
  /** Maximum number of supported zones. */
22518
22808
  maxRegions: zod.z.number(),
22519
22809
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
22520
22810
  supportedShapes: zod.z.array(MaskShapeKindSchema),
22521
22811
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
22522
- polygonVertices: MaskPolygonVerticesSchema.optional()
22812
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
22813
+ /**
22814
+ * Does this camera expose a microphone switch we can actually write?
22815
+ *
22816
+ * Camera-probed: `true` only when the firmware answered with an audio flag
22817
+ * we know how to patch. A camera that never answered is `false` — a control
22818
+ * the operator can press that changes nothing is worse than no control, and
22819
+ * the switch group renders "not available" instead.
22820
+ */
22821
+ supportsAudioMute: zod.z.boolean()
22523
22822
  });
22524
22823
  /** Partial change — every field optional. */
22525
22824
  var PrivacyMaskPatchSchema = zod.z.object({
@@ -22546,6 +22845,27 @@ var privacyMaskCapability = {
22546
22845
  }), zod.z.void(), {
22547
22846
  kind: "mutation",
22548
22847
  auth: "admin"
22848
+ }),
22849
+ /**
22850
+ * Turn the camera's microphone on or off, at the camera.
22851
+ *
22852
+ * Deliberately its OWN mutation rather than a field on
22853
+ * {@link PrivacyMaskPatchSchema}: `patch.enabled` already means "the video
22854
+ * mask master switch", and overloading it would make one boolean mean two
22855
+ * unrelated things on the same call. It is also the only method here whose
22856
+ * write leaves the device in a state a later `getStatus` reads back
22857
+ * verbatim, which is what makes it safe as a switch authority.
22858
+ *
22859
+ * A camera whose `getOptions().supportsAudioMute` is false must REJECT
22860
+ * this rather than silently accept it — a write nothing applies is exactly
22861
+ * what the switch group exists to remove.
22862
+ */
22863
+ setAudioEnabled: require_sleep.method(zod.z.object({
22864
+ deviceId: zod.z.number(),
22865
+ enabled: zod.z.boolean()
22866
+ }), zod.z.void(), {
22867
+ kind: "mutation",
22868
+ auth: "admin"
22549
22869
  })
22550
22870
  },
22551
22871
  status: {
@@ -22554,6 +22874,26 @@ var privacyMaskCapability = {
22554
22874
  },
22555
22875
  runtimeState: PrivacyMaskStatusSchema
22556
22876
  };
22877
+ /**
22878
+ * Collapse a camera's PER-PROFILE audio flags into the one answer
22879
+ * {@link PrivacyMaskStatusSchema.shape.audioEnabled} promises.
22880
+ *
22881
+ * Both firmwares this cap talks to store the flag per stream profile, and
22882
+ * both let those profiles disagree. The rule is `some`, not `every`: privacy
22883
+ * is only satisfied when NOTHING is carrying sound, so a camera whose sub
22884
+ * stream is still audible must read as `true` and be switchable off — not as
22885
+ * `false` because the main stream happens to be muted already.
22886
+ *
22887
+ * An empty list is `null` ("this camera reported no audio flag at all"),
22888
+ * never `false`.
22889
+ *
22890
+ * Lives here rather than in each provider so the rule the schema documents
22891
+ * and the rule the providers apply cannot drift apart.
22892
+ */
22893
+ function summarisePrivacyAudio(profiles) {
22894
+ if (profiles.length === 0) return null;
22895
+ return profiles.some((p) => p.audioEnabled);
22896
+ }
22557
22897
  //#endregion
22558
22898
  //#region src/capabilities/ptz.cap.ts
22559
22899
  var PtzPresetSchema = zod.z.object({
@@ -22938,6 +23278,21 @@ var LocateSegmentResultSchema = zod.z.discriminatedUnion("kind", [zod.z.object({
22938
23278
  })]);
22939
23279
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
22940
23280
  var ReadSegmentBytesResultSchema = zod.z.object({ data: zod.z.instanceof(Uint8Array) });
23281
+ /**
23282
+ * One GOP of a finalized segment, cut by byte range through the segment's own
23283
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
23284
+ * the single `moof`+`mdat` covering the requested instant — standalone-
23285
+ * demuxable, never the whole file. When the segment's index cannot be parsed
23286
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
23287
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
23288
+ */
23289
+ var ReadGopBytesResultSchema = zod.z.object({
23290
+ data: zod.z.instanceof(Uint8Array),
23291
+ /** Absolute epoch ms of the returned fragment's first sample. */
23292
+ gopStartMs: zod.z.number(),
23293
+ /** Media ms the returned fragment covers. */
23294
+ gopDurMs: zod.z.number()
23295
+ });
22941
23296
  var recordingCapability = {
22942
23297
  name: "recording",
22943
23298
  scope: "system",
@@ -23005,6 +23360,18 @@ var recordingCapability = {
23005
23360
  kind: "query",
23006
23361
  auth: "admin"
23007
23362
  }),
23363
+ /** Read the single GOP of segment `startMs` covering `epochMs`, by mfra
23364
+ * byte range — the scrub-granular read (D31 letter on the feeder path).
23365
+ * See {@link ReadGopBytesResultSchema} for the degradation contract. */
23366
+ readGopBytes: require_sleep.method(zod.z.object({
23367
+ deviceId: zod.z.number(),
23368
+ profile: zod.z.string(),
23369
+ startMs: zod.z.number(),
23370
+ epochMs: zod.z.number()
23371
+ }), ReadGopBytesResultSchema, {
23372
+ kind: "query",
23373
+ auth: "admin"
23374
+ }),
23008
23375
  setDeviceConfig: require_sleep.method(zod.z.object({
23009
23376
  deviceId: zod.z.number(),
23010
23377
  config: RecordingConfigSchema
@@ -23666,6 +24033,16 @@ var StreamProfileConfigSchema = zod.z.object({
23666
24033
  "baseline"
23667
24034
  ]).optional(),
23668
24035
  gop: zod.z.number().optional(),
24036
+ /**
24037
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
24038
+ *
24039
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
24040
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
24041
+ * writes every profile at once so "audio off" means silent everywhere. A
24042
+ * per-profile writer beside it would let a camera be half-muted and would be
24043
+ * a second knob onto one device register — the failure D62 exists to
24044
+ * prevent. Absent when the firmware does not report the flag.
24045
+ */
23669
24046
  audio: zod.z.boolean().optional()
23670
24047
  });
23671
24048
  var StreamParamsStatusSchema = zod.z.object({
@@ -23706,7 +24083,13 @@ var StreamParamsOptionsSchema = zod.z.object({
23706
24083
  ext: StreamProfileOptionsSchema.optional()
23707
24084
  });
23708
24085
  /** A partial change to one profile — every field optional; a provider
23709
- * ignores fields it doesn't support. */
24086
+ * ignores fields it doesn't support.
24087
+ *
24088
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
24089
+ * reachable from no form and honoured by exactly one provider, while the
24090
+ * camera's microphone is a whole-device fact. It now has one writer,
24091
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
24092
+ * `privacy-mask.cap.ts`. */
23710
24093
  var StreamProfilePatchSchema = zod.z.object({
23711
24094
  width: zod.z.number().optional(),
23712
24095
  height: zod.z.number().optional(),
@@ -23719,8 +24102,7 @@ var StreamProfilePatchSchema = zod.z.object({
23719
24102
  "main",
23720
24103
  "baseline"
23721
24104
  ]).optional(),
23722
- gop: zod.z.number().optional(),
23723
- audio: zod.z.boolean().optional()
24105
+ gop: zod.z.number().optional()
23724
24106
  });
23725
24107
  var streamParamsCapability = {
23726
24108
  name: "stream-params",
@@ -26639,10 +27021,7 @@ var BATTERY_DEVICE_PROFILE = {
26639
27021
  audioMode: "disabled",
26640
27022
  detectionMode: "on-motion"
26641
27023
  },
26642
- settings: {
26643
- "snapshot.minRefreshIntervalSec": 3600,
26644
- "streamBroker.preBufferEnabled": false
26645
- }
27024
+ settings: {}
26646
27025
  };
26647
27026
  /**
26648
27027
  * Profile registry — order matters when multiple profiles match the
@@ -27494,7 +27873,7 @@ var SystemMirror = class {
27494
27873
  }
27495
27874
  async refreshDeviceMetadata(deviceId, kind) {
27496
27875
  try {
27497
- const info = (await this.api.deviceManager.listAll.query({})).find((d) => d.id === deviceId);
27876
+ const info = await this.api.deviceManager.getDevice.query({ deviceId });
27498
27877
  if (!info) return;
27499
27878
  const wasNew = !this.devices.has(deviceId);
27500
27879
  this.devices.set(deviceId, info);
@@ -32386,6 +32765,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
32386
32765
  addonId: null,
32387
32766
  access: "view"
32388
32767
  },
32768
+ "privacyMask.setAudioEnabled": {
32769
+ capName: "privacy-mask",
32770
+ capScope: "device",
32771
+ addonId: null,
32772
+ access: "create"
32773
+ },
32389
32774
  "privacyMask.setMask": {
32390
32775
  capName: "privacy-mask",
32391
32776
  capScope: "device",
@@ -32554,6 +32939,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
32554
32939
  addonId: null,
32555
32940
  access: "create"
32556
32941
  },
32942
+ "recording.readGopBytes": {
32943
+ capName: "recording",
32944
+ capScope: "system",
32945
+ addonId: null,
32946
+ access: "view"
32947
+ },
32557
32948
  "recording.readSegmentBytes": {
32558
32949
  capName: "recording",
32559
32950
  capScope: "system",
@@ -35572,6 +35963,44 @@ function bestLocationMatch(externalName, existing, threshold = .8) {
35572
35963
  return best;
35573
35964
  }
35574
35965
  //#endregion
35966
+ //#region src/utils/addon-id.ts
35967
+ /**
35968
+ * The addon id as the REGISTRY, the wire and the durable stores spell it.
35969
+ *
35970
+ * `AddonContext.id` does not agree with itself across the two context
35971
+ * factories:
35972
+ *
35973
+ * - a FORKED addon gets the bare manifest id
35974
+ * (`kernel/moleculer/addon-context-factory.ts` → `id: addonId`);
35975
+ * - an addon co-located in hub-main gets it PREFIXED
35976
+ * (`server/backend/src/core/addon/addon-registry.service.ts` →
35977
+ * ``id: `addon:${addonId}` ``).
35978
+ *
35979
+ * Everything an addon might compare `ctx.id` AGAINST carries the bare form:
35980
+ * `DeviceBindingEntry.providerAddonId`, the `device-manager` bindings store's
35981
+ * `wrapperAddonId`, `CapabilityRegistry` provider keys, manifest ids.
35982
+ *
35983
+ * So `entry.providerAddonId === this.ctx.id` is silently, permanently false
35984
+ * for a builtin — and only for a builtin, which is why it survives a green
35985
+ * suite whose fake supplies the bare id. That is exactly how camera 615's
35986
+ * virtual doorbell latched `unbound` for twelve hours on 2026-08-07 while its
35987
+ * binding was intact ([D72](../../../../docs/decisions/adr-0072.md)).
35988
+ *
35989
+ * Route every comparison between `ctx.id` and a registry/store addon id
35990
+ * through {@link isSameAddonId}. `scripts/check-addon-id-comparison.ts`
35991
+ * enforces it in the processes where the prefix exists.
35992
+ */
35993
+ /** The prefix the hub-main context factory prepends to the manifest id. */
35994
+ var ADDON_ID_PREFIX = "addon:";
35995
+ /** The manifest id, whichever spelling of `ctx.id` you were handed. */
35996
+ function bareAddonId(id) {
35997
+ return id.startsWith(ADDON_ID_PREFIX) ? id.slice(6) : id;
35998
+ }
35999
+ /** True when both ids name the same addon, prefixed or not. */
36000
+ function isSameAddonId(a, b) {
36001
+ return bareAddonId(a) === bareAddonId(b);
36002
+ }
36003
+ //#endregion
35575
36004
  //#region src/utils/cosine-similarity.ts
35576
36005
  /** Cosine similarity between two embedding vectors */
35577
36006
  function cosineSimilarity(a, b) {
@@ -36298,7 +36727,7 @@ function readDetailCropConvention(config) {
36298
36727
  square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
36299
36728
  };
36300
36729
  }
36301
- function isHydratedField(entry) {
36730
+ function isHydratedField$1(entry) {
36302
36731
  return typeof entry === "object" && entry !== null && "key" in entry;
36303
36732
  }
36304
36733
  /**
@@ -36313,7 +36742,7 @@ function pickDetailCropConvention(view) {
36313
36742
  if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
36314
36743
  const flat = {};
36315
36744
  for (const section of view.sections) for (const entry of section.fields) {
36316
- if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
36745
+ if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
36317
36746
  if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
36318
36747
  }
36319
36748
  return readDetailCropConvention(flat);
@@ -36400,6 +36829,245 @@ function slideInsideFrame(rect, frameWidth, frameHeight) {
36400
36829
  };
36401
36830
  }
36402
36831
  //#endregion
36832
+ //#region src/pipeline/native-lease.ts
36833
+ /**
36834
+ * THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
36835
+ * decode worker's native-resolution frame retention.
36836
+ *
36837
+ * ## Why they live here and not in the addon that reads them
36838
+ *
36839
+ * The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
36840
+ * the READER is a private child process of `addon-pipeline`'s pipeline-runner.
36841
+ * Addons never import each other, so a key owned by either side would have to
36842
+ * be hand-copied by the other — and a hand-copied key is how a setting silently
36843
+ * stops arriving while both sides still look correct. Same reasoning, same
36844
+ * placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
36845
+ *
36846
+ * ## Why cluster-wide and not per-node
36847
+ *
36848
+ * The lease is a per-decode-worker RAM window. Its purpose — the late
36849
+ * cross-process native crop landing on a full-resolution frame rather than the
36850
+ * ≤640 detection fallback — is a property of the PIPELINE, not of a node's
36851
+ * hardware: a per-node TTL would mean the same camera produces different crop
36852
+ * quality depending on which node the balancer placed it on, and nobody could
36853
+ * tell that from the stored media. Node-level RAM pressure is already handled
36854
+ * by the per-session budget ceiling, which is itself one of these knobs.
36855
+ *
36856
+ * ## What each knob costs
36857
+ *
36858
+ * A retained frame is a full NATIVE-resolution copy in system RAM. With the
36859
+ * default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
36860
+ * 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
36861
+ * for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
36862
+ * resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
36863
+ * clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
36864
+ * admission" for what actually gets admitted.
36865
+ */
36866
+ /**
36867
+ * Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
36868
+ * (cluster-wide) settings. Keys are unique across that addon's whole schema, so
36869
+ * the reader can walk every section instead of trusting the section id.
36870
+ */
36871
+ var NATIVE_LEASE_SECTION_ID = "native-lease";
36872
+ var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
36873
+ var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
36874
+ var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
36875
+ var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
36876
+ /**
36877
+ * WHICH delivered frames the decode worker retains a native copy of.
36878
+ *
36879
+ * - `all` — every frame the worker delivered to the runner. The shipped
36880
+ * behaviour, and the only correct one if something can ask for a crop of a
36881
+ * frame the runner never sent to inference.
36882
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
36883
+ * A native-crop request always names a `frameId` that rode an inference
36884
+ * result, so that is the only set a request can name. How much it drops is
36885
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
36886
+ * this cluster, not the ~80% the design sketch assumed, because the governor
36887
+ * was not throttling as hard as the sketch supposed. Read
36888
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
36889
+ * of you rather than quoting a number from here. The newest delivered frame is
36890
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
36891
+ * which covers the one-frame race between a mark and the supersede that
36892
+ * consumes it.
36893
+ */
36894
+ var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
36895
+ /**
36896
+ * Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
36897
+ * the slider) because the value also travels to a forked child process, where a
36898
+ * junk number would silently become a 0-length or unbounded retention window.
36899
+ */
36900
+ var NativeLeaseSettingsSchema = zod.z.object({
36901
+ /**
36902
+ * How long a retained native frame is served before it counts as a miss.
36903
+ *
36904
+ * Must cover the FULL late-crop horizon: detection inference + the
36905
+ * cross-process inference-result hop to hub post-analysis + tracking + the
36906
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
36907
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
36908
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
36909
+ */
36910
+ ttlMs: zod.z.number().int().min(250).max(1e4),
36911
+ /**
36912
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
36913
+ *
36914
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
36915
+ * which one is actually binding before reasoning from that. At the shipped
36916
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
36917
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
36918
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
36919
+ * change that admits fewer frames buys retention WINDOW at constant RAM
36920
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
36921
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
36922
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
36923
+ * to replace).
36924
+ */
36925
+ budgetMb: zod.z.number().int().min(0).max(4096),
36926
+ /**
36927
+ * Demand window: eager per-frame native retention runs only within this many
36928
+ * ms of the last native-crop request (or of the dial starting).
36929
+ *
36930
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
36931
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
36932
+ * per second on a camera with zero crop demand), so leave it non-zero unless
36933
+ * you are reproducing that.
36934
+ */
36935
+ activityMs: zod.z.number().int().min(0).max(12e4),
36936
+ /**
36937
+ * Which delivered frames are retained at all — see
36938
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
36939
+ * changes WHAT is kept rather than for how long, so it is also the only one
36940
+ * that can turn a crop that used to hit into a miss. The worker counts every
36941
+ * crop request naming a frame it did NOT see marked
36942
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
36943
+ * there is the signal that some caller names frames outside the inference set
36944
+ * and that this must go back to `all`.
36945
+ */
36946
+ admission: NativeLeaseAdmissionSchema
36947
+ });
36948
+ /**
36949
+ * The values in force when the operator has set nothing — byte-for-byte the
36950
+ * constants the decode worker shipped with as env-var defaults, so making these
36951
+ * settings changed no behaviour on the day it landed.
36952
+ */
36953
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
36954
+ ttlMs: 1200,
36955
+ budgetMb: 1024,
36956
+ activityMs: 15e3,
36957
+ admission: "inferred"
36958
+ };
36959
+ /** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
36960
+ var NATIVE_LEASE_TTL_FIELD = {
36961
+ min: 250,
36962
+ max: 1e4,
36963
+ step: 50,
36964
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
36965
+ };
36966
+ var NATIVE_LEASE_BUDGET_FIELD = {
36967
+ min: 0,
36968
+ max: 4096,
36969
+ step: 64,
36970
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
36971
+ };
36972
+ var NATIVE_LEASE_ACTIVITY_FIELD = {
36973
+ min: 0,
36974
+ max: 12e4,
36975
+ step: 1e3,
36976
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
36977
+ };
36978
+ /** Select options for the admission knob (orchestrator settings UI). */
36979
+ var NATIVE_LEASE_ADMISSION_FIELD = {
36980
+ options: [{
36981
+ value: "all",
36982
+ label: "Every delivered frame"
36983
+ }, {
36984
+ value: "inferred",
36985
+ label: "Only frames sent to inference"
36986
+ }],
36987
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
36988
+ };
36989
+ /**
36990
+ * Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
36991
+ * value equal to the shipped default.
36992
+ *
36993
+ * That last rule is not tidiness, it is the difference between the documented
36994
+ * precedence being true and being a lie. `addon-settings.getGlobalSettings`
36995
+ * returns a HYDRATED payload, and `hydrateField` fills an unstored field with
36996
+ * the schema's own `default` (verified live on the hub: a cluster that has never
36997
+ * opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
36998
+ * that at face value would report all three knobs as "set" on every cluster on
36999
+ * the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
37000
+ * emergency override that the precedence promises. There is no raw-store read on
37001
+ * this cap to distinguish the two, so the default value itself is treated as
37002
+ * "the operator has expressed no preference" — which is also what leaving a
37003
+ * slider untouched means.
37004
+ *
37005
+ * The cost is one honest edge: an operator who deliberately selects the default
37006
+ * value in order to overrule an env var does not get it. Clear the env var
37007
+ * instead; the worker's spawn line names the source, so this is visible rather
37008
+ * than mysterious.
37009
+ */
37010
+ function readKnob(knob, raw) {
37011
+ const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
37012
+ if (!parsed.success) return null;
37013
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
37014
+ }
37015
+ /** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
37016
+ function readAdmissionKnob(raw) {
37017
+ const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
37018
+ if (!parsed.success) return null;
37019
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
37020
+ }
37021
+ /**
37022
+ * Narrow a FLAT settings record to the knobs the operator set.
37023
+ *
37024
+ * Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
37025
+ * budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
37026
+ * clamped, not defaulted) so the caller can still fall through to the env
37027
+ * override — clamping here would turn a typo into a value nobody chose. See
37028
+ * {@link readKnob} for why the default counts as unset.
37029
+ */
37030
+ function readNativeLeaseOverride(config) {
37031
+ const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
37032
+ const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
37033
+ const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
37034
+ const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
37035
+ return {
37036
+ ...ttlMs === null ? {} : { ttlMs },
37037
+ ...budgetMb === null ? {} : { budgetMb },
37038
+ ...activityMs === null ? {} : { activityMs },
37039
+ ...admission === null ? {} : { admission }
37040
+ };
37041
+ }
37042
+ function isHydratedField(entry) {
37043
+ return typeof entry === "object" && entry !== null && "key" in entry;
37044
+ }
37045
+ var LEASE_KEYS = [
37046
+ NATIVE_LEASE_TTL_KEY,
37047
+ NATIVE_LEASE_BUDGET_KEY,
37048
+ NATIVE_LEASE_ACTIVITY_KEY,
37049
+ NATIVE_LEASE_ADMISSION_KEY
37050
+ ];
37051
+ /**
37052
+ * Extract the operator's lease overrides from an
37053
+ * `addon-settings.getGlobalSettings` payload.
37054
+ *
37055
+ * Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
37056
+ * alone: the keys are unique across the addon's schema, and a section rename
37057
+ * must not silently revert the whole cluster to the defaults. A `null` payload
37058
+ * (addon mid-boot) means "operator set nothing" — the env/default fallback then
37059
+ * applies, which is the correct read of "I could not ask".
37060
+ */
37061
+ function pickNativeLeaseOverride(view) {
37062
+ if (view === null) return {};
37063
+ const flat = {};
37064
+ for (const section of view.sections) for (const entry of section.fields) {
37065
+ if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
37066
+ if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
37067
+ }
37068
+ return readNativeLeaseOverride(flat);
37069
+ }
37070
+ //#endregion
36403
37071
  //#region src/helpers/bind-addon-actions.ts
36404
37072
  /**
36405
37073
  * Bind an addon's custom-action catalog to its tRPC surface, returning a
@@ -36833,6 +37501,7 @@ exports.DEFAULT_DECODER_HWACCEL_CONFIG = DEFAULT_DECODER_HWACCEL_CONFIG;
36833
37501
  exports.DEFAULT_DETAIL_CROP_CONVENTION = DEFAULT_DETAIL_CROP_CONVENTION;
36834
37502
  exports.DEFAULT_EVENT_COLOR = DEFAULT_EVENT_COLOR;
36835
37503
  exports.DEFAULT_FEATURES = DEFAULT_FEATURES;
37504
+ exports.DEFAULT_NATIVE_LEASE_SETTINGS = DEFAULT_NATIVE_LEASE_SETTINGS;
36836
37505
  exports.DEFAULT_RETENTION = DEFAULT_RETENTION;
36837
37506
  exports.DEFAULT_SCRUB_THUMBNAIL_PRESET = DEFAULT_SCRUB_THUMBNAIL_PRESET;
36838
37507
  exports.DETAIL_CROP_PADDING_FIELD = DETAIL_CROP_PADDING_FIELD;
@@ -36891,6 +37560,10 @@ exports.EXPRESSION_BUILTIN_NAMES = EXPRESSION_BUILTIN_NAMES;
36891
37560
  exports.EXPRESSION_COMPILE_CACHE_CAPACITY = EXPRESSION_COMPILE_CACHE_CAPACITY;
36892
37561
  exports.EXPRESSION_IDENTIFIER_RE = EXPRESSION_IDENTIFIER_RE;
36893
37562
  exports.EXPRESSION_INJECTED_NOW = EXPRESSION_INJECTED_NOW;
37563
+ exports.EgressEncodeSchema = EgressEncodeSchema;
37564
+ exports.EgressRateControlSchema = EgressRateControlSchema;
37565
+ exports.EgressTranscodeRequestSchema = EgressTranscodeRequestSchema;
37566
+ exports.EgressTranscodeSchema = EgressTranscodeSchema;
36894
37567
  exports.ElementConfigStore = ElementConfigStore;
36895
37568
  exports.EmbeddingInfoSchema = EmbeddingInfoSchema;
36896
37569
  exports.EmbeddingResultSchema = EmbeddingResultSchema;
@@ -37046,6 +37719,15 @@ exports.MotionZoneRegionSchema = MotionZoneRegionSchema;
37046
37719
  exports.MotionZoneStatusSchema = MotionZoneStatusSchema;
37047
37720
  exports.MqttBrokerStatusSchema = StatusSchema;
37048
37721
  exports.MutationFilterSchema = MutationFilterSchema;
37722
+ exports.NATIVE_LEASE_ACTIVITY_FIELD = NATIVE_LEASE_ACTIVITY_FIELD;
37723
+ exports.NATIVE_LEASE_ACTIVITY_KEY = NATIVE_LEASE_ACTIVITY_KEY;
37724
+ exports.NATIVE_LEASE_ADMISSION_FIELD = NATIVE_LEASE_ADMISSION_FIELD;
37725
+ exports.NATIVE_LEASE_ADMISSION_KEY = NATIVE_LEASE_ADMISSION_KEY;
37726
+ exports.NATIVE_LEASE_BUDGET_FIELD = NATIVE_LEASE_BUDGET_FIELD;
37727
+ exports.NATIVE_LEASE_BUDGET_KEY = NATIVE_LEASE_BUDGET_KEY;
37728
+ exports.NATIVE_LEASE_SECTION_ID = NATIVE_LEASE_SECTION_ID;
37729
+ exports.NATIVE_LEASE_TTL_FIELD = NATIVE_LEASE_TTL_FIELD;
37730
+ exports.NATIVE_LEASE_TTL_KEY = NATIVE_LEASE_TTL_KEY;
37049
37731
  exports.NC_BASE_CONDITION_KEYS = NC_BASE_CONDITION_KEYS;
37050
37732
  exports.NC_CONDITION_CATALOG = NC_CONDITION_CATALOG;
37051
37733
  exports.NC_HISTORY_LIMIT_DEFAULT = NC_HISTORY_LIMIT_DEFAULT;
@@ -37057,6 +37739,8 @@ exports.NativeCropBboxSchema = NativeCropBboxSchema;
37057
37739
  exports.NativeCropRefSchema = NativeCropRefSchema;
37058
37740
  exports.NativeCropResultSchema = NativeCropResultSchema;
37059
37741
  exports.NativeDetectionSchema = NativeDetectionSchema;
37742
+ exports.NativeLeaseAdmissionSchema = NativeLeaseAdmissionSchema;
37743
+ exports.NativeLeaseSettingsSchema = NativeLeaseSettingsSchema;
37060
37744
  exports.NativeObjectClassEnum = NativeObjectClassEnum;
37061
37745
  exports.NativeObjectDetectionRuntimeStateSchema = NativeObjectDetectionRuntimeStateSchema;
37062
37746
  exports.NativeObjectDetectionStatusSchema = NativeObjectDetectionStatusSchema;
@@ -37126,6 +37810,7 @@ exports.PET_FEEDER_MANUAL_FEED_MAX = PET_FEEDER_MANUAL_FEED_MAX;
37126
37810
  exports.PET_FEEDER_MANUAL_FEED_MIN = PET_FEEDER_MANUAL_FEED_MIN;
37127
37811
  exports.PIPELINE_FLOW_CAPABILITY_NAMES = PIPELINE_FLOW_CAPABILITY_NAMES;
37128
37812
  exports.PIPELINE_OWNER_CAPABILITY_NAMES = PIPELINE_OWNER_CAPABILITY_NAMES;
37813
+ exports.PRIVACY_MASK_CAP_NAME = PRIVACY_MASK_CAP_NAME;
37129
37814
  exports.PROVIDER_KIND_CAP_NAMES = PROVIDER_KIND_CAP_NAMES;
37130
37815
  exports.PYTHON_SCRIPT = PYTHON_SCRIPT;
37131
37816
  exports.PackageUpdateSchema = PackageUpdateSchema;
@@ -37179,6 +37864,7 @@ exports.RESERVED_BINDING_NAMES = RESERVED_BINDING_NAMES;
37179
37864
  exports.RUNTIME_DEFAULTS = RUNTIME_DEFAULTS;
37180
37865
  exports.RUNTIME_TO_FORMAT = RUNTIME_TO_FORMAT;
37181
37866
  exports.RawStateResultSchema = require_sleep.RawStateResultSchema;
37867
+ exports.ReadGopBytesResultSchema = ReadGopBytesResultSchema;
37182
37868
  exports.ReadSegmentBytesResultSchema = ReadSegmentBytesResultSchema;
37183
37869
  exports.ReadinessRegistry = require_sleep.ReadinessRegistry;
37184
37870
  exports.ReadinessTimeoutError = require_sleep.ReadinessTimeoutError;
@@ -37413,6 +38099,7 @@ exports.authProviderCapability = authProviderCapability;
37413
38099
  exports.autoAssignProfiles = autoAssignProfiles;
37414
38100
  exports.automationControlCapability = automationControlCapability;
37415
38101
  exports.backupCapability = backupCapability;
38102
+ exports.bareAddonId = bareAddonId;
37416
38103
  exports.batteryCapability = batteryCapability;
37417
38104
  exports.bestLocationMatch = bestLocationMatch;
37418
38105
  exports.binaryCapability = binaryCapability;
@@ -37489,6 +38176,7 @@ exports.deviceStateCapability = deviceStateCapability;
37489
38176
  exports.deviceStatusCapability = deviceStatusCapability;
37490
38177
  exports.doorbellCapability = doorbellCapability;
37491
38178
  exports.egressTranscodeSharingKey = egressTranscodeSharingKey;
38179
+ exports.egressTransportFromRequest = egressTransportFromRequest;
37492
38180
  exports.embeddingEncoderCapability = embeddingEncoderCapability;
37493
38181
  exports.emitDownForOwnedCaps = require_sleep.emitDownForOwnedCaps;
37494
38182
  exports.emitReadiness = require_sleep.emitReadiness;
@@ -37542,6 +38230,7 @@ exports.isDeviceScopedCap = require_sleep.isDeviceScopedCap;
37542
38230
  exports.isEvent = require_sleep.isEvent;
37543
38231
  exports.isNode = isNode;
37544
38232
  exports.isObjectInput = isObjectInput;
38233
+ exports.isSameAddonId = isSameAddonId;
37545
38234
  exports.isSoftwareDecode = isSoftwareDecode;
37546
38235
  exports.isVoidInput = isVoidInput;
37547
38236
  exports.jobKindSchema = jobKindSchema;
@@ -37605,6 +38294,7 @@ exports.parseStreamParamsFormPatch = parseStreamParamsFormPatch;
37605
38294
  exports.petFeederCapability = petFeederCapability;
37606
38295
  exports.pickAccessoryControl = pickAccessoryControl;
37607
38296
  exports.pickDetailCropConvention = pickDetailCropConvention;
38297
+ exports.pickNativeLeaseOverride = pickNativeLeaseOverride;
37608
38298
  exports.pickPreferredRtspEntry = pickPreferredRtspEntry;
37609
38299
  exports.pickVideoEncoder = pickVideoEncoder;
37610
38300
  exports.pickerForCondition = pickerForCondition;
@@ -37625,6 +38315,7 @@ exports.ptzCapability = ptzCapability;
37625
38315
  exports.pythonScriptForBackend = pythonScriptForBackend;
37626
38316
  exports.readDetailCropConvention = readDetailCropConvention;
37627
38317
  exports.readDeviceStateFrom = readDeviceStateFrom;
38318
+ exports.readNativeLeaseOverride = readNativeLeaseOverride;
37628
38319
  exports.readNodePin = require_sleep.readNodePin;
37629
38320
  exports.readinessKey = require_sleep.readinessKey;
37630
38321
  exports.rebootCapability = rebootCapability;
@@ -37675,6 +38366,7 @@ exports.streamParamsCapability = streamParamsCapability;
37675
38366
  exports.streamPixels = streamPixels;
37676
38367
  exports.streamQualityLabel = streamQualityLabel;
37677
38368
  exports.subKindsOf = subKindsOf;
38369
+ exports.summarisePrivacyAudio = summarisePrivacyAudio;
37678
38370
  exports.supportedRuntimes = supportedRuntimes;
37679
38371
  exports.switchCapability = switchCapability;
37680
38372
  exports.switchedOffIds = switchedOffIds;
@@ -37689,6 +38381,7 @@ exports.terminalSessionCapability = terminalSessionCapability;
37689
38381
  exports.textToHtml = textToHtml;
37690
38382
  exports.toDeviceSummary = toDeviceSummary;
37691
38383
  exports.toExpressionValue = toExpressionValue;
38384
+ exports.toNodeId = require_sleep.toNodeId;
37692
38385
  exports.toStreamSourceEntry = toStreamSourceEntry;
37693
38386
  exports.toastCapability = toastCapability;
37694
38387
  exports.tokenize = tokenize;