@camstack/addon-pipeline 1.2.43 → 1.2.44

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 (37) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +157 -81
  4. package/dist/detection-pipeline/index.mjs +157 -81
  5. package/dist/{dist-UHbKZiSa.js → dist-BuFE5rOK.js} +724 -39
  6. package/dist/{dist-B-VVBzrL.mjs → dist-DOu93i_g.mjs} +659 -40
  7. package/dist/{event-loop-stall-monitor-DfKdC7G4.mjs → event-loop-stall-monitor-CShgQE6l.mjs} +40 -2
  8. package/dist/{event-loop-stall-monitor-C7_L6vHO.js → event-loop-stall-monitor-L08yG6bB.js} +40 -2
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +342 -69
  12. package/dist/pipeline-runner/index.mjs +342 -69
  13. package/dist/recorder/index.js +189 -6
  14. package/dist/recorder/index.mjs +189 -6
  15. package/dist/session-decode/decode-worker-child.js +197 -24
  16. package/dist/session-decode/decode-worker-child.mjs +197 -24
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D7iQbuKY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BRc-m6W-.mjs +26 -0
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-54IklEep.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B_hakWAb.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-CKYS9yZO.mjs → hostInit-khRXMnjl.mjs} +2 -2
  22. package/dist/stream-broker/index.js +3952 -1103
  23. package/dist/stream-broker/index.mjs +3953 -1104
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/worker-protocol-BOXlUhWO.mjs +244 -0
  26. package/dist/worker-protocol-VURr0nUh.js +279 -0
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DxZ75vB8.js → MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BBY2Ztz5.js → MotionZonesSettings-NcxxQN8r-jFDOzPCD.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BC-3QmHa.js → PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js} +1 -1
  30. package/embed-dist/assets/{index-on1wyOnd.js → index-4CtQAybX.js} +12 -12
  31. package/embed-dist/assets/index-DC63og2c.css +2 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +1 -1
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CCC9fqHH.mjs +0 -26
  35. package/dist/worker-protocol-D7RzZIla.mjs +0 -77
  36. package/dist/worker-protocol-DextwlTX.js +0 -94
  37. package/embed-dist/assets/index-CGdwTcwE.css +0 -2
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  //#region ../types/dist/event-category-41fKf-q9.mjs
2
3
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
3
4
  EventCategory["SystemBoot"] = "system.boot";
@@ -6589,9 +6590,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6589
6590
  /**
6590
6591
  * Build the tRPC request options that pin a single capability call to `nodeId`.
6591
6592
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6593
+ *
6594
+ * ## The id is normalised here, and it has to be
6595
+ *
6596
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
6597
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
6598
+ * compares a pin against real node ids, so such a pin matches nothing and the
6599
+ * call fails with `no provider registered for cap "…"`. The local-first
6600
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
6601
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
6602
+ * the thing you reach for when you specifically need THIS node — silently
6603
+ * addressed a node that does not exist.
6604
+ *
6605
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
6606
+ * own node to read the host's hardware-decode backend. It never once answered,
6607
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
6608
+ * while D67's whole premise was that the decoder addon is the authority on
6609
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
6610
+ * hardware", which was false.
6611
+ *
6612
+ * Normalising in the ONE constructor fixes every caller at once, which is why
6613
+ * it is here and not at the call sites.
6592
6614
  */
6593
6615
  function nodePin(nodeId) {
6594
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6616
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
6617
+ }
6618
+ /**
6619
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
6620
+ * is idempotent, so passing an already-clean id costs nothing.
6621
+ */
6622
+ function toNodeId(idOrRunnerId) {
6623
+ const head = idOrRunnerId.split("/")[0];
6624
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
6595
6625
  }
6596
6626
  /**
6597
6627
  * Output schema shared by the contribution + live methods.
@@ -7090,6 +7120,39 @@ function sleep(ms) {
7090
7120
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
7091
7121
  }
7092
7122
  //#endregion
7123
+ //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
7124
+ /**
7125
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7126
+ * canonical form sorts object keys alphabetically at every depth so two
7127
+ * structurally-equal inputs with different key insertion orders produce
7128
+ * the same hash. Returns a 64-char lowercase hex digest.
7129
+ *
7130
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7131
+ * accessory-rebuild work when the upstream shape is byte-identical to
7132
+ * the last applied state — preventing user-visible "re-discovery"
7133
+ * notifications on every addon-runner respawn. Each respawn re-fires
7134
+ * `DeviceBindingsChanged` for every cap registration, which without
7135
+ * this guard would propagate redundant pushes.
7136
+ *
7137
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7138
+ * subscription. The proper fix is a single "device ready" lifecycle
7139
+ * barrier so exports react only when the full cap set has landed —
7140
+ * tracked separately for post-HA-integration work.
7141
+ */
7142
+ function canonicalHash(value) {
7143
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7144
+ return createHash("sha256").update(canonical ?? "").digest("hex");
7145
+ }
7146
+ function replaceWithSortedKeys(_key, value) {
7147
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7148
+ const obj = value;
7149
+ const out = {};
7150
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7151
+ return out;
7152
+ }
7153
+ return value;
7154
+ }
7155
+ //#endregion
7093
7156
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7094
7157
  /**
7095
7158
  import { errMsg } from '@camstack/types'
@@ -7224,6 +7287,8 @@ function buildInputArgs(input, decodeHwAccel) {
7224
7287
  const args = [];
7225
7288
  if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7226
7289
  if (input.extraArgs?.length) args.push(...input.extraArgs);
7290
+ if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7291
+ if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7227
7292
  if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7228
7293
  if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7229
7294
  args.push("-i", input.url);
@@ -7272,6 +7337,7 @@ function buildVideoArgs(video, outputArgs) {
7272
7337
  if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
7273
7338
  if (video.fps !== void 0) args.push("-r", String(video.fps));
7274
7339
  if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
7340
+ if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
7275
7341
  if (video.bf !== void 0) args.push("-bf", String(video.bf));
7276
7342
  args.push(...buildRateControlArgs(video));
7277
7343
  if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
@@ -7307,6 +7373,56 @@ function isElementaryVideoSink(sink) {
7307
7373
  return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
7308
7374
  }
7309
7375
  /**
7376
+ * The fragmented-MP4 muxer flags, in the order the recorder has proven them
7377
+ * (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
7378
+ * `-segment_format_options`, across every vendor in the fleet):
7379
+ *
7380
+ * - `frag_keyframe` — cut a fragment at each key frame, so every fragment
7381
+ * opens on a sync sample. HKSV's whole requirement.
7382
+ * - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
7383
+ * is what makes the head a standalone INITIALISATION segment.
7384
+ * - `default_base_moof` — fragment offsets are self-relative, so a fragment is
7385
+ * demuxable without the bytes that preceded it. D31's byte-range read path
7386
+ * depends on exactly this property of the recorder's segments.
7387
+ */
7388
+ var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
7389
+ /**
7390
+ * The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
7391
+ * union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
7392
+ * which is what a plain `container` read would have done for `mp4` — a valid
7393
+ * argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
7394
+ * unusable byte stream.
7395
+ */
7396
+ function buildStdoutOrRtspSinkArgs(sink) {
7397
+ if (sink.kind === "rtsp-listen") return [
7398
+ "-f",
7399
+ "rtsp",
7400
+ "-rtsp_transport",
7401
+ "tcp",
7402
+ "-rtsp_flags",
7403
+ "listen",
7404
+ sink.url
7405
+ ];
7406
+ if (sink.kind === "rtp-outputs") return [];
7407
+ return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
7408
+ "-f",
7409
+ sink.container,
7410
+ "pipe:1"
7411
+ ];
7412
+ }
7413
+ /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
7414
+ function buildFmp4SinkArgs(sink) {
7415
+ return [
7416
+ "-movflags",
7417
+ FMP4_MOVFLAGS,
7418
+ "-min_frag_duration",
7419
+ String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
7420
+ "-f",
7421
+ "mp4",
7422
+ "pipe:1"
7423
+ ];
7424
+ }
7425
+ /**
7310
7426
  * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
7311
7427
  * audio optional so a source with no audio skips it instead of failing the
7312
7428
  * whole invocation.
@@ -7367,19 +7483,7 @@ function buildFfmpegArgs(inv) {
7367
7483
  ];
7368
7484
  }
7369
7485
  const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7370
- const sinkArgs = inv.sink.kind === "stdout" ? [
7371
- "-f",
7372
- inv.sink.container,
7373
- "pipe:1"
7374
- ] : [
7375
- "-f",
7376
- "rtsp",
7377
- "-rtsp_transport",
7378
- "tcp",
7379
- "-rtsp_flags",
7380
- "listen",
7381
- inv.sink.url
7382
- ];
7486
+ const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7383
7487
  return [
7384
7488
  ...head,
7385
7489
  ...buildVideoArgs(inv.video, inv.outputArgs),
@@ -7464,7 +7568,10 @@ function invocationFromEncodeProfile(input) {
7464
7568
  height: v.height
7465
7569
  } : null;
7466
7570
  const target = v.codec === "h265" ? "h265" : "h264";
7467
- const video = shouldCopy ? { kind: "copy" } : {
7571
+ const video = shouldCopy ? {
7572
+ kind: "copy",
7573
+ ...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
7574
+ } : {
7468
7575
  kind: "encode",
7469
7576
  encoder: pickVideoEncoder(target, input.decodeHwAccel, input.hardwareEncoders === true),
7470
7577
  scale,
@@ -7472,10 +7579,14 @@ function invocationFromEncodeProfile(input) {
7472
7579
  ...v.tune !== void 0 ? { tune: v.tune } : {},
7473
7580
  ...v.profile !== void 0 ? { profile: v.profile } : {},
7474
7581
  ...v.level !== void 0 ? { level: v.level } : {},
7582
+ ...input.pixelFormat !== void 0 ? { pixelFormat: input.pixelFormat } : {},
7475
7583
  ...v.fps !== void 0 ? { fps: v.fps } : {},
7476
7584
  ...v.gopFrames !== void 0 ? { gopFrames: v.gopFrames } : {},
7585
+ ...input.forceKeyFramesSeconds !== void 0 ? { forceKeyFramesSeconds: input.forceKeyFramesSeconds } : {},
7477
7586
  ...v.bf !== void 0 ? { bf: v.bf } : {},
7478
- ...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {}
7587
+ ...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {},
7588
+ ...input.rateControl !== void 0 ? { rateControl: input.rateControl } : {},
7589
+ ...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
7479
7590
  };
7480
7591
  return {
7481
7592
  logLevel: input.logLevel ?? "error",
@@ -7517,6 +7628,159 @@ var BASE_LIVE_EGRESS_PROFILE = {
7517
7628
  };
7518
7629
  ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7519
7630
  ({ ...BASE_LIVE_EGRESS_PROFILE });
7631
+ /** VBV window for a consumer whose budget is enforced per second (HomeKit). */
7632
+ var RATE_CONTROL_TIGHT = {
7633
+ kind: "cbr",
7634
+ vbvSeconds: 1
7635
+ };
7636
+ /** VBV window for a consumer that tolerates a keyframe spike (browser, Echo). */
7637
+ var RATE_CONTROL_RELAXED = {
7638
+ kind: "cap",
7639
+ vbvSeconds: 2
7640
+ };
7641
+ function createHwAccelCache(options) {
7642
+ const now = options.now ?? (() => Date.now());
7643
+ let value = null;
7644
+ let writtenAt = Number.NEGATIVE_INFINITY;
7645
+ return {
7646
+ read() {
7647
+ return now() - writtenAt < options.ttlMs ? value : void 0;
7648
+ },
7649
+ write(next) {
7650
+ value = next;
7651
+ writtenAt = now();
7652
+ }
7653
+ };
7654
+ }
7655
+ /** `true` when a value means "decode in software". */
7656
+ function meansSoftware(value) {
7657
+ return !value || value === "none" || value === "copy";
7658
+ }
7659
+ /**
7660
+ * Resolve the `-hwaccel` value for an egress transcode. Hardware is the
7661
+ * DEFAULT — an egress that decodes in software on a hub with working vaapi is
7662
+ * paying for nothing — and every software outcome is announced.
7663
+ *
7664
+ * Returns a concrete backend name, the literal `'auto'`, or `null` for
7665
+ * software decode (⇒ {@link buildFfmpegArgs} emits no `-hwaccel` at all).
7666
+ */
7667
+ async function resolveEgressDecodeHwAccel(deps) {
7668
+ const override = deps.override;
7669
+ if (override !== void 0 && override !== null && override !== "") return meansSoftware(override) ? null : override;
7670
+ const cached = deps.cache?.read();
7671
+ if (cached !== void 0) return cached;
7672
+ let backend;
7673
+ try {
7674
+ backend = await deps.readDecoderBackend();
7675
+ } catch (err) {
7676
+ const kernelPreferred = await deps.readKernelPreferred().catch(() => []);
7677
+ deps.onFallback({
7678
+ reason: "decoder-unreadable",
7679
+ kernelPreferred,
7680
+ error: err instanceof Error ? err.message : String(err)
7681
+ });
7682
+ deps.cache?.write(null);
7683
+ return null;
7684
+ }
7685
+ if (backend === "") {
7686
+ const kernelPreferred = await deps.readKernelPreferred().catch(() => []);
7687
+ deps.onFallback({
7688
+ reason: "decoder-unprobed",
7689
+ kernelPreferred
7690
+ });
7691
+ deps.cache?.write(null);
7692
+ return null;
7693
+ }
7694
+ const resolved = meansSoftware(backend) ? null : backend;
7695
+ deps.cache?.write(resolved);
7696
+ return resolved;
7697
+ }
7698
+ /**
7699
+ * The sharing key for `streamBroker.acquireEgressTranscode`.
7700
+ *
7701
+ * **Two requesters asking for exactly the same argument set reach the same
7702
+ * process.** Exact match, never fuzzy: a quantised ladder that merged
7703
+ * NEARLY-identical requests would make the stream a consumer receives depend
7704
+ * on who else is watching and in what order they arrived — unpredictable in
7705
+ * precisely the way a debugging session cannot tolerate. Same arguments ⇒ same
7706
+ * process. Different arguments ⇒ different process, and that is fine; the
7707
+ * operator accepted the cost ("it's fine to have several processes").
7708
+ *
7709
+ * Derived from the STRUCTURED plan, not from a flag array. `pipelineKeyFor`
7710
+ * (`transcode-pipeline.ts`) folds `getStreamWithCodec`'s raw `outputArgs` into
7711
+ * its key, so that method's extensibility hatch and its sharing key are the
7712
+ * same field — a consumer needing one extra flag silently forks the child, and
7713
+ * two consumers that want the same thing but spell it differently never share.
7714
+ * Here every knob is a named field, and defaults are APPLIED before hashing so
7715
+ * an omitted field and its explicit default land on the same key.
7716
+ */
7717
+ /**
7718
+ * The transport a CAP request describes. `fragments` is deliberately
7719
+ * unreachable from here — the request schema has no way to ask for it, so the
7720
+ * cap path can never be handed a fragment child by accident.
7721
+ */
7722
+ function egressTransportFromRequest(request) {
7723
+ return { transport: request.publishLocally === true ? "push" : "dial" };
7724
+ }
7725
+ /** Absent optional ⇒ this sentinel, so `undefined` and "not set" agree. */
7726
+ var UNSET = "\0unset";
7727
+ function canonicalVideo(video) {
7728
+ return {
7729
+ codec: video.codec,
7730
+ profile: video.profile ?? UNSET,
7731
+ level: video.level ?? UNSET,
7732
+ width: video.width ?? -1,
7733
+ height: video.height ?? -1,
7734
+ fps: video.fps ?? -1,
7735
+ bitrateKbps: video.bitrateKbps ?? -1,
7736
+ gopFrames: video.gopFrames ?? -1,
7737
+ bf: video.bf ?? -1,
7738
+ preset: video.preset ?? UNSET,
7739
+ tune: video.tune ?? UNSET
7740
+ };
7741
+ }
7742
+ function canonicalAudio(audio) {
7743
+ if (audio === "passthrough") return { codec: "passthrough" };
7744
+ return {
7745
+ codec: audio.codec,
7746
+ bitrateKbps: audio.bitrateKbps ?? -1,
7747
+ sampleRateHz: audio.sampleRateHz ?? -1,
7748
+ channels: audio.channels ?? -1
7749
+ };
7750
+ }
7751
+ /**
7752
+ * The normalised plan a key is computed from. Exported so a test — and a
7753
+ * future operator-facing "why are these two not sharing?" surface — can diff
7754
+ * two requests without reversing a hash.
7755
+ */
7756
+ function canonicalEgressPlan(request, delivery = egressTransportFromRequest(request)) {
7757
+ return {
7758
+ deviceId: request.deviceId,
7759
+ source: request.source.kind === "profile" ? `profile:${request.source.profile}` : `cam-stream:${request.source.camStreamId}`,
7760
+ transport: delivery.transport,
7761
+ fragmentMs: delivery.fragmentMs ?? -1,
7762
+ video: canonicalVideo(request.encode.video),
7763
+ audio: canonicalAudio(request.encode.audio),
7764
+ rateControl: request.rateControl ?? "relaxed",
7765
+ bitstreamFilter: request.bitstreamFilter ?? UNSET,
7766
+ pixelFormat: request.pixelFormat ?? UNSET,
7767
+ decodeHwAccel: request.decodeHwAccel ?? UNSET
7768
+ };
7769
+ }
7770
+ /**
7771
+ * The refcount / dedup key. `canonicalHash` sorts object keys at every depth,
7772
+ * so a request built with a different field order produces the same digest.
7773
+ *
7774
+ * The handle this keys is IMMUTABLE: there is no `reconfigure`. A consumer
7775
+ * whose requirements change RELEASES and re-acquires; the refcount does the
7776
+ * rest. That is what stops co-tenants disturbing each other — the co-tenant
7777
+ * hazard that made Alexa's shared `derived:alexa-<id>` stream a hazard was
7778
+ * exactly a mutable shared object, where one consumer's downgrade dragged
7779
+ * every other consumer to 360p.
7780
+ */
7781
+ function egressTranscodeSharingKey(request, delivery = egressTransportFromRequest(request)) {
7782
+ return `egress:${canonicalHash(canonicalEgressPlan(request, delivery))}`;
7783
+ }
7520
7784
  /**
7521
7785
  * Deep wiring healthcheck — snapshot of active reachability probes across
7522
7786
  * every declared capability + widget of every installed plugin, on every
@@ -7573,7 +7837,7 @@ object({
7573
7837
  * ## This file adds no state
7574
7838
  *
7575
7839
  * Every switch here is a VIEW onto an authority that already existed
7576
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7840
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7577
7841
  * group is that there is exactly one place each function is turned off, and
7578
7842
  * the group routes to it:
7579
7843
  *
@@ -7584,6 +7848,40 @@ object({
7584
7848
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7585
7849
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7586
7850
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7851
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7852
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7853
+ *
7854
+ * ## The two switches whose authority is not on this server
7855
+ *
7856
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7857
+ * in "the group stores nothing" — it is the purest form of it: the camera
7858
+ * holds the fact, every read is a read-through, and there is no server-side
7859
+ * copy that could drift. Their availability therefore cannot come from
7860
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7861
+ * is filtered out there); it comes from the cap's own camera-probed
7862
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7863
+ * THIS camera rather than for the device type
7864
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7865
+ *
7866
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7867
+ *
7868
+ * Every other switch means *this camera's function is doing its job*, so
7869
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7870
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7871
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7872
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7873
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7874
+ * worse than either surface not having one.
7875
+ *
7876
+ * Two consequences follow and both are load-bearing:
7877
+ *
7878
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7879
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7880
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7881
+ * normal, healthy state of most cameras rendered as an operator disablement.
7882
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7883
+ * unconditionally by both clients, so for this row it has to read correctly
7884
+ * whichever way the switch is sitting.
7587
7885
  *
7588
7886
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7589
7887
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -7602,14 +7900,17 @@ object({
7602
7900
  * `CameraStatus.switchedOff`.
7603
7901
  */
7604
7902
  /**
7605
- * The five functions the operator named (2026-08-05). Deliberately NOT one id
7606
- * per pipeline step: face recognition and plate/LPR are per-step toggles on
7903
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7904
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7905
+ * recognition and plate/LPR are per-step toggles on
7607
7906
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7608
- * editor, not in a five-button safety group.
7907
+ * editor, not in a safety group.
7609
7908
  */
7610
7909
  var CameraSwitchIdSchema = _enum([
7611
7910
  "stream-broker",
7612
7911
  "object-detection",
7912
+ "privacy-mask",
7913
+ "device-audio",
7613
7914
  "audio-analysis",
7614
7915
  "recording",
7615
7916
  "notifications"
@@ -7627,14 +7928,26 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7627
7928
  capName: string()
7628
7929
  }),
7629
7930
  object({ kind: literal("recording-config") }),
7630
- object({ kind: literal("notification-mute") })
7931
+ object({ kind: literal("notification-mute") }),
7932
+ object({
7933
+ kind: literal("camera-audio"),
7934
+ capName: string()
7935
+ }),
7936
+ object({
7937
+ kind: literal("camera-mask"),
7938
+ capName: string()
7939
+ })
7631
7940
  ]);
7632
7941
  /**
7633
7942
  * Why a switch is not offered for this camera. Rendered instead of the
7634
7943
  * control, never as a dead control — an absent function and a broken one must
7635
7944
  * not look the same.
7636
7945
  */
7637
- var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7946
+ var CameraSwitchUnavailableReasonSchema = _enum([
7947
+ "no-provider",
7948
+ "source-unreachable",
7949
+ "not-configured"
7950
+ ]);
7638
7951
  /**
7639
7952
  * One switch, resolved for one camera.
7640
7953
  *
@@ -9728,6 +10041,26 @@ var EgressTranscodeRequestSchema = object({
9728
10041
  "h264_mp4toannexb",
9729
10042
  "hevc_mp4toannexb"
9730
10043
  ]).optional(),
10044
+ /**
10045
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
10046
+ * consumer to dial the returned url. The broker picks the id and returns it
10047
+ * as `camStreamId` — a caller-supplied one would be circular, since the
10048
+ * sharing key is computed FROM this request.
10049
+ *
10050
+ * The url is still returned and still the contract for a transcode pinned to
10051
+ * another node. But dialling it locally costs an RTSP round trip that changes
10052
+ * the transport underneath the consumer: a dialled stream is an RTP source,
10053
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
10054
+ * repacketizer branch. The push branch — the one the derived mechanism has
10055
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
10056
+ * arriving, key frame arriving, black screen, on a chain healthy at every
10057
+ * other point.
10058
+ *
10059
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
10060
+ * gives priority to `hub-in-process` so a local call never leaves the node.
10061
+ * This is that rule for media.
10062
+ */
10063
+ publishLocally: boolean().optional(),
9731
10064
  pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9732
10065
  /**
9733
10066
  * Operator/consumer override for decode hardware. ABSENT is the normal case
@@ -9772,7 +10105,13 @@ var EgressTranscodeSchema = object({
9772
10105
  * Returned rather than assumed: a consumer that asked for hardware and got
9773
10106
  * software needs to be able to see that without reading the broker's logs.
9774
10107
  */
9775
- decodeHwAccel: string().nullable()
10108
+ decodeHwAccel: string().nullable(),
10109
+ /**
10110
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
10111
+ * `url`, and the session takes the push/deframe transport rather than the
10112
+ * RTP-passthrough one. `null` means the consumer must dial.
10113
+ */
10114
+ camStreamId: string().nullable()
9776
10115
  });
9777
10116
  var streamBrokerCapability = {
9778
10117
  name: "stream-broker",
@@ -18467,9 +18806,15 @@ DeviceType.Camera, method(object({
18467
18806
  * Bypass the cache freshness check and fetch directly from the
18468
18807
  * native (or stream-broker fallback). Triggered by the UI's
18469
18808
  * "refresh" button so an operator can force a fresh frame
18470
- * even when the cache is well within `snapshotMaxAgeMs`.
18471
- * On battery cams this WILL wake the camera — accept the
18472
- * cost only when the user explicitly asks for it.
18809
+ * even when the cache is well within the device's
18810
+ * `snapshotMaxAgeS` window.
18811
+ *
18812
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
18813
+ * battery camera it is the one thing that walks past the wrapper's
18814
+ * sleep gate and wakes the camera, so a background caller — a poller,
18815
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
18816
+ * gets the cached frame, which on a sleeping battery camera is the
18817
+ * correct answer: stale but honest beats woken.
18473
18818
  */
18474
18819
  force: boolean().optional()
18475
18820
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -22846,12 +23191,30 @@ object({
22846
23191
  });
22847
23192
  DeviceType.Sensor;
22848
23193
  /**
22849
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
22850
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
22851
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
22852
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
22853
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
22854
- * normalized 0..1 (top-left origin).
23194
+ * PRIVACY what the camera deliberately does not capture. Two planes:
23195
+ *
23196
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
23197
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
23198
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
23199
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
23200
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
23201
+ * normalized 0..1 (top-left origin).
23202
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
23203
+ * camera encoding an audio track at all, so EVERY consumer — live view,
23204
+ * recording, the audio analyzer, an export — sees silent video. There is
23205
+ * no server-side copy of this fact; the camera is the store and every read
23206
+ * is a read-through, which is why a switch over it cannot drift
23207
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
23208
+ *
23209
+ * Both belong here for one reason: they are the two things an operator turns
23210
+ * off when the answer to "what is this camera allowed to record" changes, and
23211
+ * both are applied ON the device, before anything leaves it.
23212
+ *
23213
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
23214
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
23215
+ * by one provider — and it was removed when this landed. A second writer onto
23216
+ * one device register is the shape of every knob this repo has shipped that
23217
+ * disagreed with the one the reader read.
22855
23218
  */
22856
23219
  /** A privacy-mask region's geometry — rectangle or free polygon. */
22857
23220
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -22867,16 +23230,40 @@ object({
22867
23230
  enabled: boolean(),
22868
23231
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
22869
23232
  regions: array(PrivacyMaskRegionSchema),
23233
+ /**
23234
+ * Is the camera capturing sound right now? Read from the camera, never from
23235
+ * a server-side mirror.
23236
+ *
23237
+ * `null` means "no answer" — either this camera exposes no controllable
23238
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
23239
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
23240
+ * "the microphone is off" and "we could not ask" look identical to an
23241
+ * operator only until one of them is wrong.
23242
+ *
23243
+ * On a camera whose profiles carry the flag independently (Reolink writes
23244
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
23245
+ * privacy is only satisfied when every one of them is silent.
23246
+ */
23247
+ audioEnabled: boolean().nullable(),
22870
23248
  lastFetchedAt: number()
22871
23249
  });
22872
- /** Per-camera availability. */
23250
+ /** Per-camera availability. Probed, never assumed from the model name. */
22873
23251
  var PrivacyMaskOptionsSchema = object({
22874
23252
  /** Maximum number of supported zones. */
22875
23253
  maxRegions: number(),
22876
23254
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
22877
23255
  supportedShapes: array(MaskShapeKindSchema),
22878
23256
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
22879
- polygonVertices: MaskPolygonVerticesSchema.optional()
23257
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
23258
+ /**
23259
+ * Does this camera expose a microphone switch we can actually write?
23260
+ *
23261
+ * Camera-probed: `true` only when the firmware answered with an audio flag
23262
+ * we know how to patch. A camera that never answered is `false` — a control
23263
+ * the operator can press that changes nothing is worse than no control, and
23264
+ * the switch group renders "not available" instead.
23265
+ */
23266
+ supportsAudioMute: boolean()
22880
23267
  });
22881
23268
  /** Partial change — every field optional. */
22882
23269
  var PrivacyMaskPatchSchema = object({
@@ -22889,6 +23276,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
22889
23276
  }), _void(), {
22890
23277
  kind: "mutation",
22891
23278
  auth: "admin"
23279
+ }), method(object({
23280
+ deviceId: number(),
23281
+ enabled: boolean()
23282
+ }), _void(), {
23283
+ kind: "mutation",
23284
+ auth: "admin"
22892
23285
  });
22893
23286
  var PtzPresetSchema = object({
22894
23287
  id: string(),
@@ -23098,6 +23491,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
23098
23491
  })]);
23099
23492
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
23100
23493
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
23494
+ /**
23495
+ * One GOP of a finalized segment, cut by byte range through the segment's own
23496
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
23497
+ * the single `moof`+`mdat` covering the requested instant — standalone-
23498
+ * demuxable, never the whole file. When the segment's index cannot be parsed
23499
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
23500
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
23501
+ */
23502
+ var ReadGopBytesResultSchema = object({
23503
+ data: _instanceof(Uint8Array),
23504
+ /** Absolute epoch ms of the returned fragment's first sample. */
23505
+ gopStartMs: number(),
23506
+ /** Media ms the returned fragment covers. */
23507
+ gopDurMs: number()
23508
+ });
23101
23509
  var recordingCapability = {
23102
23510
  name: "recording",
23103
23511
  scope: "system",
@@ -23165,6 +23573,18 @@ var recordingCapability = {
23165
23573
  kind: "query",
23166
23574
  auth: "admin"
23167
23575
  }),
23576
+ /** Read the single GOP of segment `startMs` covering `epochMs`, by mfra
23577
+ * byte range — the scrub-granular read (D31 letter on the feeder path).
23578
+ * See {@link ReadGopBytesResultSchema} for the degradation contract. */
23579
+ readGopBytes: method(object({
23580
+ deviceId: number(),
23581
+ profile: string(),
23582
+ startMs: number(),
23583
+ epochMs: number()
23584
+ }), ReadGopBytesResultSchema, {
23585
+ kind: "query",
23586
+ auth: "admin"
23587
+ }),
23168
23588
  setDeviceConfig: method(object({
23169
23589
  deviceId: number(),
23170
23590
  config: RecordingConfigSchema
@@ -23716,6 +24136,16 @@ var StreamProfileConfigSchema = object({
23716
24136
  "baseline"
23717
24137
  ]).optional(),
23718
24138
  gop: number().optional(),
24139
+ /**
24140
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
24141
+ *
24142
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
24143
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
24144
+ * writes every profile at once so "audio off" means silent everywhere. A
24145
+ * per-profile writer beside it would let a camera be half-muted and would be
24146
+ * a second knob onto one device register — the failure D62 exists to
24147
+ * prevent. Absent when the firmware does not report the flag.
24148
+ */
23719
24149
  audio: boolean().optional()
23720
24150
  });
23721
24151
  object({
@@ -23756,7 +24186,13 @@ var StreamParamsOptionsSchema = object({
23756
24186
  ext: StreamProfileOptionsSchema.optional()
23757
24187
  });
23758
24188
  /** A partial change to one profile — every field optional; a provider
23759
- * ignores fields it doesn't support. */
24189
+ * ignores fields it doesn't support.
24190
+ *
24191
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
24192
+ * reachable from no form and honoured by exactly one provider, while the
24193
+ * camera's microphone is a whole-device fact. It now has one writer,
24194
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
24195
+ * `privacy-mask.cap.ts`. */
23760
24196
  var StreamProfilePatchSchema = object({
23761
24197
  width: number().optional(),
23762
24198
  height: number().optional(),
@@ -23769,8 +24205,7 @@ var StreamProfilePatchSchema = object({
23769
24205
  "main",
23770
24206
  "baseline"
23771
24207
  ]).optional(),
23772
- gop: number().optional(),
23773
- audio: boolean().optional()
24208
+ gop: number().optional()
23774
24209
  });
23775
24210
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
23776
24211
  deviceId: number(),
@@ -28283,6 +28718,12 @@ Object.freeze({
28283
28718
  addonId: null,
28284
28719
  access: "view"
28285
28720
  },
28721
+ "privacyMask.setAudioEnabled": {
28722
+ capName: "privacy-mask",
28723
+ capScope: "device",
28724
+ addonId: null,
28725
+ access: "create"
28726
+ },
28286
28727
  "privacyMask.setMask": {
28287
28728
  capName: "privacy-mask",
28288
28729
  capScope: "device",
@@ -28451,6 +28892,12 @@ Object.freeze({
28451
28892
  addonId: null,
28452
28893
  access: "create"
28453
28894
  },
28895
+ "recording.readGopBytes": {
28896
+ capName: "recording",
28897
+ capScope: "system",
28898
+ addonId: null,
28899
+ access: "view"
28900
+ },
28454
28901
  "recording.readSegmentBytes": {
28455
28902
  capName: "recording",
28456
28903
  capScope: "system",
@@ -30085,7 +30532,7 @@ function readDetailCropConvention(config) {
30085
30532
  square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
30086
30533
  };
30087
30534
  }
30088
- function isHydratedField(entry) {
30535
+ function isHydratedField$1(entry) {
30089
30536
  return typeof entry === "object" && entry !== null && "key" in entry;
30090
30537
  }
30091
30538
  /**
@@ -30100,7 +30547,7 @@ function pickDetailCropConvention(view) {
30100
30547
  if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
30101
30548
  const flat = {};
30102
30549
  for (const section of view.sections) for (const entry of section.fields) {
30103
- if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
30550
+ if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
30104
30551
  if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
30105
30552
  }
30106
30553
  return readDetailCropConvention(flat);
@@ -30180,6 +30627,178 @@ function slideInsideFrame(rect, frameWidth, frameHeight) {
30180
30627
  h
30181
30628
  };
30182
30629
  }
30630
+ var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
30631
+ var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
30632
+ var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
30633
+ var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
30634
+ /**
30635
+ * WHICH delivered frames the decode worker retains a native copy of.
30636
+ *
30637
+ * - `all` — every frame the worker delivered to the runner. The shipped
30638
+ * behaviour, and the only correct one if something can ask for a crop of a
30639
+ * frame the runner never sent to inference.
30640
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
30641
+ * A native-crop request always names a `frameId` that rode an inference
30642
+ * result, so that is the only set a request can name. How much it drops is
30643
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
30644
+ * this cluster, not the ~80% the design sketch assumed, because the governor
30645
+ * was not throttling as hard as the sketch supposed. Read
30646
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
30647
+ * of you rather than quoting a number from here. The newest delivered frame is
30648
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
30649
+ * which covers the one-frame race between a mark and the supersede that
30650
+ * consumes it.
30651
+ */
30652
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
30653
+ /**
30654
+ * Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
30655
+ * the slider) because the value also travels to a forked child process, where a
30656
+ * junk number would silently become a 0-length or unbounded retention window.
30657
+ */
30658
+ var NativeLeaseSettingsSchema = object({
30659
+ /**
30660
+ * How long a retained native frame is served before it counts as a miss.
30661
+ *
30662
+ * Must cover the FULL late-crop horizon: detection inference + the
30663
+ * cross-process inference-result hop to hub post-analysis + tracking + the
30664
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
30665
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
30666
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
30667
+ */
30668
+ ttlMs: number().int().min(250).max(1e4),
30669
+ /**
30670
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
30671
+ *
30672
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
30673
+ * which one is actually binding before reasoning from that. At the shipped
30674
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
30675
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
30676
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
30677
+ * change that admits fewer frames buys retention WINDOW at constant RAM
30678
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
30679
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
30680
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
30681
+ * to replace).
30682
+ */
30683
+ budgetMb: number().int().min(0).max(4096),
30684
+ /**
30685
+ * Demand window: eager per-frame native retention runs only within this many
30686
+ * ms of the last native-crop request (or of the dial starting).
30687
+ *
30688
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
30689
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
30690
+ * per second on a camera with zero crop demand), so leave it non-zero unless
30691
+ * you are reproducing that.
30692
+ */
30693
+ activityMs: number().int().min(0).max(12e4),
30694
+ /**
30695
+ * Which delivered frames are retained at all — see
30696
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
30697
+ * changes WHAT is kept rather than for how long, so it is also the only one
30698
+ * that can turn a crop that used to hit into a miss. The worker counts every
30699
+ * crop request naming a frame it did NOT see marked
30700
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
30701
+ * there is the signal that some caller names frames outside the inference set
30702
+ * and that this must go back to `all`.
30703
+ */
30704
+ admission: NativeLeaseAdmissionSchema
30705
+ });
30706
+ /**
30707
+ * The values in force when the operator has set nothing — byte-for-byte the
30708
+ * constants the decode worker shipped with as env-var defaults, so making these
30709
+ * settings changed no behaviour on the day it landed.
30710
+ */
30711
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
30712
+ ttlMs: 1200,
30713
+ budgetMb: 1024,
30714
+ activityMs: 15e3,
30715
+ admission: "inferred"
30716
+ };
30717
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
30718
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
30719
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
30720
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
30721
+ /**
30722
+ * Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
30723
+ * value equal to the shipped default.
30724
+ *
30725
+ * That last rule is not tidiness, it is the difference between the documented
30726
+ * precedence being true and being a lie. `addon-settings.getGlobalSettings`
30727
+ * returns a HYDRATED payload, and `hydrateField` fills an unstored field with
30728
+ * the schema's own `default` (verified live on the hub: a cluster that has never
30729
+ * opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
30730
+ * that at face value would report all three knobs as "set" on every cluster on
30731
+ * the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
30732
+ * emergency override that the precedence promises. There is no raw-store read on
30733
+ * this cap to distinguish the two, so the default value itself is treated as
30734
+ * "the operator has expressed no preference" — which is also what leaving a
30735
+ * slider untouched means.
30736
+ *
30737
+ * The cost is one honest edge: an operator who deliberately selects the default
30738
+ * value in order to overrule an env var does not get it. Clear the env var
30739
+ * instead; the worker's spawn line names the source, so this is visible rather
30740
+ * than mysterious.
30741
+ */
30742
+ function readKnob(knob, raw) {
30743
+ const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
30744
+ if (!parsed.success) return null;
30745
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
30746
+ }
30747
+ /** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
30748
+ function readAdmissionKnob(raw) {
30749
+ const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
30750
+ if (!parsed.success) return null;
30751
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
30752
+ }
30753
+ /**
30754
+ * Narrow a FLAT settings record to the knobs the operator set.
30755
+ *
30756
+ * Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
30757
+ * budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
30758
+ * clamped, not defaulted) so the caller can still fall through to the env
30759
+ * override — clamping here would turn a typo into a value nobody chose. See
30760
+ * {@link readKnob} for why the default counts as unset.
30761
+ */
30762
+ function readNativeLeaseOverride(config) {
30763
+ const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
30764
+ const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
30765
+ const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
30766
+ const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
30767
+ return {
30768
+ ...ttlMs === null ? {} : { ttlMs },
30769
+ ...budgetMb === null ? {} : { budgetMb },
30770
+ ...activityMs === null ? {} : { activityMs },
30771
+ ...admission === null ? {} : { admission }
30772
+ };
30773
+ }
30774
+ function isHydratedField(entry) {
30775
+ return typeof entry === "object" && entry !== null && "key" in entry;
30776
+ }
30777
+ var LEASE_KEYS = [
30778
+ NATIVE_LEASE_TTL_KEY,
30779
+ NATIVE_LEASE_BUDGET_KEY,
30780
+ NATIVE_LEASE_ACTIVITY_KEY,
30781
+ NATIVE_LEASE_ADMISSION_KEY
30782
+ ];
30783
+ /**
30784
+ * Extract the operator's lease overrides from an
30785
+ * `addon-settings.getGlobalSettings` payload.
30786
+ *
30787
+ * Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
30788
+ * alone: the keys are unique across the addon's schema, and a section rename
30789
+ * must not silently revert the whole cluster to the defaults. A `null` payload
30790
+ * (addon mid-boot) means "operator set nothing" — the env/default fallback then
30791
+ * applies, which is the correct read of "I could not ask".
30792
+ */
30793
+ function pickNativeLeaseOverride(view) {
30794
+ if (view === null) return {};
30795
+ const flat = {};
30796
+ for (const section of view.sections) for (const entry of section.fields) {
30797
+ if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
30798
+ if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
30799
+ }
30800
+ return readNativeLeaseOverride(flat);
30801
+ }
30183
30802
  var AUTO = {
30184
30803
  value: "auto",
30185
30804
  label: "Auto"
@@ -30397,4 +31016,4 @@ function enumerateInferenceDevices(hw) {
30397
31016
  return out;
30398
31017
  }
30399
31018
  //#endregion
30400
- export { isEvent as $, evaluateZoneRules as A, recordingExportCapability as B, customAction as C, deriveRecordingMode as D, deriveDetailCropRect as E, motionDetectionCapability as F, webrtcSessionCapability as G, storageEvictableCapability as H, pickDetailCropConvention as I, CAM_PROFILE_ORDER as J, errMsg as K, pipelineExecutorCapability as L, invocationFromEncodeProfile as M, mapAudioLabelToMacro as N, detectionPipelineCapability as O, maskUrlCredentials as P, hydrateSchema as Q, pipelineRunnerCapability as R, cameraStreamsCapability as S, defineCustomActions as T, streamBrokerCapability as U, runtimeDevices as V, supportedRuntimes as W, DeviceType as X, DeviceFeature as Y, createEvent as Z, YAMNET_TO_MACRO as _, union as _t, COCO_80_LABELS as a, selectAssignedProfileSlots as at, audioAnalyzerCapability as b, DEFAULT_DETAIL_CROP_CONVENTION as c, array as ct, EncodeProfileSchema as d, lazy as dt, makeProfileBrokerId as et, ExportRecordSchema as f, literal as ft, RingBuffer as g, string as gt, RecordingConfigSchema as h, record as ht, AUDIO_PRESETS as i, parseProfileBrokerId as it, hfModelUrl as j, enumerateInferenceDevices as k, DEVICE_BACKEND_TO_FORMAT as l, boolean as lt, OpsLogEntrySchema as m, object as mt, AUDIO_BACKEND_CHOICES as n, nodePin as nt, COCO_TO_MACRO as o, sleep as ot, HF_BASE_URL as p, number as pt, BaseAddon as q, AUDIO_MACRO_LABELS as r, parseJsonUnknown as rt, DEFAULT_AUDIO_ANALYZER_CONFIG as s, _enum as st, APPLE_SA_TO_MACRO as t, makeSourceBrokerId as tt, EVENT_PAD_MS as u, discriminatedUnion as ut, addonWidgetsSourceCapability as v, EventCategory as vt, defaultDeviceFor as w, buildFfmpegArgs as x, audioAnalysisCapability as y, recordingCapability as z };
31019
+ export { storageEvictableCapability as $, defaultDeviceFor as A, invocationFromEncodeProfile as B, addonWidgetsSourceCapability as C, literal as Ct, cameraStreamsCapability as D, string as Dt, buildFfmpegArgs as E, record as Et, egressTranscodeSharingKey as F, pickDetailCropConvention as G, mapAudioLabelToMacro as H, egressTransportFromRequest as I, pipelineRunnerCapability as J, pickNativeLeaseOverride as K, enumerateInferenceDevices as L, deriveDetailCropRect as M, deriveRecordingMode as N, createHwAccelCache as O, union as Ot, detectionPipelineCapability as P, runtimeDevices as Q, evaluateZoneRules as R, YAMNET_TO_MACRO as S, lazy as St, audioAnalyzerCapability as T, object as Tt, maskUrlCredentials as U, isSoftwareDecode as V, motionDetectionCapability as W, recordingExportCapability as X, recordingCapability as Y, resolveEgressDecodeHwAccel as Z, OpsLogEntrySchema as _, sleep as _t, COCO_80_LABELS as a, CAM_PROFILE_ORDER as at, RecordingConfigSchema as b, boolean as bt, DEFAULT_DETAIL_CROP_CONVENTION as c, createEvent as ct, EVENT_PAD_MS as d, makeProfileBrokerId as dt, streamBrokerCapability as et, EncodeProfileSchema as f, makeSourceBrokerId as ft, NativeLeaseSettingsSchema as g, selectAssignedProfileSlots as gt, NativeLeaseAdmissionSchema as h, parseProfileBrokerId as ht, AUDIO_PRESETS as i, BaseAddon as it, defineCustomActions as j, customAction as k, EventCategory as kt, DEFAULT_NATIVE_LEASE_SETTINGS as l, hydrateSchema as lt, HF_BASE_URL as m, parseJsonUnknown as mt, AUDIO_BACKEND_CHOICES as n, webrtcSessionCapability as nt, COCO_TO_MACRO as o, DeviceFeature as ot, ExportRecordSchema as p, nodePin as pt, pipelineExecutorCapability as q, AUDIO_MACRO_LABELS as r, errMsg as rt, DEFAULT_AUDIO_ANALYZER_CONFIG as s, DeviceType as st, APPLE_SA_TO_MACRO as t, supportedRuntimes as tt, DEVICE_BACKEND_TO_FORMAT as u, isEvent as ut, RATE_CONTROL_RELAXED as v, _enum as vt, audioAnalysisCapability as w, number as wt, RingBuffer as x, discriminatedUnion as xt, RATE_CONTROL_TIGHT as y, array as yt, hfModelUrl as z };