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