@camstack/addon-pipeline 1.2.42 → 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 (39) hide show
  1. package/dist/audio-analyzer/index.js +4 -4
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +164 -88
  4. package/dist/detection-pipeline/index.mjs +158 -82
  5. package/dist/{dist-BdDd3ql_.js → dist-BuFE5rOK.js} +1608 -36
  6. package/dist/{dist-COK_1Ot8.mjs → dist-DOu93i_g.mjs} +1525 -37
  7. package/dist/{event-loop-stall-monitor-CWOJtTNc.mjs → event-loop-stall-monitor-CShgQE6l.mjs} +40 -2
  8. package/dist/{event-loop-stall-monitor-DNFRgNef.js → event-loop-stall-monitor-L08yG6bB.js} +40 -2
  9. package/dist/{model-download-service-Cp9f4dk6-fsdDExML.js → model-download-service-D8B-4ktF-7-YxE9Wx.js} +2 -2
  10. package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-D8B-4ktF-BB7oZL3y.mjs} +2 -2
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +496 -67
  14. package/dist/pipeline-runner/index.mjs +496 -67
  15. package/dist/recorder/index.js +194 -11
  16. package/dist/recorder/index.mjs +191 -8
  17. package/dist/session-decode/decode-worker-child.js +197 -24
  18. package/dist/session-decode/decode-worker-child.mjs +197 -24
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C8ovZN0g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs} +2 -2
  21. 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
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DAjAxXHm.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
  23. package/dist/stream-broker/{hostInit-mEtjQWcr.mjs → hostInit-khRXMnjl.mjs} +2 -2
  24. package/dist/stream-broker/index.js +17331 -14101
  25. package/dist/stream-broker/index.mjs +17330 -14100
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/worker-protocol-BOXlUhWO.mjs +244 -0
  28. package/dist/worker-protocol-VURr0nUh.js +279 -0
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C0TIfr0G.js → MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-x0SkfHwv.js → MotionZonesSettings-NcxxQN8r-jFDOzPCD.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BoaAB4Ta.js → PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js} +1 -1
  32. package/embed-dist/assets/{index-BgRdCCVy.js → index-4CtQAybX.js} +12 -12
  33. package/embed-dist/assets/index-DC63og2c.css +2 -0
  34. package/embed-dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-SU_6F3Y7.mjs +0 -26
  37. package/dist/worker-protocol-D7RzZIla.mjs +0 -77
  38. package/dist/worker-protocol-DextwlTX.js +0 -94
  39. 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'
@@ -7113,6 +7176,14 @@ var EncodeProfileSchema = object({
7113
7176
  "main",
7114
7177
  "high"
7115
7178
  ]).optional(),
7179
+ /**
7180
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7181
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7182
+ * it, or it ships a stream that does not match its own advertisement — the
7183
+ * defect class that kept HomeKit black for a year and that Alexa carried
7184
+ * silently. Optional because a browser negotiates the level itself.
7185
+ */
7186
+ level: string().optional(),
7116
7187
  width: number().int().positive().optional(),
7117
7188
  height: number().int().positive().optional(),
7118
7189
  fps: number().positive().optional(),
@@ -7159,6 +7230,557 @@ var EncodeProfileSchema = object({
7159
7230
  */
7160
7231
  outputArgs: array(string()).optional()
7161
7232
  });
7233
+ var AUDIO_ENCODER_BY_CODEC = {
7234
+ opus: "libopus",
7235
+ aac: "aac",
7236
+ pcmu: "pcm_mulaw",
7237
+ pcma: "pcm_alaw"
7238
+ };
7239
+ /**
7240
+ * Camera-microphone audio, per codec. Lives HERE rather than in
7241
+ * `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
7242
+ * on these types); it is re-exported from there, which is where to read it.
7243
+ *
7244
+ * Every source in this repo is a mono camera mic. The former broker preset
7245
+ * encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
7246
+ * that is the value this consolidation changed.
7247
+ */
7248
+ var AUDIO_PRESETS = {
7249
+ aac: {
7250
+ kind: "encode",
7251
+ codec: "aac",
7252
+ bitrateKbps: 128,
7253
+ sampleRateHz: 48e3,
7254
+ channels: 1
7255
+ },
7256
+ opus: {
7257
+ kind: "encode",
7258
+ codec: "opus",
7259
+ bitrateKbps: 64,
7260
+ sampleRateHz: 48e3,
7261
+ channels: 1
7262
+ },
7263
+ pcmu: {
7264
+ kind: "encode",
7265
+ codec: "pcmu",
7266
+ sampleRateHz: 8e3,
7267
+ channels: 1
7268
+ }
7269
+ };
7270
+ /** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
7271
+ function logBannerArgs(level) {
7272
+ return [
7273
+ "-hide_banner",
7274
+ "-loglevel",
7275
+ level
7276
+ ];
7277
+ }
7278
+ /** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
7279
+ function isSoftwareDecode(decodeHwAccel) {
7280
+ return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
7281
+ }
7282
+ /**
7283
+ * Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
7284
+ * appended to this list by a caller — that is the whole point of the function.
7285
+ */
7286
+ function buildInputArgs(input, decodeHwAccel) {
7287
+ const args = [];
7288
+ if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
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));
7292
+ if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7293
+ if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7294
+ args.push("-i", input.url);
7295
+ return args;
7296
+ }
7297
+ /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7298
+ function buildVideoFilterArgs(scale, outputArgs) {
7299
+ if (!scale) return [];
7300
+ if (outputArgs.some((a) => a === "-vf")) return [];
7301
+ if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7302
+ return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7303
+ }
7304
+ /** Rate-control args for an encode plan. */
7305
+ function buildRateControlArgs(video) {
7306
+ const kbps = video.bitrateKbps;
7307
+ if (kbps === void 0) return [];
7308
+ const rc = video.rateControl ?? {
7309
+ kind: "cap",
7310
+ vbvSeconds: 2
7311
+ };
7312
+ const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
7313
+ return [
7314
+ ...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
7315
+ "-maxrate",
7316
+ `${kbps}k`,
7317
+ "-bufsize",
7318
+ `${bufsize}k`
7319
+ ];
7320
+ }
7321
+ /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7322
+ function buildVideoArgs(video, outputArgs) {
7323
+ if (video.kind === "copy") return [
7324
+ "-c:v",
7325
+ "copy",
7326
+ ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7327
+ ];
7328
+ const args = [
7329
+ ...buildVideoFilterArgs(video.scale, outputArgs),
7330
+ "-c:v",
7331
+ video.encoder
7332
+ ];
7333
+ if (video.preset !== void 0) args.push("-preset", video.preset);
7334
+ if (video.tune !== void 0) args.push("-tune", video.tune);
7335
+ if (video.profile !== void 0) args.push("-profile:v", video.profile);
7336
+ if (video.level !== void 0) args.push("-level", video.level);
7337
+ if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
7338
+ if (video.fps !== void 0) args.push("-r", String(video.fps));
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})`);
7341
+ if (video.bf !== void 0) args.push("-bf", String(video.bf));
7342
+ args.push(...buildRateControlArgs(video));
7343
+ if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
7344
+ return args;
7345
+ }
7346
+ /** The whole audio block, after `-i`. */
7347
+ function buildAudioArgs(audio) {
7348
+ if (audio.kind === "none") return ["-an"];
7349
+ if (audio.kind === "copy") return ["-c:a", "copy"];
7350
+ const args = [];
7351
+ if (audio.filter !== void 0) args.push("-af", audio.filter);
7352
+ args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
7353
+ if (audio.application !== void 0) args.push("-application", audio.application);
7354
+ if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
7355
+ if (audio.globalHeader === true) args.push("-flags", "+global_header");
7356
+ if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
7357
+ if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
7358
+ if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
7359
+ if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
7360
+ return args;
7361
+ }
7362
+ /** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
7363
+ function buildRtpOutputArgs(out) {
7364
+ const args = [];
7365
+ if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
7366
+ if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
7367
+ if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
7368
+ args.push("-f", "rtp", out.url);
7369
+ return args;
7370
+ }
7371
+ /** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
7372
+ function isElementaryVideoSink(sink) {
7373
+ return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
7374
+ }
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
+ /**
7426
+ * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
7427
+ * audio optional so a source with no audio skips it instead of failing the
7428
+ * whole invocation.
7429
+ */
7430
+ function buildAudioSidecarArgs(sidecar) {
7431
+ return [
7432
+ "-map",
7433
+ "0:a:0?",
7434
+ ...buildAudioArgs(sidecar.codec === "pcma" ? {
7435
+ kind: "encode",
7436
+ codec: "pcma",
7437
+ sampleRateHz: 8e3,
7438
+ channels: 1
7439
+ } : AUDIO_PRESETS[sidecar.codec]),
7440
+ ...buildRtpOutputArgs({
7441
+ url: sidecar.rtpUrl,
7442
+ sdpFile: sidecar.sdpFile
7443
+ })
7444
+ ];
7445
+ }
7446
+ /**
7447
+ * Assemble the full ffmpeg argument list. Layout:
7448
+ *
7449
+ * -hide_banner -loglevel <level>
7450
+ * [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
7451
+ * [<input.extraArgs>] │
7452
+ * [-fflags <flag>…] │
7453
+ * [-rtsp_transport tcp] │
7454
+ * -i <url> ─┘
7455
+ * <video block> <threads> <audio block> ─┐ OUTPUT options.
7456
+ * <consumer outputArgs verbatim> │
7457
+ * <sink> ─┘ terminal
7458
+ */
7459
+ function buildFfmpegArgs(inv) {
7460
+ const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7461
+ const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7462
+ if (inv.sink.kind === "rtp-outputs") {
7463
+ const videoLeg = inv.sink.video ? [
7464
+ "-an",
7465
+ "-map",
7466
+ "0:v:0",
7467
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7468
+ ...threadArgs,
7469
+ ...inv.outputArgs,
7470
+ ...buildRtpOutputArgs(inv.sink.video)
7471
+ ] : [];
7472
+ const audioLeg = inv.sink.audio ? [
7473
+ "-vn",
7474
+ "-map",
7475
+ "0:a:0?",
7476
+ ...buildAudioArgs(inv.audio),
7477
+ ...buildRtpOutputArgs(inv.sink.audio)
7478
+ ] : [];
7479
+ return [
7480
+ ...head,
7481
+ ...videoLeg,
7482
+ ...audioLeg
7483
+ ];
7484
+ }
7485
+ const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7486
+ const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7487
+ return [
7488
+ ...head,
7489
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7490
+ ...threadArgs,
7491
+ ...audioArgs,
7492
+ ...inv.outputArgs,
7493
+ ...sinkArgs,
7494
+ ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7495
+ ];
7496
+ }
7497
+ /**
7498
+ * Hardware ENCODER ids per decode-hwaccel backend — a static, deterministic
7499
+ * map (the same shape the reference NVR uses: platform → encoder, no probe).
7500
+ */
7501
+ var ENCODER_IDS_BY_BACKEND = {
7502
+ videotoolbox: {
7503
+ h264: "h264_videotoolbox",
7504
+ h265: "hevc_videotoolbox"
7505
+ },
7506
+ vaapi: {
7507
+ h264: "h264_vaapi",
7508
+ h265: "hevc_vaapi"
7509
+ },
7510
+ qsv: {
7511
+ h264: "h264_qsv",
7512
+ h265: "hevc_qsv"
7513
+ },
7514
+ cuda: {
7515
+ h264: "h264_nvenc",
7516
+ h265: "hevc_nvenc"
7517
+ },
7518
+ nvdec: {
7519
+ h264: "h264_nvenc",
7520
+ h265: "hevc_nvenc"
7521
+ },
7522
+ amf: {
7523
+ h264: "h264_amf",
7524
+ h265: "hevc_amf"
7525
+ }
7526
+ };
7527
+ /**
7528
+ * The hardware encoder for a target codec on `backend`, or the software one.
7529
+ * `'auto'` is NOT a backend identity (it is an instruction to ffmpeg), so it
7530
+ * maps to software encoding.
7531
+ */
7532
+ function pickVideoEncoder(target, backend, useHardware) {
7533
+ const software = target === "h264" ? "libx264" : "libx265";
7534
+ if (!useHardware || backend === null || isSoftwareDecode(backend) || backend === "auto") return software;
7535
+ const ids = ENCODER_IDS_BY_BACKEND[backend.toLowerCase()];
7536
+ if (!ids) return software;
7537
+ return target === "h264" ? ids.h264 : ids.h265;
7538
+ }
7539
+ /** Map an `EncodeProfile.audio` to an audio plan. */
7540
+ function audioPlanFromEncodeProfile(audio) {
7541
+ if (audio === "passthrough") return { kind: "none" };
7542
+ if (audio.codec === "copy") return { kind: "copy" };
7543
+ return {
7544
+ kind: "encode",
7545
+ codec: audio.codec,
7546
+ ...audio.bitrateKbps !== void 0 ? { bitrateKbps: audio.bitrateKbps } : {},
7547
+ ...audio.sampleRateHz !== void 0 ? { sampleRateHz: audio.sampleRateHz } : {},
7548
+ ...audio.channels !== void 0 ? { channels: audio.channels } : {}
7549
+ };
7550
+ }
7551
+ /**
7552
+ * Adapt an `EncodeProfile` (the operator/consumer-facing shape) into an
7553
+ * {@link FfmpegInvocation}. This is the ONLY bridge between the two models —
7554
+ * a second one is how the repo grew two argv builders that disagreed about
7555
+ * hardware.
7556
+ *
7557
+ * Smart video copy: when the source already speaks the requested codec the
7558
+ * encode block is elided entirely and ffmpeg runs as a re-muxer on the video
7559
+ * plane. Width / height / fps / bitrate in the profile are a downstream BUDGET,
7560
+ * not a forced rescale.
7561
+ */
7562
+ function invocationFromEncodeProfile(input) {
7563
+ const v = input.profile.video;
7564
+ const shouldCopy = v.codec === "copy" || input.forceReencode !== true && v.codec === input.sourceCodec;
7565
+ const scale = v.width !== void 0 && v.height !== void 0 ? {
7566
+ mode: "fit",
7567
+ width: v.width,
7568
+ height: v.height
7569
+ } : null;
7570
+ const target = v.codec === "h265" ? "h265" : "h264";
7571
+ const video = shouldCopy ? {
7572
+ kind: "copy",
7573
+ ...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
7574
+ } : {
7575
+ kind: "encode",
7576
+ encoder: pickVideoEncoder(target, input.decodeHwAccel, input.hardwareEncoders === true),
7577
+ scale,
7578
+ ...v.preset !== void 0 ? { preset: v.preset } : {},
7579
+ ...v.tune !== void 0 ? { tune: v.tune } : {},
7580
+ ...v.profile !== void 0 ? { profile: v.profile } : {},
7581
+ ...v.level !== void 0 ? { level: v.level } : {},
7582
+ ...input.pixelFormat !== void 0 ? { pixelFormat: input.pixelFormat } : {},
7583
+ ...v.fps !== void 0 ? { fps: v.fps } : {},
7584
+ ...v.gopFrames !== void 0 ? { gopFrames: v.gopFrames } : {},
7585
+ ...input.forceKeyFramesSeconds !== void 0 ? { forceKeyFramesSeconds: input.forceKeyFramesSeconds } : {},
7586
+ ...v.bf !== void 0 ? { bf: v.bf } : {},
7587
+ ...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {},
7588
+ ...input.rateControl !== void 0 ? { rateControl: input.rateControl } : {},
7589
+ ...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
7590
+ };
7591
+ return {
7592
+ logLevel: input.logLevel ?? "error",
7593
+ decodeHwAccel: input.decodeHwAccel,
7594
+ input: {
7595
+ url: input.sourceUrl,
7596
+ rtspTransport: "tcp",
7597
+ fflags: ["+discardcorrupt"],
7598
+ ...input.profile.inputArgs?.length ? { extraArgs: input.profile.inputArgs } : {}
7599
+ },
7600
+ video,
7601
+ audio: audioPlanFromEncodeProfile(input.profile.audio),
7602
+ threadCount: input.threadCount ?? 0,
7603
+ outputArgs: input.profile.outputArgs ?? [],
7604
+ sink: input.sink,
7605
+ ...input.audioSidecar !== void 0 ? { audioSidecar: input.audioSidecar } : {}
7606
+ };
7607
+ }
7608
+ /**
7609
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7610
+ * Baseline because it is the one profile every consumer in this repo decodes
7611
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7612
+ */
7613
+ var BASE_LIVE_EGRESS_PROFILE = {
7614
+ video: {
7615
+ codec: "h264",
7616
+ profile: "baseline",
7617
+ level: "3.1",
7618
+ width: 1280,
7619
+ height: 720,
7620
+ fps: 25,
7621
+ bitrateKbps: 2500,
7622
+ gopFrames: 25,
7623
+ bf: 0,
7624
+ preset: "veryfast",
7625
+ tune: "zerolatency"
7626
+ },
7627
+ audio: "passthrough"
7628
+ };
7629
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
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
+ }
7162
7784
  /**
7163
7785
  * Deep wiring healthcheck — snapshot of active reachability probes across
7164
7786
  * every declared capability + widget of every installed plugin, on every
@@ -7209,6 +7831,154 @@ object({
7209
7831
  })
7210
7832
  });
7211
7833
  /**
7834
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7835
+ * pipeline functions an operator thinks in terms of.
7836
+ *
7837
+ * ## This file adds no state
7838
+ *
7839
+ * Every switch here is a VIEW onto an authority that already existed
7840
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7841
+ * group is that there is exactly one place each function is turned off, and
7842
+ * the group routes to it:
7843
+ *
7844
+ * | Switch | Authority | Proven "off stops the work" gate |
7845
+ * | --- | --- | --- |
7846
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7847
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7848
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7849
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
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.
7885
+ *
7886
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7887
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7888
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7889
+ * surface that decision never got.
7890
+ *
7891
+ * ## Two rules that are load-bearing
7892
+ *
7893
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7894
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7895
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7896
+ * turning the camera back on would then silently record nothing.
7897
+ * - **A switch that is off must be reported as off**, not merely produce
7898
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7899
+ * "disabled by an operator" instead of "broken" — see
7900
+ * `CameraStatus.switchedOff`.
7901
+ */
7902
+ /**
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
7906
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7907
+ * editor, not in a safety group.
7908
+ */
7909
+ var CameraSwitchIdSchema = _enum([
7910
+ "stream-broker",
7911
+ "object-detection",
7912
+ "privacy-mask",
7913
+ "device-audio",
7914
+ "audio-analysis",
7915
+ "recording",
7916
+ "notifications"
7917
+ ]);
7918
+ /**
7919
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7920
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7921
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7922
+ * a compile error rather than a review comment.
7923
+ */
7924
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7925
+ object({ kind: literal("device-disabled") }),
7926
+ object({
7927
+ kind: literal("wrapper-binding"),
7928
+ capName: string()
7929
+ }),
7930
+ object({ kind: literal("recording-config") }),
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
+ })
7940
+ ]);
7941
+ /**
7942
+ * Why a switch is not offered for this camera. Rendered instead of the
7943
+ * control, never as a dead control — an absent function and a broken one must
7944
+ * not look the same.
7945
+ */
7946
+ var CameraSwitchUnavailableReasonSchema = _enum([
7947
+ "no-provider",
7948
+ "source-unreachable",
7949
+ "not-configured"
7950
+ ]);
7951
+ /**
7952
+ * One switch, resolved for one camera.
7953
+ *
7954
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7955
+ * client-side: the viewer is a separate repository that does not import
7956
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7957
+ * that will disagree with itself. Five rows per camera is nothing.
7958
+ */
7959
+ var CameraSwitchSchema = object({
7960
+ id: CameraSwitchIdSchema,
7961
+ label: string(),
7962
+ /**
7963
+ * What the operator LOSES while this is off, in one sentence. Required, not
7964
+ * optional: a switch that cannot say what it costs should not ship.
7965
+ */
7966
+ costWhenOff: string(),
7967
+ /** False = do not render a control. `unavailableReason` says why. */
7968
+ available: boolean(),
7969
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7970
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7971
+ enabled: boolean(),
7972
+ authority: CameraSwitchAuthoritySchema
7973
+ });
7974
+ /** The whole group for one camera. */
7975
+ var CameraSwitchGroupSchema = object({
7976
+ deviceId: number().int(),
7977
+ switches: array(CameraSwitchSchema).readonly(),
7978
+ /** Unix ms when the group was composed server-side. */
7979
+ fetchedAt: number()
7980
+ });
7981
+ /**
7212
7982
  * Ops-log — the durable, append-only operations audit shared by the
7213
7983
  * recordings and events management surfaces.
7214
7984
  *
@@ -7227,14 +7997,16 @@ var OpsLogOpSchema = _enum([
7227
7997
  "manual-delete",
7228
7998
  "rescan",
7229
7999
  "retention-run",
7230
- "relocate"
8000
+ "relocate",
8001
+ "orphan-audit"
7231
8002
  ]);
7232
8003
  /** Why the operation ran. */
7233
8004
  var OpsLogReasonSchema = _enum([
7234
8005
  "retention",
7235
8006
  "quota",
7236
8007
  "manual",
7237
- "operator"
8008
+ "operator",
8009
+ "maintenance"
7238
8010
  ]);
7239
8011
  /** One audit row, shared verbatim by both domains. */
7240
8012
  var OpsLogEntrySchema = object({
@@ -9221,6 +9993,126 @@ var RtpSourceSchema = object({
9221
9993
  encoder: string(),
9222
9994
  pipelineKey: string()
9223
9995
  });
9996
+ /**
9997
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9998
+ * hatch.** This is deliberate and it is the one lesson taken from
9999
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
10000
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
10001
+ * adding a flag silently forks the shared child, and two consumers that mean
10002
+ * the same thing but spell it differently never share. Here every knob is a
10003
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
10004
+ * never an opaque array.
10005
+ *
10006
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
10007
+ * The operator-facing derived-stream transform editor still has them — that is
10008
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
10009
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
10010
+ */
10011
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
10012
+ inputArgs: true,
10013
+ outputArgs: true
10014
+ });
10015
+ /**
10016
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
10017
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
10018
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
10019
+ * an Echo). Named rather than numeric so the INTENT survives.
10020
+ */
10021
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
10022
+ var EgressTranscodeRequestSchema = object({
10023
+ deviceId: number().int().nonnegative(),
10024
+ /** Which published stream to read. */
10025
+ source: discriminatedUnion("kind", [object({
10026
+ kind: literal("profile"),
10027
+ profile: CamProfileSchema
10028
+ }), object({
10029
+ kind: literal("cam-stream"),
10030
+ camStreamId: string().min(1)
10031
+ })]),
10032
+ encode: EgressEncodeSchema,
10033
+ rateControl: EgressRateControlSchema.optional(),
10034
+ /**
10035
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
10036
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
10037
+ * branches. Enumerated, not free text.
10038
+ */
10039
+ bitstreamFilter: _enum([
10040
+ "dump_extra",
10041
+ "h264_mp4toannexb",
10042
+ "hevc_mp4toannexb"
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(),
10064
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
10065
+ /**
10066
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
10067
+ * and the one that matters: the broker then resolves the backend from the
10068
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
10069
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
10070
+ * on this hardware — never the raw kernel resolver's qsv-first order.
10071
+ */
10072
+ decodeHwAccel: _enum([
10073
+ "auto",
10074
+ "none",
10075
+ "videotoolbox",
10076
+ "vaapi",
10077
+ "qsv",
10078
+ "cuda"
10079
+ ]).optional(),
10080
+ /**
10081
+ * Host to embed in the returned restream `url`. The broker mints hub-local
10082
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
10083
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
10084
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
10085
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
10086
+ */
10087
+ hostname: string().optional(),
10088
+ /** Attribution for the broker panel. Never part of the sharing key. */
10089
+ tag: string().optional()
10090
+ });
10091
+ var EgressTranscodeSchema = object({
10092
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
10093
+ url: string(),
10094
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
10095
+ pipelineKey: string(),
10096
+ videoCodec: _enum(["H264", "H265"]),
10097
+ resolution: object({
10098
+ width: number().int().positive(),
10099
+ height: number().int().positive()
10100
+ }),
10101
+ transcoded: boolean(),
10102
+ encoder: string(),
10103
+ /**
10104
+ * The decode backend the child ACTUALLY ran with — `null` for software.
10105
+ * Returned rather than assumed: a consumer that asked for hardware and got
10106
+ * software needs to be able to see that without reading the broker's logs.
10107
+ */
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()
10115
+ });
9224
10116
  var streamBrokerCapability = {
9225
10117
  name: "stream-broker",
9226
10118
  scope: "system",
@@ -9392,6 +10284,40 @@ var streamBrokerCapability = {
9392
10284
  auth: "admin"
9393
10285
  }),
9394
10286
  /**
10287
+ * THE ffmpeg primitive. Acquire an encoded stream matching a structured
10288
+ * encode plan; the broker builds the argv through the ONE builder
10289
+ * (`@camstack/types` `ffmpeg/invocation.ts`), resolves decode hardware from
10290
+ * the DECODER ADDON's per-node ranking, and returns a dialable RTSP url.
10291
+ *
10292
+ * **Refcounted and deduplicated on EXACT match.** Two requesters whose
10293
+ * requests produce the same `egressTranscodeSharingKey` receive the SAME
10294
+ * `pipelineKey` and the same child process. Nearly-identical requests are
10295
+ * NOT merged.
10296
+ *
10297
+ * **The handle is immutable.** There is deliberately no `reconfigure`
10298
+ * method and this one never accepts a `pipelineKey` alongside encode
10299
+ * parameters: a consumer whose requirements change releases and
10300
+ * re-acquires. A mutable shared handle is exactly what made Alexa's old
10301
+ * `derived:alexa-<id>` stream a co-tenant hazard — one consumer's
10302
+ * downgrade dragged every other consumer down with it.
10303
+ *
10304
+ * Placement: unpinned, `classifyCapRoute` serves this hub-in-process
10305
+ * (Priority 1). A caller that wants the transcode elsewhere passes
10306
+ * `nodePin(nodeId)` and a `hostname` it can dial.
10307
+ */
10308
+ acquireEgressTranscode: method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
10309
+ kind: "mutation",
10310
+ auth: "admin"
10311
+ }),
10312
+ /** Drop one reference. The child dies when the last holder releases. */
10313
+ releaseEgressTranscode: method(object({ pipelineKey: string() }), object({
10314
+ released: boolean(),
10315
+ refcount: number().int().nonnegative()
10316
+ }), {
10317
+ kind: "mutation",
10318
+ auth: "admin"
10319
+ }),
10320
+ /**
9395
10321
  * ── Decoded audio-chunk plane (Phase 5 / D9) ──────────────────────
9396
10322
  *
9397
10323
  * The serialisable replacement for the live-object `IStreamBroker.
@@ -14271,12 +15197,13 @@ var NcConditionsSchema = object({
14271
15197
  * source; otherwise the subject's source must equal it. Legacy records
14272
15198
  * with no stamped source are treated as `pipeline`. The union spans both
14273
15199
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
14274
- * tracks carry `sensor`.
15200
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
14275
15201
  */
14276
15202
  source: _enum([
14277
15203
  "pipeline",
14278
15204
  "onboard",
14279
15205
  "sensor",
15206
+ "audio",
14280
15207
  "any"
14281
15208
  ]).optional(),
14282
15209
  /**
@@ -14852,6 +15779,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14852
15779
  }), object({ success: literal(true) }), {
14853
15780
  kind: "mutation",
14854
15781
  auth: "admin"
15782
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
15783
+ deviceId: number().int(),
15784
+ muted: boolean()
15785
+ }), object({ success: literal(true) }), {
15786
+ kind: "mutation",
15787
+ auth: "admin"
14855
15788
  }), method(object({
14856
15789
  rule: NcRuleInputSchema,
14857
15790
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -15190,12 +16123,60 @@ var TrackAudioLabelSchema = object({
15190
16123
  });
15191
16124
  /**
15192
16125
  * How a track was produced. `pipeline` (default / absent) = the spatial
15193
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
15194
- * linked sensor/control state change (no positions; carries a snapshot). The
15195
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
15196
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
16126
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
16127
+ * no positions, a single snapshot, and no bbox trajectory at all:
16128
+ *
16129
+ * - `sensor` — a linked sensor/control device state change.
16130
+ * - `audio` — an audio event on the camera itself that was anomalous for
16131
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
16132
+ *
16133
+ * The spatial subsystems (tracker association, occupancy count, re-id /
16134
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
16135
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
16136
+ * check silently readmits every source added after it was written.
16137
+ */
16138
+ var TrackSourceSchema = _enum([
16139
+ "pipeline",
16140
+ "sensor",
16141
+ "audio"
16142
+ ]);
16143
+ /**
16144
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
16145
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
16146
+ * so the two surfaces cannot drift.
16147
+ *
16148
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
16149
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
16150
+ * columns existed read as absent, and a consumer that needs a boolean should say
16151
+ * `flag === true`, not `flag !== false`.
16152
+ *
16153
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
16154
+ * operator curation, and the behaviour they drive will be specified separately.
16155
+ * In particular a `markForTrain` track is NOT pinned against retention — see
16156
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15197
16157
  */
15198
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
16158
+ var TrackFlagFields = {
16159
+ /** Operator marked this track as training material. */
16160
+ markForTrain: boolean().optional(),
16161
+ /** Operator marked this track for diagnostic attention. */
16162
+ debug: boolean().optional()
16163
+ };
16164
+ /**
16165
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
16166
+ * one flag can never clear the other — the toggles are independent and are
16167
+ * driven from three surfaces that do not know about each other.
16168
+ */
16169
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
16170
+ /**
16171
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
16172
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
16173
+ * mutation result without a re-fetch.
16174
+ */
16175
+ var TrackFlagsSchema = object({
16176
+ trackId: string(),
16177
+ markForTrain: boolean(),
16178
+ debug: boolean()
16179
+ });
15199
16180
  var TrackSchema = object({
15200
16181
  trackId: string(),
15201
16182
  deviceId: number(),
@@ -15238,7 +16219,8 @@ var TrackSchema = object({
15238
16219
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
15239
16220
  * Populated from the persisted envelope columns on historical reads;
15240
16221
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15241
- envelope: TrackEnvelopeSchema.optional()
16222
+ envelope: TrackEnvelopeSchema.optional(),
16223
+ ...TrackFlagFields
15242
16224
  });
15243
16225
  var BaseEventFields = {
15244
16226
  id: string(),
@@ -15451,7 +16433,8 @@ var KeyEventSchema = object({
15451
16433
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15452
16434
  bestEventId: string(),
15453
16435
  /** Track lifetime in ms (lastSeen - firstSeen). */
15454
- windowMs: number().optional()
16436
+ windowMs: number().optional(),
16437
+ ...TrackFlagFields
15455
16438
  });
15456
16439
  object({
15457
16440
  trackId: string(),
@@ -15537,7 +16520,31 @@ var RebuildObjectEmbeddingsInput = object({
15537
16520
  since: number().optional(),
15538
16521
  until: number().optional(),
15539
16522
  /** Stop after this many tracks; the result reports whether more remain. */
15540
- maxTracks: number().int().positive().optional()
16523
+ maxTracks: number().int().positive().optional(),
16524
+ /**
16525
+ * Run every embedding on THIS node instead of round-robining the fleet.
16526
+ *
16527
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
16528
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
16529
+ * calling it that would pin the rebuild REQUEST itself to that node — the
16530
+ * rebuild orchestration lives on the hub, and only the per-track step runs
16531
+ * remotely. This field is data; the per-track pin is applied inside.
16532
+ *
16533
+ * Absent ⇒ round-robin over every online node whose runner can serve the
16534
+ * pinned model.
16535
+ */
16536
+ executeOnNodeId: string().optional(),
16537
+ /**
16538
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
16539
+ * run flat out.
16540
+ *
16541
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
16542
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
16543
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
16544
+ * force is logged at start and finish so a deliberately slow pass reads
16545
+ * differently from a stalled one.
16546
+ */
16547
+ pacingMs: number().int().nonnegative().optional()
15541
16548
  });
15542
16549
  /**
15543
16550
  * Result of emptying the CLIP index.
@@ -15571,13 +16578,23 @@ var RebuildStatusSchema = object({
15571
16578
  /** Tracks with no usable detection box. */
15572
16579
  missingBbox: number(),
15573
16580
  /**
15574
- * Tracks the pipeline REFUSED rather than broke on: the camera is not
15575
- * attached, or `clip-embedding` is not enabled in its step tree. Separate
15576
- * from `failed` because the remedy is a configuration change, not an engine
15577
- * investigation and because a pass over decommissioned cameras would
15578
- * otherwise read as a total engine outage.
16581
+ * Tracks an executing node REFUSED rather than broke on an unreadable key
16582
+ * frame, a step that threw. Separate from `failed` because the remedy is
16583
+ * different, and because a whole camera silently contributing zero vectors
16584
+ * is the shape of failure a rebuild must never hide.
15579
16585
  */
15580
16586
  notRunnable: number(),
16587
+ /**
16588
+ * The pass stopped because NO node could serve the pinned model.
16589
+ *
16590
+ * Distinct from `notRunnable` on purpose: that one says "this track was
16591
+ * refused", this one says "the cluster cannot do this work at all" — every
16592
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
16593
+ * pinned model for its engine format, or dropped out. The remedy is a model /
16594
+ * engine change, not a per-camera one. Non-zero here always comes with
16595
+ * `complete: false`.
16596
+ */
16597
+ noCapableNode: number(),
15581
16598
  failed: number(),
15582
16599
  /** Set once a pass ends: true only when EVERYTHING was covered. */
15583
16600
  complete: boolean().nullable(),
@@ -15649,7 +16666,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15649
16666
  }), {
15650
16667
  kind: "mutation",
15651
16668
  auth: "admin"
15652
- }), method(object({}), EventStoreFootprintSchema, {
16669
+ }), method(object({
16670
+ /** Log/audit scope only — the trackId is globally unique on its own. */
16671
+ deviceId: number(),
16672
+ trackId: string(),
16673
+ flags: TrackFlagsPatchSchema
16674
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15653
16675
  kind: "query",
15654
16676
  auth: "admin"
15655
16677
  }), method(object({
@@ -16411,6 +17433,53 @@ var DetailResultSchema = object({
16411
17433
  nativeFaceShortSidePx: number().optional()
16412
17434
  });
16413
17435
  /**
17436
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
17437
+ *
17438
+ * A refusal is a first-class answer, not an error, because the caller's next
17439
+ * move depends on WHICH one it is — and because "the pass produced nothing"
17440
+ * must never be reachable without a named, counted cause. The two tiers:
17441
+ *
17442
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
17443
+ * never serve this (step, model) pair. The caller drops it from its rotation
17444
+ * and retries the same work elsewhere; nothing about the work changes.
17445
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
17446
+ * fine, this one request is not. Retrying it on another node would only
17447
+ * spread the same failure.
17448
+ */
17449
+ var StatelessStepRefusalSchema = _enum([
17450
+ "unknown-step",
17451
+ "model-not-servable",
17452
+ "unreadable-frame",
17453
+ "execution-failed"
17454
+ ]);
17455
+ /**
17456
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
17457
+ * result, because `null` is exactly what made the camera-bound detail path
17458
+ * unable to tell "refused" from "never asked".
17459
+ */
17460
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
17461
+ kind: literal("ran"),
17462
+ /** The node that actually executed it — the pin, echoed back for the log. */
17463
+ nodeId: string(),
17464
+ /**
17465
+ * The model the step ran with.
17466
+ *
17467
+ * The node verified this exact id has a build for the format it dispatched
17468
+ * on BEFORE running, so the executor's format resolution returns it
17469
+ * unchanged. A caller that pinned a model must compare this field and
17470
+ * treat a mismatch as a refusal — the whole point of the pin is that a
17471
+ * pass writes one feature space.
17472
+ */
17473
+ modelId: string(),
17474
+ details: array(DetailResultSchema)
17475
+ }), object({
17476
+ kind: literal("refused"),
17477
+ nodeId: string(),
17478
+ reason: StatelessStepRefusalSchema,
17479
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
17480
+ detail: string()
17481
+ })]);
17482
+ /**
16414
17483
  * Per-camera tunable ranges + defaults. Single source of truth used
16415
17484
  * by both the Zod data schema (validation + default fallback) and
16416
17485
  * the device settings UI (slider min/max/step). Touch one place and
@@ -16855,7 +17924,77 @@ var pipelineRunnerCapability = {
16855
17924
  cropJpeg: string().optional(),
16856
17925
  parent: DetailParentSchema,
16857
17926
  steps: array(string()).optional()
16858
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" })
17927
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }),
17928
+ /**
17929
+ * Run ONE enrichment step against caller-supplied pixels, with NO camera
17930
+ * session — no attach, no frame handle, no device affinity.
17931
+ *
17932
+ * ## Why this exists next to `runDetailSubtree` and not inside it
17933
+ *
17934
+ * `runDetailSubtree` resolves its step tree from the LIVE ATTACH STATE. That
17935
+ * is correct for it: its reason to exist is a frame HANDLE, and a handle is
17936
+ * only resolvable while a decode session is open. But it makes the method
17937
+ * useless to anything walking history — cameras run `detectionMode:
17938
+ * 'on-motion'` and detach ~30 s after motion stops, so a fleet-wide
17939
+ * embedding rebuild found most cameras detached and rebuilt only whatever
17940
+ * happened to be attached at that second (measured 2026-08-06: 1125 tracks
17941
+ * scanned, 121 rebuilt, 601 refused as "camera is not attached").
17942
+ *
17943
+ * A caller that ships its own pixels needs none of that machinery. So this
17944
+ * method takes the three things a step actually needs — an image, a box, a
17945
+ * step id — resolves the step from the CATALOG rather than from an
17946
+ * attachment, and runs it on whichever node the caller pinned.
17947
+ *
17948
+ * ## What it deliberately keeps
17949
+ *
17950
+ * The crop rectangle is derived by the SAME `deriveDetailCropRect` call the
17951
+ * live detail plane uses, from the same cluster-wide convention
17952
+ * ([D52](../../../docs/decisions/adr-0052.md)). `frameJpeg` is a FULL frame,
17953
+ * never a pre-cut tile, for exactly the reason `runDetailSubtree` documents:
17954
+ * a caller that cuts is a second feature space.
17955
+ *
17956
+ * ## What it deliberately drops
17957
+ *
17958
+ * No device jump and no `deviceKey`: the jump roster comes from the attach
17959
+ * config, which does not exist here. The step runs on the node's default
17960
+ * engine. This is a maintenance path, not a latency-sensitive one.
17961
+ *
17962
+ * ## Routing
17963
+ *
17964
+ * NOT device-bound. `sourceDeviceId` is DIAGNOSTIC — the camera whose track
17965
+ * these pixels came from, so every log line on the executing node can carry
17966
+ * `tags: { deviceId }`. It is deliberately not named `deviceId`: that field
17967
+ * is a routing hint that would send the call to the camera's owning node,
17968
+ * which is the placement constraint this method exists to remove. Callers
17969
+ * choose the node with `nodePin(nodeId)`; unpinned, an unowned call is
17970
+ * served hub-in-process like any other singleton.
17971
+ */
17972
+ runStatelessStep: method(object({
17973
+ /** Catalog step id, e.g. `clip-embedding`. */
17974
+ stepId: string(),
17975
+ /**
17976
+ * REQUIRED model pin. The node runs this exact model or refuses with
17977
+ * `model-not-servable` — it never substitutes a format default, because
17978
+ * a fleet pass that round-robins across nodes would then fill one index
17979
+ * from several encoders.
17980
+ */
17981
+ modelId: string(),
17982
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
17983
+ frameJpeg: string(),
17984
+ /**
17985
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
17986
+ * purpose: the caller stores boxes against a downscaled analysis frame
17987
+ * while the stored key frame is native-resolution, and the only side
17988
+ * that reliably knows the image's pixel dimensions is the side that
17989
+ * decodes it. Denormalising here removes a second reader of the
17990
+ * dimensions and the class of mismatch that comes with it.
17991
+ */
17992
+ bbox: NativeCropBboxSchema,
17993
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
17994
+ className: string(),
17995
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
17996
+ sourceDeviceId: number()
17997
+ }), RunStatelessStepResultSchema, { kind: "mutation" })
16859
17998
  }
16860
17999
  };
16861
18000
  var CameraPipelineConfigSchema = object({
@@ -17155,6 +18294,20 @@ var CameraStatusSchema = object({
17155
18294
  detection: CameraDetectionStatusSchema.nullable(),
17156
18295
  audio: CameraAudioStatusSchema.nullable(),
17157
18296
  recording: CameraRecordingStatusSchema.nullable(),
18297
+ /**
18298
+ * Per-camera function switches an OPERATOR has turned off
18299
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
18300
+ *
18301
+ * This is the difference between DISABLED and BROKEN. A camera whose
18302
+ * `detection` block reports zero fps and whose `switchedOff` contains
18303
+ * `'object-detection'` was switched off by a person; the same camera with an
18304
+ * empty list is failing. Every status surface must render the two
18305
+ * differently — a quiet camera that looks identical to a dead one is the
18306
+ * silence-reads-as-never-happened trap this repo keeps paying for.
18307
+ *
18308
+ * Empty when nothing is off. Never contains a switch no provider offers.
18309
+ */
18310
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
17158
18311
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
17159
18312
  fetchedAt: number()
17160
18313
  });
@@ -17323,7 +18476,14 @@ method(object({
17323
18476
  }), method(object({
17324
18477
  deviceId: number(),
17325
18478
  agentNodeId: string().optional()
17326
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
18479
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
18480
+ deviceId: number(),
18481
+ switchId: CameraSwitchIdSchema,
18482
+ enabled: boolean()
18483
+ }), CameraSwitchGroupSchema, {
18484
+ kind: "mutation",
18485
+ auth: "admin"
18486
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
17327
18487
  name: string(),
17328
18488
  description: string().optional(),
17329
18489
  config: CameraPipelineConfigSchema
@@ -17646,9 +18806,15 @@ DeviceType.Camera, method(object({
17646
18806
  * Bypass the cache freshness check and fetch directly from the
17647
18807
  * native (or stream-broker fallback). Triggered by the UI's
17648
18808
  * "refresh" button so an operator can force a fresh frame
17649
- * even when the cache is well within `snapshotMaxAgeMs`.
17650
- * On battery cams this WILL wake the camera — accept the
17651
- * 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.
17652
18818
  */
17653
18819
  force: boolean().optional()
17654
18820
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -22025,12 +23191,30 @@ object({
22025
23191
  });
22026
23192
  DeviceType.Sensor;
22027
23193
  /**
22028
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
22029
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
22030
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
22031
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
22032
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
22033
- * 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.
22034
23218
  */
22035
23219
  /** A privacy-mask region's geometry — rectangle or free polygon. */
22036
23220
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -22046,16 +23230,40 @@ object({
22046
23230
  enabled: boolean(),
22047
23231
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
22048
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(),
22049
23248
  lastFetchedAt: number()
22050
23249
  });
22051
- /** Per-camera availability. */
23250
+ /** Per-camera availability. Probed, never assumed from the model name. */
22052
23251
  var PrivacyMaskOptionsSchema = object({
22053
23252
  /** Maximum number of supported zones. */
22054
23253
  maxRegions: number(),
22055
23254
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
22056
23255
  supportedShapes: array(MaskShapeKindSchema),
22057
23256
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
22058
- 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()
22059
23267
  });
22060
23268
  /** Partial change — every field optional. */
22061
23269
  var PrivacyMaskPatchSchema = object({
@@ -22068,6 +23276,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
22068
23276
  }), _void(), {
22069
23277
  kind: "mutation",
22070
23278
  auth: "admin"
23279
+ }), method(object({
23280
+ deviceId: number(),
23281
+ enabled: boolean()
23282
+ }), _void(), {
23283
+ kind: "mutation",
23284
+ auth: "admin"
22071
23285
  });
22072
23286
  var PtzPresetSchema = object({
22073
23287
  id: string(),
@@ -22277,6 +23491,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
22277
23491
  })]);
22278
23492
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
22279
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
+ });
22280
23509
  var recordingCapability = {
22281
23510
  name: "recording",
22282
23511
  scope: "system",
@@ -22344,6 +23573,18 @@ var recordingCapability = {
22344
23573
  kind: "query",
22345
23574
  auth: "admin"
22346
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
+ }),
22347
23588
  setDeviceConfig: method(object({
22348
23589
  deviceId: number(),
22349
23590
  config: RecordingConfigSchema
@@ -22895,6 +24136,16 @@ var StreamProfileConfigSchema = object({
22895
24136
  "baseline"
22896
24137
  ]).optional(),
22897
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
+ */
22898
24149
  audio: boolean().optional()
22899
24150
  });
22900
24151
  object({
@@ -22935,7 +24186,13 @@ var StreamParamsOptionsSchema = object({
22935
24186
  ext: StreamProfileOptionsSchema.optional()
22936
24187
  });
22937
24188
  /** A partial change to one profile — every field optional; a provider
22938
- * 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`. */
22939
24196
  var StreamProfilePatchSchema = object({
22940
24197
  width: number().optional(),
22941
24198
  height: number().optional(),
@@ -22948,8 +24205,7 @@ var StreamProfilePatchSchema = object({
22948
24205
  "main",
22949
24206
  "baseline"
22950
24207
  ]).optional(),
22951
- gop: number().optional(),
22952
- audio: boolean().optional()
24208
+ gop: number().optional()
22953
24209
  });
22954
24210
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
22955
24211
  deviceId: number(),
@@ -26472,6 +27728,12 @@ Object.freeze({
26472
27728
  addonId: null,
26473
27729
  access: "view"
26474
27730
  },
27731
+ "notificationRules.listDeviceMutes": {
27732
+ capName: "notification-rules",
27733
+ capScope: "system",
27734
+ addonId: null,
27735
+ access: "view"
27736
+ },
26475
27737
  "notificationRules.listRules": {
26476
27738
  capName: "notification-rules",
26477
27739
  capScope: "system",
@@ -26490,6 +27752,12 @@ Object.freeze({
26490
27752
  addonId: null,
26491
27753
  access: "create"
26492
27754
  },
27755
+ "notificationRules.setDeviceMuted": {
27756
+ capName: "notification-rules",
27757
+ capScope: "system",
27758
+ addonId: null,
27759
+ access: "create"
27760
+ },
26493
27761
  "notificationRules.setRuleEnabled": {
26494
27762
  capName: "notification-rules",
26495
27763
  capScope: "system",
@@ -26766,6 +28034,12 @@ Object.freeze({
26766
28034
  addonId: null,
26767
28035
  access: "view"
26768
28036
  },
28037
+ "pipelineAnalytics.setTrackFlags": {
28038
+ capName: "pipeline-analytics",
28039
+ capScope: "device",
28040
+ addonId: null,
28041
+ access: "create"
28042
+ },
26769
28043
  "pipelineAnalytics.wipeAllAnalytics": {
26770
28044
  capName: "pipeline-analytics",
26771
28045
  capScope: "device",
@@ -27072,6 +28346,12 @@ Object.freeze({
27072
28346
  addonId: null,
27073
28347
  access: "view"
27074
28348
  },
28349
+ "pipelineOrchestrator.getCameraSwitches": {
28350
+ capName: "pipeline-orchestrator",
28351
+ capScope: "system",
28352
+ addonId: null,
28353
+ access: "view"
28354
+ },
27075
28355
  "pipelineOrchestrator.getCapabilityBindings": {
27076
28356
  capName: "pipeline-orchestrator",
27077
28357
  capScope: "system",
@@ -27204,6 +28484,12 @@ Object.freeze({
27204
28484
  addonId: null,
27205
28485
  access: "create"
27206
28486
  },
28487
+ "pipelineOrchestrator.setCameraSwitch": {
28488
+ capName: "pipeline-orchestrator",
28489
+ capScope: "system",
28490
+ addonId: null,
28491
+ access: "create"
28492
+ },
27207
28493
  "pipelineOrchestrator.setCapabilityBinding": {
27208
28494
  capName: "pipeline-orchestrator",
27209
28495
  capScope: "system",
@@ -27294,6 +28580,12 @@ Object.freeze({
27294
28580
  addonId: null,
27295
28581
  access: "create"
27296
28582
  },
28583
+ "pipelineRunner.runStatelessStep": {
28584
+ capName: "pipeline-runner",
28585
+ capScope: "system",
28586
+ addonId: null,
28587
+ access: "create"
28588
+ },
27297
28589
  "plateGallery.assignPlate": {
27298
28590
  capName: "plate-gallery",
27299
28591
  capScope: "system",
@@ -27426,6 +28718,12 @@ Object.freeze({
27426
28718
  addonId: null,
27427
28719
  access: "view"
27428
28720
  },
28721
+ "privacyMask.setAudioEnabled": {
28722
+ capName: "privacy-mask",
28723
+ capScope: "device",
28724
+ addonId: null,
28725
+ access: "create"
28726
+ },
27429
28727
  "privacyMask.setMask": {
27430
28728
  capName: "privacy-mask",
27431
28729
  capScope: "device",
@@ -27594,6 +28892,12 @@ Object.freeze({
27594
28892
  addonId: null,
27595
28893
  access: "create"
27596
28894
  },
28895
+ "recording.readGopBytes": {
28896
+ capName: "recording",
28897
+ capScope: "system",
28898
+ addonId: null,
28899
+ access: "view"
28900
+ },
27597
28901
  "recording.readSegmentBytes": {
27598
28902
  capName: "recording",
27599
28903
  capScope: "system",
@@ -28110,6 +29414,12 @@ Object.freeze({
28110
29414
  addonId: null,
28111
29415
  access: "create"
28112
29416
  },
29417
+ "streamBroker.acquireEgressTranscode": {
29418
+ capName: "stream-broker",
29419
+ capScope: "system",
29420
+ addonId: null,
29421
+ access: "create"
29422
+ },
28113
29423
  "streamBroker.assignProfile": {
28114
29424
  capName: "stream-broker",
28115
29425
  capScope: "system",
@@ -28218,6 +29528,12 @@ Object.freeze({
28218
29528
  addonId: null,
28219
29529
  access: "create"
28220
29530
  },
29531
+ "streamBroker.releaseEgressTranscode": {
29532
+ capName: "stream-broker",
29533
+ capScope: "system",
29534
+ addonId: null,
29535
+ access: "create"
29536
+ },
28221
29537
  "streamBroker.releaseStreamWithCodec": {
28222
29538
  capName: "stream-broker",
28223
29539
  capScope: "system",
@@ -29216,7 +30532,7 @@ function readDetailCropConvention(config) {
29216
30532
  square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
29217
30533
  };
29218
30534
  }
29219
- function isHydratedField(entry) {
30535
+ function isHydratedField$1(entry) {
29220
30536
  return typeof entry === "object" && entry !== null && "key" in entry;
29221
30537
  }
29222
30538
  /**
@@ -29231,7 +30547,7 @@ function pickDetailCropConvention(view) {
29231
30547
  if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
29232
30548
  const flat = {};
29233
30549
  for (const section of view.sections) for (const entry of section.fields) {
29234
- if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
30550
+ if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
29235
30551
  if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
29236
30552
  }
29237
30553
  return readDetailCropConvention(flat);
@@ -29311,6 +30627,178 @@ function slideInsideFrame(rect, frameWidth, frameHeight) {
29311
30627
  h
29312
30628
  };
29313
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
+ }
29314
30802
  var AUTO = {
29315
30803
  value: "auto",
29316
30804
  label: "Auto"
@@ -29528,4 +31016,4 @@ function enumerateInferenceDevices(hw) {
29528
31016
  return out;
29529
31017
  }
29530
31018
  //#endregion
29531
- export { nodePin as $, mapAudioLabelToMacro as A, streamBrokerCapability as B, defineCustomActions as C, enumerateInferenceDevices as D, detectionPipelineCapability as E, pipelineRunnerCapability as F, CAM_PROFILE_ORDER as G, webrtcSessionCapability as H, recordingCapability as I, createEvent as J, DeviceFeature as K, recordingExportCapability as L, motionDetectionCapability as M, pickDetailCropConvention as N, evaluateZoneRules as O, pipelineExecutorCapability as P, makeSourceBrokerId as Q, runtimeDevices as R, defaultDeviceFor as S, deriveRecordingMode as T, errMsg as U, supportedRuntimes as V, BaseAddon as W, isEvent as X, hydrateSchema as Y, makeProfileBrokerId as Z, addonWidgetsSourceCapability as _, COCO_TO_MACRO as a, array as at, cameraStreamsCapability as b, DEVICE_BACKEND_TO_FORMAT as c, lazy as ct, ExportRecordSchema as d, object as dt, parseJsonUnknown as et, HF_BASE_URL as f, record as ft, YAMNET_TO_MACRO as g, RingBuffer as h, EventCategory as ht, COCO_80_LABELS as i, _enum as it, maskUrlCredentials as j, hfModelUrl as k, EVENT_PAD_MS as l, literal as lt, RecordingConfigSchema as m, union as mt, AUDIO_BACKEND_CHOICES as n, selectAssignedProfileSlots as nt, DEFAULT_AUDIO_ANALYZER_CONFIG as o, boolean as ot, OpsLogEntrySchema as p, string as pt, DeviceType as q, AUDIO_MACRO_LABELS as r, sleep as rt, DEFAULT_DETAIL_CROP_CONVENTION as s, discriminatedUnion as st, APPLE_SA_TO_MACRO as t, parseProfileBrokerId as tt, EncodeProfileSchema as u, number as ut, audioAnalysisCapability as v, deriveDetailCropRect as w, customAction as x, audioAnalyzerCapability as y, storageEvictableCapability 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 };