@camstack/addon-pipeline 1.2.227 → 1.2.229

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 +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-C1etqCJk.js → default-detection-model-C7fsHtHQ.js} +1 -1
  4. package/dist/{default-detection-model-CNwum1uC.mjs → default-detection-model-DC4yVaFM.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-d1K9t4mU.mjs → dist-DTPYOq7J.mjs} +440 -19
  8. package/dist/{dist-BGdDektN.js → dist-p5lZW8pr.js} +445 -18
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-CQ5yofm2.js → node-CGVUk5S9.js} +1 -1
  12. package/dist/{node-CpckZhmU.mjs → node-Cz92fVGW.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +4 -4
  15. package/dist/{process-memory-iUDS4M_G.mjs → process-memory-BwijtYhM.mjs} +1 -1
  16. package/dist/{process-memory-D2NCFwt_.js → process-memory-COypUeEc.js} +1 -1
  17. package/dist/recorder/index.js +4 -3
  18. package/dist/recorder/index.mjs +4 -3
  19. package/dist/{restream-intent-B1Difcnq.js → restream-intent-C4adO0P8.js} +5 -0
  20. package/dist/{restream-intent-DWe2kInY.mjs → restream-intent-MeJhGCK9.mjs} +5 -0
  21. package/dist/{segment-demux-js-Dk_Pvocs.js → segment-demux-js-B-yzHYVc.js} +1 -1
  22. package/dist/{segment-demux-js-iVyZ1H9j.mjs → segment-demux-js-CyaNDtrw.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-7qCmXLC3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B9KO-igY.mjs} +3 -3
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bc-sSkWV.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-mnvO611B.mjs} +1 -1
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CUMGe_Dp.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-JoG-yDpV.mjs} +1 -1
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-D6wBZr7_.mjs → hostInit-CDwsQyIR.mjs} +3 -3
  30. package/dist/stream-broker/index.js +1357 -53
  31. package/dist/stream-broker/index.mjs +1357 -53
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +1 -1
  34. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  35. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  36. package/python/__pycache__/test_inference_pool_ov_requests.cpython-314.pyc +0 -0
  37. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  38. package/python/inference_pool.py +39 -9
  39. package/python/test_inference_pool_ov_requests.py +79 -0
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as isEvent, A as RingBuffer, Dt as resolveEgressDecodeHwAccel, E as RATE_CONTROL_TIGHT, Gt as BaseAddon, Ht as invocationFromEncodeProfile, I as batteryCapability, J as egressTranscodeSharingKey, Jt as DeviceType, Kt as CAM_PROFILE_ORDER, L as cameraStreamsCapability, Lt as webrtcSessionCapability, M as addonWidgetsSourceCapability, Pt as streamBrokerCapability, R as createHwAccelCache, Rt as errMsg, T as RATE_CONTROL_RELAXED, Ut as isSoftwareDecode, V as declareLogChannel, Vt as buildFfmpegArgs$1, W as deriveBatteryPresence, Wt as BOOT_RECOVERY_BACKOFF_MS, Xt as createEvent, Y as egressTransportFromRequest, Zt as expandAudioChunkToF32le, _n as string, cn as array, en as makeProfileBrokerId, fn as literal, gn as record, h as EncodeProfileSchema, i as BatteryStatusSchema, in as parseProfileBrokerId, ln as boolean, lt as maskUrlCredentials, mn as object, nn as nodePin, ot as loadContributionCapability, pn as number, qt as DeviceFeature, sn as _enum, st as logChannelsCapability, tn as makeSourceBrokerId, un as discriminatedUnion, v as G711_SCALE_CORRECTION_DB, vn as union, yn as EventCategory, z as createLogChannelsProvider, zt as AUDIO_PRESETS } from "../dist-d1K9t4mU.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-CpckZhmU.mjs";
4
- import { c as PREBUFFER_MAX_MS, d as resolvePrebuffer, i as parseRestreamPath, l as capPreRollRequestMs, n as NO_DECLARATION, u as prebufferRetentionMs } from "../restream-intent-DWe2kInY.mjs";
2
+ import { A as RingBuffer, Bt as AUDIO_PRESETS, Ct as rankStreams, E as RATE_CONTROL_TIGHT, Ft as streamBrokerCapability, Gt as BOOT_RECOVERY_BACKOFF_MS, Ht as buildFfmpegArgs$1, I as batteryCapability, J as egressTranscodeSharingKey, Jt as DeviceFeature, Kt as BaseAddon, L as cameraStreamsCapability, M as addonWidgetsSourceCapability, Ot as resolveEgressDecodeHwAccel, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as webrtcSessionCapability, T as RATE_CONTROL_RELAXED, Ut as invocationFromEncodeProfile, V as declareLogChannel, W as deriveBatteryPresence, Wt as isSoftwareDecode, Y as egressTransportFromRequest, Yt as DeviceType, Zt as createEvent, _n as record, an as parseProfileBrokerId, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, ln as array, lt as maskUrlCredentials, mn as number, nn as makeSourceBrokerId, ot as loadContributionCapability, pn as literal, qt as CAM_PROFILE_ORDER, rn as nodePin, st as logChannelsCapability, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, yn as union, z as createLogChannelsProvider, zt as errMsg } from "../dist-DTPYOq7J.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Cz92fVGW.mjs";
4
+ import { c as PREBUFFER_MAX_MS, d as resolvePrebuffer, i as parseRestreamPath, l as capPreRollRequestMs, n as NO_DECLARATION, u as prebufferRetentionMs } from "../restream-intent-MeJhGCK9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-iVyZ1H9j.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-CyaNDtrw.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { spawn } from "node:child_process";
18
18
  import { readFile } from "node:fs/promises";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { renderCourtesyJpeg, renderCourtesyOverlayJpeg } from "@camstack/system/addon-utils";
20
21
  import * as net$1 from "node:net";
21
22
  import net from "node:net";
22
23
  import { createSocket } from "node:dgram";
@@ -556,6 +557,201 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
556
557
  return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
557
558
  }
558
559
  //#endregion
560
+ //#region src/stream-broker/stream-broker/egress-source-readiness.ts
561
+ /**
562
+ * Whether waiting on this source can achieve anything — the three-way answer
563
+ * `waitForSourceMedia` consumes.
564
+ *
565
+ * The subtlety that cost a measured 15 s per cold Alexa open on 2026-09-13: a
566
+ * lazy publisher materialises because someone DIALS it. The source broker's own
567
+ * `redial` / `kickReconnect` both refuse without demand, by design — a broker
568
+ * with no consumer must not wake a camera. So for a cold, idle source the
569
+ * ffmpeg child IS the demand, and withholding it waits for media that only
570
+ * starts because of the thing being withheld. The live log of that mistake:
571
+ *
572
+ * 19:04:27 waited for the SOURCE {outcome=timeout waitedMs=15005 ready=false}
573
+ * 19:04:30 the child died before the decoder — retrying in SOFTWARE
574
+ * 19:04:32 rfc4571: publisher materialized {materializeMs=5798}
575
+ *
576
+ * Fifteen seconds of nothing, then the spawn, then the wake it had triggered.
577
+ *
578
+ * So: wait only when a materialize is ALREADY in flight — a second consumer
579
+ * arriving while the first is waking the camera, which is the case the wait was
580
+ * really for. A cold idle source answers `null`: not "no media", but "waiting
581
+ * here cannot help", and the caller spawns at once.
582
+ */
583
+ function sourceWaitState(input) {
584
+ if (input.hasMedia) return true;
585
+ if (input.materializing) return false;
586
+ return null;
587
+ }
588
+ async function waitForSourceMedia(input) {
589
+ const started = input.now();
590
+ const first = input.hasMedia();
591
+ if (first === null) return {
592
+ ready: false,
593
+ waitedMs: 0,
594
+ outcome: "unknown"
595
+ };
596
+ if (first) return {
597
+ ready: true,
598
+ waitedMs: 0,
599
+ outcome: "live"
600
+ };
601
+ for (;;) {
602
+ const elapsed = input.now() - started;
603
+ if (elapsed >= input.timeoutMs) return {
604
+ ready: false,
605
+ waitedMs: elapsed,
606
+ outcome: "timeout"
607
+ };
608
+ await input.sleep(input.pollMs);
609
+ const state = input.hasMedia();
610
+ const waitedMs = input.now() - started;
611
+ if (state === null) return {
612
+ ready: false,
613
+ waitedMs,
614
+ outcome: "unknown"
615
+ };
616
+ if (state) return {
617
+ ready: true,
618
+ waitedMs,
619
+ outcome: "materialized"
620
+ };
621
+ }
622
+ }
623
+ //#endregion
624
+ //#region src/stream-broker/stream-broker/stream-health-scope.ts
625
+ /**
626
+ * WHICH streams a health transition is worth telling the operator about.
627
+ *
628
+ * `stream.online` / `stream.offline` are operator-facing: they reach the
629
+ * notification rules and land on a phone. So they must be facts about a
630
+ * CAMERA, and an egress stream is not one — it is the private pipe an exporter
631
+ * asks the broker to build for one consumer session and drops when that session
632
+ * ends. Its whole life is an implementation detail of somebody watching.
633
+ *
634
+ * Measured on this hub 2026-09-13: every Alexa session on the baby monitor
635
+ * produced a `stream.online` and a `stream.offline` pair against
636
+ * `640/egress:8e96e97a…`, and the operator read them as the battery camera
637
+ * flapping during its wake. Nothing was flapping — that is just what an egress
638
+ * pipe does, once per session, by design.
639
+ *
640
+ * The doctrine already existed one layer down: an egress is published with
641
+ * `autoEligible: false` so "a private egress can never become a camera's
642
+ * published stream". It simply had not reached the event path.
643
+ *
644
+ * A DERIVED stream is different and stays in scope: the operator configured it,
645
+ * it is meant to persist, and its going offline is news.
646
+ */
647
+ /** Private, per-session egress pipes. See {@link healthEventIsOperatorFacing}. */
648
+ var PRIVATE_STREAM_PREFIXES = ["egress:"];
649
+ function healthEventIsOperatorFacing(camStreamId) {
650
+ return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
651
+ }
652
+ //#endregion
653
+ //#region src/stream-broker/stream-broker/prestream-eligibility.ts
654
+ function prestreamEligible(input) {
655
+ if (input.batteryOperated === null || input.declaresSignals === null) return {
656
+ eligible: false,
657
+ reason: "unknown"
658
+ };
659
+ if (!input.batteryOperated) return {
660
+ eligible: false,
661
+ reason: "not-battery"
662
+ };
663
+ if (!input.declaresSignals) return {
664
+ eligible: false,
665
+ reason: "no-signals-cap"
666
+ };
667
+ return { eligible: true };
668
+ }
669
+ /**
670
+ * Ceiling on ONE run, measured from its first signal.
671
+ *
672
+ * Five minutes. Long enough that a genuine sustained event — a person moving
673
+ * around a garden — is covered end to end; short enough that a sensor stuck
674
+ * ON costs one five-minute wake rather than a night.
675
+ */
676
+ var WAKE_HOLD_MAX_RUN_MS = 5 * 6e4;
677
+ /**
678
+ * How long a camera is left alone after a run hit the ceiling.
679
+ *
680
+ * Without this the ceiling is cosmetic, and the test that found it says why: a
681
+ * run ends exactly AT the ceiling, so the very next signal from a sensor that
682
+ * never stops re-arms a fresh five minutes. Five minutes awake, an instant
683
+ * asleep, five minutes awake — all night, which is the precise failure the
684
+ * ceiling was written to prevent.
685
+ *
686
+ * So a capped run buys silence. Ten minutes: long enough that a stuck sensor
687
+ * costs a third of its time rather than all of it, short enough that a genuine
688
+ * long event — someone working in the garden — resumes within a few minutes
689
+ * instead of being locked out for an hour.
690
+ *
691
+ * Signals during the quarantine are REFUSED and counted, not queued: a queue
692
+ * would fire them all the moment it lifts, which is the same drain arriving
693
+ * late.
694
+ */
695
+ var WAKE_QUARANTINE_AFTER_CAP_MS = 10 * 6e4;
696
+ var NO_HOLD = {
697
+ runStartedAt: 0,
698
+ until: 0,
699
+ signals: 0,
700
+ quarantinedUntil: 0
701
+ };
702
+ /**
703
+ * Absorb one signal.
704
+ *
705
+ * A signal arriving with the run already at its ceiling does NOT extend it —
706
+ * and does not start a new one either, because a new run measured from now
707
+ * would be the same unbounded extension wearing a different name. It is
708
+ * reported as `capped` so the operator can see a sensor that will not stop.
709
+ */
710
+ function absorbSignal(input) {
711
+ const { current, now, holdMs } = input;
712
+ if (!(current.until > now && current.runStartedAt > 0)) {
713
+ if (current.quarantinedUntil > now) return {
714
+ hold: {
715
+ ...current,
716
+ signals: current.signals + 1
717
+ },
718
+ outcome: "quarantined"
719
+ };
720
+ return {
721
+ hold: {
722
+ runStartedAt: now,
723
+ until: now + holdMs,
724
+ signals: 1,
725
+ quarantinedUntil: 0
726
+ },
727
+ outcome: "armed"
728
+ };
729
+ }
730
+ if (now - current.runStartedAt >= 3e5) return {
731
+ hold: {
732
+ ...current,
733
+ signals: current.signals + 1,
734
+ quarantinedUntil: now + WAKE_QUARANTINE_AFTER_CAP_MS
735
+ },
736
+ outcome: "capped"
737
+ };
738
+ const ceiling = current.runStartedAt + WAKE_HOLD_MAX_RUN_MS;
739
+ const until = Math.min(now + holdMs, ceiling);
740
+ return {
741
+ hold: {
742
+ ...current,
743
+ until,
744
+ signals: current.signals + 1,
745
+ ...until === ceiling ? { quarantinedUntil: ceiling + WAKE_QUARANTINE_AFTER_CAP_MS } : {}
746
+ },
747
+ outcome: "extended"
748
+ };
749
+ }
750
+ /** Whether the hold still keeps the camera awake at `now`. */
751
+ function holdIsLive(hold, now) {
752
+ return hold.until > now;
753
+ }
754
+ //#endregion
559
755
  //#region src/stream-broker/battery-wake-on-play.ts
560
756
  /**
561
757
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -1244,7 +1440,7 @@ var ADTS_SAMPLE_RATES = [
1244
1440
  /** AAC-LC AudioObjectType for the 2-byte AudioSpecificConfig. */
1245
1441
  var AAC_LC_OBJECT_TYPE = 2;
1246
1442
  /** Grace before escalating SIGTERM → SIGKILL on a lingering child. */
1247
- var KILL_GRACE_MS = 500;
1443
+ var KILL_GRACE_MS$1 = 500;
1248
1444
  /**
1249
1445
  * Parse the 7-byte ADTS header of an AAC frame into the audio-init params the
1250
1446
  * broker's push ingress needs. Returns `null` when the buffer is too short or
@@ -1438,7 +1634,7 @@ var FfmpegPushReader = class {
1438
1634
  if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
1439
1635
  child.kill("SIGKILL");
1440
1636
  } catch {}
1441
- }, KILL_GRACE_MS).unref?.();
1637
+ }, KILL_GRACE_MS$1).unref?.();
1442
1638
  }
1443
1639
  };
1444
1640
  //#endregion
@@ -2890,6 +3086,26 @@ var RtspRestreamer = class {
2890
3086
  getSessionCount() {
2891
3087
  return this.sessions.size;
2892
3088
  }
3089
+ /**
3090
+ * Sessions that count as LIVE INTENT — everything except a prestream dial.
3091
+ *
3092
+ * The split exists so a Frigate-style puller can hold a session open for ever
3093
+ * against a battery camera without keeping it awake (D173). For a broker with
3094
+ * no prestream session this equals {@link getSessionCount} exactly, which is
3095
+ * why nothing else changed behaviour.
3096
+ */
3097
+ getLiveIntentSessionCount() {
3098
+ let n = 0;
3099
+ for (const session of this.sessions.values()) if (!session.isPrestreamIntent()) n += 1;
3100
+ return n;
3101
+ }
3102
+ /** Sessions asking only for the picture. Drives the prestream pump, nothing
3103
+ * else — it must never reach a dial decision. */
3104
+ getPrestreamSessionCount() {
3105
+ let n = 0;
3106
+ for (const session of this.sessions.values()) if (session.isPrestreamIntent()) n += 1;
3107
+ return n;
3108
+ }
2893
3109
  /** Per-session diagnostic snapshots consumed by `StreamBroker.listClients`. */
2894
3110
  listSessionInfos() {
2895
3111
  return [...this.sessions.values()].map((s) => s.getInfo());
@@ -3642,6 +3858,16 @@ var RtspSession = class {
3642
3858
  return this.declaration.intent === "detection";
3643
3859
  }
3644
3860
  /**
3861
+ * Whether this dial asked for the STREAM without asking for the CAMERA.
3862
+ *
3863
+ * The one question the demand split turns on: a prestream session is FED but
3864
+ * is not INTENT, so it never dials a sleeping camera. See
3865
+ * `PRESTREAM_PATH_SUFFIX`.
3866
+ */
3867
+ isPrestreamIntent() {
3868
+ return this.declaration.intent === "prestream";
3869
+ }
3870
+ /**
3645
3871
  * Ms of retained history this session ASKED for on its dial. `0` = none.
3646
3872
  *
3647
3873
  * Read by `RtspRestreamer.servePendingSessionsRtp`, which serves the smaller
@@ -4946,6 +5172,84 @@ function pushDimension(into, constraint, requested, known) {
4946
5172
  });
4947
5173
  }
4948
5174
  //#endregion
5175
+ //#region src/stream-broker/stream-broker/decode-failure-attribution.ts
5176
+ /**
5177
+ * Was the hardware decoder actually at fault?
5178
+ *
5179
+ * The egress child is spawned with `-hwaccel <backend>`, and until now ANY
5180
+ * failure of that first attempt was read as "hardware produced no packets":
5181
+ * the child was retried in software and the backend was quarantined for thirty
5182
+ * minutes, process-wide, for every camera.
5183
+ *
5184
+ * Measured on the hub 2026-09-13 at 17:48, the failure that triggered a
5185
+ * quarantine was this:
5186
+ *
5187
+ * ```
5188
+ * 17:48:43 egress: spawning ffmpeg child (hwaccel vaapi)
5189
+ * 17:48:45 native:sub — dial rate-limited, deferring to backoff window
5190
+ * 17:48:46 child FAILED: Error opening input: Invalid data found …
5191
+ * 17:48:49 rfc4571: publisher materialized {materializeMs=5486}
5192
+ * ```
5193
+ *
5194
+ * The child was started three seconds before anything was publishing to the
5195
+ * RTSP endpoint it was told to read. It never reached a decoder at all — and
5196
+ * the decoder took the blame, and every other camera lost hardware decode for
5197
+ * half an hour.
5198
+ *
5199
+ * ## The direction of the doubt
5200
+ *
5201
+ * An UNRECOGNISED failure still blames the backend. That asymmetry is
5202
+ * deliberate: a wrong quarantine costs software decode, while a missed one
5203
+ * costs every session a three-second death before its own retry — seven times
5204
+ * on one camera in half an hour, which is what put the quarantine here in the
5205
+ * first place. Only a failure we can positively attribute elsewhere is
5206
+ * excused.
5207
+ */
5208
+ /** Shapes only a decode backend produces. */
5209
+ var BACKEND_FAILURE = [
5210
+ /device creation failed/i,
5211
+ /failed to create .*(vaapi|qsv|cuda|videotoolbox|drm) .*device/i,
5212
+ /no va display/i,
5213
+ /hwaccel .*(not|un)(supported|available)/i,
5214
+ /cannot open the .*(hardware|hw) device/i,
5215
+ /impossible to convert between the formats/i
5216
+ ];
5217
+ /**
5218
+ * Shapes that are NOT the decoder's — checked FIRST, because the argv always
5219
+ * names the backend and a substring match on its name would convict it of
5220
+ * every failure ever logged.
5221
+ */
5222
+ var NOT_BACKEND = [
5223
+ /error opening input/i,
5224
+ /invalid data found when processing input/i,
5225
+ /connection refused/i,
5226
+ /connection timed out/i,
5227
+ /server returned \d/i,
5228
+ /no route to host/i,
5229
+ /immediate exit requested/i,
5230
+ /end of file/i
5231
+ ];
5232
+ function isDecodeBackendFailure(error) {
5233
+ if (NOT_BACKEND.some((re) => re.test(error))) return false;
5234
+ if (BACKEND_FAILURE.some((re) => re.test(error))) return true;
5235
+ return true;
5236
+ }
5237
+ //#endregion
5238
+ //#region src/stream-broker/stream-broker/egress-geometry.ts
5239
+ function capAxis(requested, source) {
5240
+ if (requested === void 0) return void 0;
5241
+ if (source === void 0 || source <= 0) return requested;
5242
+ return Math.min(requested, source);
5243
+ }
5244
+ function deliveredGeometry(input) {
5245
+ const width = capAxis(input.requestedWidth, input.sourceWidth);
5246
+ const height = capAxis(input.requestedHeight, input.sourceHeight);
5247
+ return {
5248
+ ...width === void 0 ? {} : { width },
5249
+ ...height === void 0 ? {} : { height }
5250
+ };
5251
+ }
5252
+ //#endregion
4949
5253
  //#region src/stream-broker/stream-broker/rtsp-host.ts
4950
5254
  /**
4951
5255
  * Rewrite the host of an `rtsp://host:port/path` URL in place, preserving the
@@ -5318,6 +5622,28 @@ var TranscodeEgress = class {
5318
5622
  */
5319
5623
  var DEFAULT_RELEASE_GRACE_MS = 5e3;
5320
5624
  /**
5625
+ * How long an egress child waits for its source to carry media before it is
5626
+ * spawned anyway. Sized against the measured cold open of a lazy publisher on
5627
+ * this hub (12.4 s on the wyze bridge, 5.5 s on the camera that produced the
5628
+ * mis-attributed VAAPI death) with margin — and bounded, because the wait is
5629
+ * never a refusal and a source that is genuinely dead must not hold a consumer
5630
+ * past the point where its own client has given up.
5631
+ */
5632
+ var SOURCE_MEDIA_WAIT_MS = 15e3;
5633
+ /** Poll cadence for that wait. Flat, for the reason a materialize poll is. */
5634
+ var SOURCE_MEDIA_POLL_MS = 250;
5635
+ /**
5636
+ * How long to wait before re-dialling a source whose input was not there.
5637
+ *
5638
+ * The first spawn is what WAKES a lazy publisher, and the measured wake on this
5639
+ * hub lands between 2.2 s and 7.8 s (`materializeMs`). Three seconds is inside
5640
+ * that band: long enough that a quick wake is already serving, short enough
5641
+ * that the second attempt still beats the consumer's own patience.
5642
+ */
5643
+ var INPUT_RETRY_DELAY_MS = 3e3;
5644
+ /** After the first three, log one keyframe in this many. */
5645
+ var KEYFRAME_LOG_EVERY = 25;
5646
+ /**
5321
5647
  * How long a push child may produce nothing before it is declared dead. The
5322
5648
  * observed failure EXITS in ~2 s and is caught by the error path long before
5323
5649
  * this; the timeout only covers a child that hangs.
@@ -5483,12 +5809,18 @@ var EgressTranscodeManager = class {
5483
5809
  const decodeHwAccel = await this.opts.resolveDecodeHwAccel(deviceId, request.decodeHwAccel);
5484
5810
  const fragments = delivery.transport === "fragments";
5485
5811
  const publishedCamStreamId = `egress:${key.replace(/^egress:/, "")}`;
5812
+ const delivered = deliveredGeometry({
5813
+ requestedWidth: request.encode.video.width,
5814
+ requestedHeight: request.encode.video.height,
5815
+ sourceWidth: source.facts.width,
5816
+ sourceHeight: source.facts.height
5817
+ });
5486
5818
  const pushSink = fragments || request.publishLocally !== true || this.opts.publishPushStream === void 0 ? null : await this.opts.publishPushStream({
5487
5819
  deviceId,
5488
5820
  camStreamId: publishedCamStreamId,
5489
5821
  codec: request.encode.video.codec,
5490
- width: request.encode.video.width ?? 0,
5491
- height: request.encode.video.height ?? 0
5822
+ width: delivered.width ?? 0,
5823
+ height: delivered.height ?? 0
5492
5824
  });
5493
5825
  if (request.publishLocally === true && pushSink === null && !fragments) this.opts.logger.warn("egress transcode: local publication REFUSED — the consumer must dial the url", {
5494
5826
  tags: { deviceId },
@@ -5502,7 +5834,10 @@ var EgressTranscodeManager = class {
5502
5834
  const fragmentSeconds = fragments ? (delivery.fragmentMs ?? 0) / 1e3 : void 0;
5503
5835
  const { sink: _sink, audioSidecar: _audioSidecar, ...plan } = invocationFromEncodeProfile({
5504
5836
  profile: {
5505
- video: request.encode.video,
5837
+ video: {
5838
+ ...request.encode.video,
5839
+ ...delivered
5840
+ },
5506
5841
  audio: audio.profileAudio
5507
5842
  },
5508
5843
  sourceCodec: source.codec,
@@ -5526,6 +5861,7 @@ var EgressTranscodeManager = class {
5526
5861
  const plane = fragments ? new Fmp4FragmentPlane(this.opts.logger.child("egress-fmp4")) : null;
5527
5862
  const channel = pushSink === null && plane === null ? this.opts.acquireChannel(brokerId) : null;
5528
5863
  const consumerUrl = channel !== null ? channel.consumerUrl : plane !== null ? fragmentsOnlyUrl(key) : pushOnlyUrl(publishedCamStreamId);
5864
+ await this.awaitSourceMedia(deviceId, key, source);
5529
5865
  const child = this.createChild({
5530
5866
  brokerId,
5531
5867
  deviceId,
@@ -5571,8 +5907,9 @@ var EgressTranscodeManager = class {
5571
5907
  throw err;
5572
5908
  }
5573
5909
  const actualHwAccel = child.decodeHwAccel();
5574
- if (decodeHwAccel !== null && !isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
5575
- else if (actualHwAccel !== null && !isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
5910
+ if (decodeHwAccel !== null && !isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) {
5911
+ if (child.hwAccelAtFault?.() !== false) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
5912
+ } else if (actualHwAccel !== null && !isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
5576
5913
  const entry = {
5577
5914
  key,
5578
5915
  deviceId,
@@ -5932,6 +6269,18 @@ var EgressTranscodeManager = class {
5932
6269
  dts: packet.dts
5933
6270
  }
5934
6271
  });
6272
+ } else if (packet.keyframe === true) {
6273
+ keyframesOut += 1;
6274
+ if (keyframesOut <= 3 || keyframesOut % KEYFRAME_LOG_EVERY === 0) logger.info("egress push: keyframe out", {
6275
+ tags: { deviceId },
6276
+ meta: {
6277
+ brokerId: spec.brokerId,
6278
+ camStreamId: spec.publishedCamStreamId,
6279
+ keyframesOut,
6280
+ bytes: packet.data.length,
6281
+ approxRtpPackets: Math.ceil(packet.data.length / 1200)
6282
+ }
6283
+ });
5935
6284
  } else if (packetsOut % PUSH_PACKET_LOG_EVERY === 0) logger.info("egress push: packets still flowing out of the ffmpeg child", {
5936
6285
  tags: { deviceId },
5937
6286
  meta: {
@@ -5989,25 +6338,66 @@ var EgressTranscodeManager = class {
5989
6338
  reader.connect();
5990
6339
  });
5991
6340
  };
6341
+ let keyframesOut = 0;
6342
+ let hwAccelAtFault = true;
5992
6343
  return {
6344
+ hwAccelAtFault: () => hwAccelAtFault,
5993
6345
  start: async () => {
5994
6346
  const requested = spec.invocation.decodeHwAccel;
6347
+ /** Whether a same-decode retry already produced a live child. */
6348
+ let recoveredOnRetry = false;
6349
+ const inputRetryDelayMs = this.opts.inputRetryDelayMs ?? INPUT_RETRY_DELAY_MS;
5995
6350
  try {
5996
6351
  await spawnAttempt(requested);
5997
6352
  } catch (err) {
5998
6353
  if (requested === null || isSoftwareDecode(requested)) throw err;
5999
- logger.warn("egress push: hardware decode produced NO packets — retrying in SOFTWARE", {
6000
- tags: { deviceId },
6001
- meta: {
6002
- brokerId: spec.brokerId,
6003
- camStreamId: spec.publishedCamStreamId,
6004
- decodeHwAccel: requested,
6005
- error: errMsg(err)
6354
+ const blameBackend = isDecodeBackendFailure(errMsg(err));
6355
+ hwAccelAtFault = blameBackend;
6356
+ if (!blameBackend) {
6357
+ logger.info("egress push: the input was not there — retrying the SAME decode", {
6358
+ tags: { deviceId },
6359
+ meta: {
6360
+ brokerId: spec.brokerId,
6361
+ camStreamId: spec.publishedCamStreamId,
6362
+ decodeHwAccel: requested,
6363
+ afterMs: inputRetryDelayMs,
6364
+ error: errMsg(err)
6365
+ }
6366
+ });
6367
+ reader?.destroy();
6368
+ await new Promise((resolve) => {
6369
+ setTimeout(resolve, inputRetryDelayMs).unref?.();
6370
+ });
6371
+ try {
6372
+ await spawnAttempt(requested);
6373
+ recoveredOnRetry = true;
6374
+ } catch (retryErr) {
6375
+ logger.warn("egress push: the input was STILL not there — falling to SOFTWARE", {
6376
+ tags: { deviceId },
6377
+ meta: {
6378
+ brokerId: spec.brokerId,
6379
+ camStreamId: spec.publishedCamStreamId,
6380
+ decodeHwAccel: requested,
6381
+ error: errMsg(retryErr)
6382
+ }
6383
+ });
6006
6384
  }
6007
- });
6008
- reader?.destroy();
6009
- activeHwAccel = null;
6010
- await spawnAttempt(null);
6385
+ }
6386
+ if (!recoveredOnRetry) {
6387
+ logger.warn(blameBackend ? "egress push: hardware decode produced NO packets — retrying in SOFTWARE" : "egress push: the child died before the decoder — retrying in SOFTWARE, backend NOT blamed", {
6388
+ tags: { deviceId },
6389
+ meta: {
6390
+ brokerId: spec.brokerId,
6391
+ camStreamId: spec.publishedCamStreamId,
6392
+ decodeHwAccel: requested,
6393
+ blameBackend,
6394
+ error: errMsg(err)
6395
+ }
6396
+ });
6397
+ reader?.destroy();
6398
+ activeHwAccel = null;
6399
+ await spawnAttempt(null);
6400
+ }
6011
6401
  }
6012
6402
  logger.info("egress transcode: publishing LOCALLY — no rtsp round trip", {
6013
6403
  tags: { deviceId },
@@ -6083,6 +6473,37 @@ var EgressTranscodeManager = class {
6083
6473
  * source dial. A targeted source that has never been dialed is refused rather
6084
6474
  * than silently substituted — the consumer asked for a specific stream.
6085
6475
  */
6476
+ /**
6477
+ * Hold the spawn until the source is carrying media, under a bound.
6478
+ *
6479
+ * Never throws and never refuses: a source that says nothing by the deadline
6480
+ * is spawned against anyway, and the line says what was waited. The only
6481
+ * thing this changes is WHEN ffmpeg opens the loopback.
6482
+ */
6483
+ async awaitSourceMedia(deviceId, key, source) {
6484
+ const hasMedia = this.opts.sourceHasMedia;
6485
+ if (hasMedia === void 0) return;
6486
+ const verdict = await waitForSourceMedia({
6487
+ hasMedia: () => hasMedia(source.brokerId),
6488
+ timeoutMs: SOURCE_MEDIA_WAIT_MS,
6489
+ pollMs: SOURCE_MEDIA_POLL_MS,
6490
+ now: () => Date.now(),
6491
+ sleep: (ms) => new Promise((resolve) => {
6492
+ setTimeout(resolve, ms).unref?.();
6493
+ })
6494
+ });
6495
+ if (verdict.outcome === "live") return;
6496
+ this.opts.logger.info("egress transcode: waited for the SOURCE before spawning", {
6497
+ tags: { deviceId },
6498
+ meta: {
6499
+ pipelineKey: key,
6500
+ sourceBrokerId: source.brokerId,
6501
+ outcome: verdict.outcome,
6502
+ waitedMs: verdict.waitedMs,
6503
+ ready: verdict.ready
6504
+ }
6505
+ });
6506
+ }
6086
6507
  resolveSource(request) {
6087
6508
  const deviceId = request.deviceId;
6088
6509
  const camStreamId = this.resolvedCamStreamId(request);
@@ -6093,6 +6514,7 @@ var EgressTranscodeManager = class {
6093
6514
  const stream = this.opts.cameraStreamLookup(deviceId).find((s) => s.camStreamId === camStreamId);
6094
6515
  const declaredCodec = stream?.codec !== void 0 && stream.codec.length > 0;
6095
6516
  return {
6517
+ brokerId,
6096
6518
  url: entry.url,
6097
6519
  codec: normaliseSourceCodec(stream?.codec),
6098
6520
  width: stream?.resolution?.width ?? 1280,
@@ -6725,13 +7147,13 @@ var EVENT_CLIP_MAX_HEIGHT = 1080;
6725
7147
  * `pickClipProfile` and the answer reports which was used.
6726
7148
  */
6727
7149
  function pickBestClipProfile(candidates) {
6728
- const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
7150
+ const usable = candidates.filter((c) => normaliseCodec$2(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
6729
7151
  if (usable.length === 0) return null;
6730
7152
  const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
6731
7153
  return [...within.length > 0 ? within : usable].sort((a, b) => within.length > 0 ? (b.height ?? 0) - (a.height ?? 0) : (a.height ?? 0) - (b.height ?? 0))[0]?.profile ?? null;
6732
7154
  }
6733
7155
  /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
6734
- function normaliseCodec$1(codec) {
7156
+ function normaliseCodec$2(codec) {
6735
7157
  return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
6736
7158
  }
6737
7159
  /**
@@ -7177,6 +7599,249 @@ function runGif(input, args) {
7177
7599
  });
7178
7600
  }
7179
7601
  //#endregion
7602
+ //#region src/stream-broker/stream-broker/prestream-encode.ts
7603
+ /**
7604
+ * ONE short-lived ffmpeg turns the prestream's picture into ONE Annex-B IDR.
7605
+ *
7606
+ * ── Why one encode and not a continuous child ─────────────────────────────
7607
+ * The obvious shape is `-loop 1 -framerate 1` and let ffmpeg emit the picture
7608
+ * for as long as the camera is waking. That is a resident child per sleeping
7609
+ * camera, for the whole wake, to redraw a picture that changes at most hourly.
7610
+ * So the child encodes exactly ONE frame (`-frames:v 1`) and exits, the caller
7611
+ * caches the bytes, and {@link PrestreamPump} re-emits them. A new encode
7612
+ * happens only when the picture changes.
7613
+ *
7614
+ * ── Why every frame carries its own parameter sets ────────────────────────
7615
+ * The pump re-emits ONE access unit at 1 fps, so a consumer that attaches on
7616
+ * tick 4 sees its first packet with nothing before it. `-g 1` plus
7617
+ * `repeat-headers=1` makes that packet a complete IDR with SPS/PPS (or
7618
+ * VPS/SPS/PPS) inline, so it decodes within a second of attaching instead of
7619
+ * waiting for a keyframe that will never come.
7620
+ *
7621
+ * ── Why the geometry comes from the plan and nowhere else ─────────────────
7622
+ * `planPrestream` refuses a camera whose geometry nobody has probed, precisely
7623
+ * so this file never has a default to fall back on. `stream-broker.ts` records
7624
+ * what a wrong one does: a fixed 1280x720 placeholder SPS in front of a
7625
+ * camera's real IDR froze Chrome's decoder on the last synthetic frame until
7626
+ * the viewer was recreated by hand. Hence `scale` mode `exact`, the plan's
7627
+ * numbers, and the plan's codec.
7628
+ *
7629
+ * ── Why no text is drawn here ─────────────────────────────────────────────
7630
+ * D118: the hub carries two ffmpeg builds and only one has libfreetype, so
7631
+ * `drawtext` is forbidden. The picture arrives already composed — sharp + an
7632
+ * inline SVG, the house pattern from `snapshot-courtesy.ts`.
7633
+ */
7634
+ /**
7635
+ * Bound on the encode. A single 4 MP still on `ultrafast` is milliseconds; a
7636
+ * child that has not answered in this long is hung, and the prestream would
7637
+ * otherwise hold a promise open for the rest of the process's life.
7638
+ */
7639
+ var PRESTREAM_ENCODE_TIMEOUT_MS = 1e4;
7640
+ /** Grace before escalating SIGTERM → SIGKILL on a child that will not go. */
7641
+ var KILL_GRACE_MS = 500;
7642
+ /** x264/x265 params shared by both codecs: one-frame GOP, headers on it. */
7643
+ var STILL_ENCODER_PARAMS = "keyint=1:repeat-headers=1";
7644
+ /**
7645
+ * The argv. PURE, so the two properties that matter — the geometry is the
7646
+ * plan's, and the frame is a self-contained IDR — are assertable without a
7647
+ * binary.
7648
+ */
7649
+ function buildPrestreamEncodeArgs(plan) {
7650
+ const h265 = plan.codec === "h265";
7651
+ return buildFfmpegArgs$1({
7652
+ logLevel: "error",
7653
+ decodeHwAccel: null,
7654
+ input: {
7655
+ url: "pipe:0",
7656
+ extraArgs: [
7657
+ "-f",
7658
+ "image2pipe",
7659
+ "-framerate",
7660
+ "1"
7661
+ ]
7662
+ },
7663
+ video: {
7664
+ kind: "encode",
7665
+ encoder: h265 ? "libx265" : "libx264",
7666
+ scale: {
7667
+ mode: "exact",
7668
+ width: plan.width,
7669
+ height: plan.height
7670
+ },
7671
+ preset: "ultrafast",
7672
+ ...h265 ? {} : { tune: "stillimage" },
7673
+ pixelFormat: "yuv420p",
7674
+ fps: 1,
7675
+ gopFrames: 1,
7676
+ bf: 0
7677
+ },
7678
+ audio: { kind: "none" },
7679
+ threadCount: 1,
7680
+ outputArgs: [
7681
+ "-frames:v",
7682
+ "1",
7683
+ h265 ? "-x265-params" : "-x264-params",
7684
+ STILL_ENCODER_PARAMS
7685
+ ],
7686
+ sink: {
7687
+ kind: "stdout",
7688
+ container: h265 ? "hevc" : "h264"
7689
+ }
7690
+ });
7691
+ }
7692
+ /**
7693
+ * Run the encode. Resolves with the Annex-B bytes, rejects with a named reason
7694
+ * — the caller turns a rejection into a refusal log and no prestream, which is
7695
+ * the same outcome as a black screen and never worse than one.
7696
+ */
7697
+ function encodePrestreamIdr(options) {
7698
+ const args = buildPrestreamEncodeArgs(options.plan);
7699
+ const spawnFn = options.spawnFn ?? spawn;
7700
+ const tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
7701
+ return new Promise((resolve, reject) => {
7702
+ let child;
7703
+ try {
7704
+ child = spawnFn(options.ffmpegPath, args);
7705
+ } catch (err) {
7706
+ reject(/* @__PURE__ */ new Error(`prestream encode could not spawn ffmpeg: ${errMsg(err)}`));
7707
+ return;
7708
+ }
7709
+ const chunks = [];
7710
+ let stderrTail = "";
7711
+ let settled = false;
7712
+ const finish = (outcome) => {
7713
+ if (settled) return;
7714
+ settled = true;
7715
+ clearTimeout(timer);
7716
+ if ("error" in outcome) reject(outcome.error);
7717
+ else resolve(outcome.bytes);
7718
+ };
7719
+ const timer = setTimeout(() => {
7720
+ killChild(child);
7721
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode timed out after ${String(PRESTREAM_ENCODE_TIMEOUT_MS)} ms`) });
7722
+ }, PRESTREAM_ENCODE_TIMEOUT_MS);
7723
+ timer.unref?.();
7724
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
7725
+ child.stderr?.on("data", (chunk) => {
7726
+ stderrTail = `${stderrTail}${chunk.toString("utf8")}`.slice(-2048);
7727
+ });
7728
+ child.stdin?.on("error", () => {});
7729
+ child.on("error", (err) => {
7730
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode failed: ${err.message}`) });
7731
+ });
7732
+ child.on("exit", (code, signal) => {
7733
+ if (code !== 0) {
7734
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode exited ${String(code ?? signal)}: ${stderrTail.trim() || "no stderr"}`) });
7735
+ return;
7736
+ }
7737
+ const bytes = Buffer.concat(chunks);
7738
+ if (bytes.length === 0) {
7739
+ finish({ error: /* @__PURE__ */ new Error("prestream encode produced no bytes") });
7740
+ return;
7741
+ }
7742
+ options.logger?.debug("prestream: picture encoded", {
7743
+ ...tags,
7744
+ meta: {
7745
+ codec: options.plan.codec,
7746
+ width: options.plan.width,
7747
+ height: options.plan.height,
7748
+ bytes: bytes.length
7749
+ }
7750
+ });
7751
+ finish({ bytes });
7752
+ });
7753
+ child.stdin?.end(options.jpeg);
7754
+ });
7755
+ }
7756
+ function killChild(child) {
7757
+ if (child.exitCode !== null || child.signalCode !== null) return;
7758
+ child.kill("SIGTERM");
7759
+ setTimeout(() => {
7760
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
7761
+ }, KILL_GRACE_MS).unref?.();
7762
+ }
7763
+ //#endregion
7764
+ //#region src/stream-broker/stream-broker/prestream-picture.ts
7765
+ /**
7766
+ * The PICTURE the prestream shows: the camera's own last frame under a "Waking"
7767
+ * treatment, or — when there is none — a generated card carrying its name.
7768
+ *
7769
+ * ── Why a fallback exists at all ──────────────────────────────────────────
7770
+ * The snapshot cache is RAM-only. After a hub restart a sleeping battery camera
7771
+ * has no cached frame, and `getSnapshot` without `force` will not wake it to
7772
+ * make one (`force` is an OPERATOR signal — a background caller that sets it
7773
+ * wakes the camera it was trying not to disturb). So on exactly the cameras
7774
+ * this feature is for, "no picture" is a normal state, not an error. A card
7775
+ * saying WAKING with the camera's name is worse than the real frame and much
7776
+ * better than black — and it is what makes the broker servable inside Amazon's
7777
+ * six-second SDP deadline on a cold hub.
7778
+ *
7779
+ * ── Why the source is reported ────────────────────────────────────────────
7780
+ * The two look identical to a consumer and are very different to an operator:
7781
+ * one means the cache is warm, the other means it is cold. The caller logs
7782
+ * which was used, per camera.
7783
+ *
7784
+ * Pure input → bytes. No snapshot fetch, no device lookup, no I/O of its own —
7785
+ * the caller does both and hands the results in.
7786
+ */
7787
+ /**
7788
+ * Compose the picture.
7789
+ *
7790
+ * An overlay that cannot be rendered (a truncated or non-decodable cached
7791
+ * frame) falls through to the generated card rather than leaking an unlabelled
7792
+ * stale frame — the same call the snapshot wrapper's `stateImage` makes, for
7793
+ * the same reason.
7794
+ */
7795
+ async function composePrestreamPicture(input) {
7796
+ const tags = input.deviceId === void 0 ? {} : { tags: { deviceId: input.deviceId } };
7797
+ if (input.cachedJpeg !== null && input.cachedJpeg.length > 0) try {
7798
+ return {
7799
+ jpeg: await renderCourtesyOverlayJpeg(input.cachedJpeg, {
7800
+ deviceName: input.deviceName,
7801
+ reason: "waking"
7802
+ }),
7803
+ source: "cached-snapshot"
7804
+ };
7805
+ } catch (err) {
7806
+ input.logger?.warn("prestream: cached frame could not be treated — using the card", {
7807
+ ...tags,
7808
+ meta: { error: errMsg(err) }
7809
+ });
7810
+ }
7811
+ return {
7812
+ jpeg: await renderCourtesyJpeg({
7813
+ deviceName: input.deviceName,
7814
+ reason: "waking",
7815
+ width: input.width,
7816
+ height: input.height
7817
+ }),
7818
+ source: "generated-card"
7819
+ };
7820
+ }
7821
+ function planPrestream(input) {
7822
+ if (input.sourceUrl === void 0 || !input.sourceUrl.startsWith("lazy:rfc4571:")) return { refusal: "not-lazy" };
7823
+ if (input.width === void 0 || input.height === void 0 || input.width <= 0 || input.height <= 0) return { refusal: "unknown-geometry" };
7824
+ const codec = normaliseCodec$1(input.codec);
7825
+ if (codec === null) return { refusal: "unknown-codec" };
7826
+ return { plan: {
7827
+ width: input.width,
7828
+ height: input.height,
7829
+ codec
7830
+ } };
7831
+ }
7832
+ /**
7833
+ * `null` for a codec nobody has read. NOT a default of h264: a prestream in the
7834
+ * wrong codec is a stream the consumer cannot decode at all, which is worse
7835
+ * than the black screen it replaces.
7836
+ */
7837
+ function normaliseCodec$1(codec) {
7838
+ if (codec === void 0) return null;
7839
+ const c = codec.toLowerCase().replace(/[.\s-]/g, "");
7840
+ if (c === "h264" || c === "avc" || c === "avc1") return "h264";
7841
+ if (c === "h265" || c === "hevc" || c === "hvc1" || c === "hev1") return "h265";
7842
+ return null;
7843
+ }
7844
+ //#endregion
7180
7845
  //#region src/stream-broker/stream-broker/profile-restreamers.ts
7181
7846
  /**
7182
7847
  * Diff the desired profile→source aliasing against the current
@@ -11310,6 +11975,124 @@ function getPlaceholderFrame(kind, codec) {
11310
11975
  return buf;
11311
11976
  }
11312
11977
  //#endregion
11978
+ //#region src/stream-broker/stream-broker/prestream-pump.ts
11979
+ /**
11980
+ * The prestream pump: ONE encode, then a 1 fps re-emit until real media lands.
11981
+ *
11982
+ * ── What it is for ────────────────────────────────────────────────────────
11983
+ * A lazy source is BUILT on demand, and on this hub that build costs 2.2 to
11984
+ * 12.4 s. For all of it the consumer had black — and Amazon allows SIX SECONDS
11985
+ * for an Alexa SDP answer, so a cold battery camera could not answer an Echo at
11986
+ * all. The pump makes the broker servable before the camera is: the camera's
11987
+ * own last picture under a "Waking" treatment, at the camera's real geometry,
11988
+ * replaced by real media the instant the first real packet arrives.
11989
+ *
11990
+ * ── The three rules it exists to hold ─────────────────────────────────────
11991
+ * 1. **Prepare ONCE.** The picture is fetched and encoded a single time per
11992
+ * wake and the bytes are cached here. Re-fetching per tick would put a
11993
+ * snapshot round-trip and a fork+exec on every second of every waking
11994
+ * camera, and would move frames across a process boundary at frame-rate —
11995
+ * D9/D18.
11996
+ * 2. **A late preparation lands nowhere.** The encode is async. If the camera
11997
+ * woke while it was running the pump has already been stopped, and starting
11998
+ * the timer then would paint a still picture over live media.
11999
+ * 3. **It never dials.** The pump has no source, no socket and no demand. It
12000
+ * is started alongside the publisher request, never instead of it.
12001
+ *
12002
+ * Emission is the caller's: {@link PrestreamPumpOptions.emit} is where the
12003
+ * `isPlaceholder: true` packet is built, because that flag belongs to the
12004
+ * broker's packet contract and the containment that reads it lives there.
12005
+ */
12006
+ var DEFAULT_PRESTREAM_INTERVAL_MS = 1e3;
12007
+ var PrestreamPump = class {
12008
+ options;
12009
+ intervalMs;
12010
+ tags;
12011
+ timer;
12012
+ /** True from `start()` until `stop()`, INCLUDING while the encode runs. */
12013
+ active = false;
12014
+ frame = null;
12015
+ startedAt = 0;
12016
+ emitted = 0;
12017
+ constructor(options) {
12018
+ this.options = options;
12019
+ this.intervalMs = options.intervalMs ?? DEFAULT_PRESTREAM_INTERVAL_MS;
12020
+ this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
12021
+ }
12022
+ /** Whether the window is currently being filled (or about to be). */
12023
+ get running() {
12024
+ return this.active;
12025
+ }
12026
+ /**
12027
+ * Open the window. Idempotent: a second call while the first preparation is
12028
+ * still in flight is a no-op, so a reconnect tick that re-enters the lazy
12029
+ * branch every second does not spawn an encode every second.
12030
+ */
12031
+ start(sourceUrl) {
12032
+ if (this.active) return;
12033
+ this.active = true;
12034
+ this.startedAt = Date.now();
12035
+ this.emitted = 0;
12036
+ this.options.prepare({ sourceUrl }).then((frame) => {
12037
+ if (!this.active) {
12038
+ this.options.logger?.debug("prestream: preparation landed after the window closed", { ...this.tags });
12039
+ return;
12040
+ }
12041
+ if (frame === null) {
12042
+ this.options.logger?.info("prestream: refused — the window goes unfilled", { ...this.tags });
12043
+ this.active = false;
12044
+ return;
12045
+ }
12046
+ this.frame = frame;
12047
+ this.options.logger?.info("prestream: filling the wake window", {
12048
+ ...this.tags,
12049
+ meta: {
12050
+ codec: frame.codec,
12051
+ bytes: frame.annexB.length,
12052
+ intervalMs: this.intervalMs,
12053
+ preparedInMs: Date.now() - this.startedAt
12054
+ }
12055
+ });
12056
+ this.tick();
12057
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
12058
+ }).catch((err) => {
12059
+ this.options.logger?.warn("prestream: preparation failed — the window goes unfilled", {
12060
+ ...this.tags,
12061
+ meta: { error: errMsg(err) }
12062
+ });
12063
+ this.active = false;
12064
+ });
12065
+ }
12066
+ /**
12067
+ * Close the window. Called on the first NON-placeholder packet — the one edge
12068
+ * the prestream is defined by — and on the broker's own teardown paths so a
12069
+ * stopped broker leaves no timer behind.
12070
+ */
12071
+ stop(reason) {
12072
+ if (!this.active) return;
12073
+ this.active = false;
12074
+ if (this.timer) {
12075
+ clearInterval(this.timer);
12076
+ this.timer = void 0;
12077
+ }
12078
+ this.options.logger?.info("prestream: window closed", {
12079
+ ...this.tags,
12080
+ meta: {
12081
+ reason,
12082
+ framesEmitted: this.emitted,
12083
+ heldMs: Date.now() - this.startedAt
12084
+ }
12085
+ });
12086
+ this.frame = null;
12087
+ }
12088
+ tick() {
12089
+ const frame = this.frame;
12090
+ if (!this.active || frame === null) return;
12091
+ this.emitted++;
12092
+ this.options.emit(frame);
12093
+ }
12094
+ };
12095
+ //#endregion
11313
12096
  //#region src/stream-broker/stream-broker/stream-pipe-server.ts
11314
12097
  /**
11315
12098
  * A lightweight TCP server that broadcasts raw encoded video bytes
@@ -11602,6 +12385,23 @@ var StreamBroker = class StreamBroker {
11602
12385
  audioMutedDropLogged = false;
11603
12386
  reconnectTimer;
11604
12387
  placeholderTimer;
12388
+ /**
12389
+ * The prestream pump, armed by the manager via `setPrestreamProvider`.
12390
+ * `undefined` on a broker whose manager supplied none — the pre-prestream
12391
+ * behaviour, unchanged.
12392
+ */
12393
+ prestreamPump;
12394
+ /**
12395
+ * Latched once real media has been seen on this session.
12396
+ *
12397
+ * The lazy branch is re-entered on every materialize poll tick, so stopping
12398
+ * the pump is not enough on its own: the very next tick would re-open a
12399
+ * window that real bytes have already closed, and the consumer would get a
12400
+ * still picture painted over live video every second. Reset exactly where
12401
+ * `firstKeyframeReceived` is — broker stop and suspend — so a battery camera
12402
+ * that goes back to sleep gets its prestream again on the next wake.
12403
+ */
12404
+ prestreamWindowClosed = false;
11605
12405
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11606
12406
  /**
11607
12407
  * Dial rate-limiter (2026-07-23 storm fix): the earliest wall-clock at
@@ -11696,6 +12496,15 @@ var StreamBroker = class StreamBroker {
11696
12496
  */
11697
12497
  materializeRequestedAt = 0;
11698
12498
  /**
12499
+ * The hold a camera's own signal bought. See `wake-hold.ts` — a term of
12500
+ * {@link hasLiveIntent}, so the predicate's owner owns the state: putting it
12501
+ * in the manager would give two places a vote on whether to dial.
12502
+ */
12503
+ wakeHold = NO_HOLD;
12504
+ /** Fires when the hold lapses, so the suspend happens instead of waiting for
12505
+ * an unrelated edge. Re-armed on every extension. */
12506
+ wakeHoldTimer;
12507
+ /**
11699
12508
  * Rolling source-RTP activity watchdog. Distinct from the first-video
11700
12509
  * watchdog: this one ARMS at DIAL time (so a connection that opens the
11701
12510
  * loopback TCP but never delivers media is still detected) and RESETS on
@@ -11995,6 +12804,18 @@ var StreamBroker = class StreamBroker {
11995
12804
  getLastPacketAt() {
11996
12805
  return this.lastPacketAt;
11997
12806
  }
12807
+ /**
12808
+ * Whether a LAZY publisher is being built right now.
12809
+ *
12810
+ * A consumer deciding whether it is worth waiting for this source needs to
12811
+ * tell "cold and idle" from "waking up": the first cannot be waited for at
12812
+ * all (the dial is the demand that starts the wake), the second can.
12813
+ * `getFirstVideoWatchdogStatus` carries the same number and is a TEST SEAM —
12814
+ * this is the production read.
12815
+ */
12816
+ isMaterializing() {
12817
+ return this.materializeRequestedAt > 0;
12818
+ }
11998
12819
  /** Source type of the active stream (rtsp/push/rtmp/placeholder/error). */
11999
12820
  getActiveSourceType() {
12000
12821
  return this.source?.type ?? "unknown";
@@ -12083,6 +12904,38 @@ var StreamBroker = class StreamBroker {
12083
12904
  this.requestSourceRefresh = notify;
12084
12905
  }
12085
12906
  /**
12907
+ * Arm the PRESTREAM — the fill for the window between "someone asked for this
12908
+ * camera" and "the camera is awake".
12909
+ *
12910
+ * Manager-owned, because everything the fill needs is the manager's: the
12911
+ * camera's probed geometry and codec (`planPrestream`), its last cached
12912
+ * snapshot, its name, and the ffmpeg binary. The broker contributes the one
12913
+ * thing it owns — WHEN the window is open — and nothing else.
12914
+ *
12915
+ * A broker with no provider behaves exactly as it did before the prestream
12916
+ * existed: the lazy branch asks the publisher to materialize and the consumer
12917
+ * sees black, which is the outcome every refusal falls back to.
12918
+ */
12919
+ setPrestreamProvider(provider) {
12920
+ this.prestreamPump = new PrestreamPump({
12921
+ prepare: provider.prepare,
12922
+ emit: (frame) => {
12923
+ const now = Date.now();
12924
+ this.pushEncodedPacket({
12925
+ type: "video",
12926
+ data: frame.annexB,
12927
+ pts: now,
12928
+ dts: now,
12929
+ keyframe: true,
12930
+ codec: frame.codec,
12931
+ isPlaceholder: true
12932
+ });
12933
+ },
12934
+ ...this.logger ? { logger: this.logger } : {},
12935
+ ...this.numericDeviceId === void 0 ? {} : { deviceId: this.numericDeviceId }
12936
+ });
12937
+ }
12938
+ /**
12086
12939
  * Fire `requestSourceRefresh` at most once per ~500 ms. The
12087
12940
  * dial-failure path fans into onError + reader.connect().catch; we
12088
12941
  * only want one round-trip to the publisher per real failure.
@@ -12132,7 +12985,7 @@ var StreamBroker = class StreamBroker {
12132
12985
  */
12133
12986
  redial(reason) {
12134
12987
  if (this.manualStop || this.stopping || !this.source) return;
12135
- if (!this.hasDemand()) return;
12988
+ if (!this.hasLiveIntent()) return;
12136
12989
  this.logger?.info("source redial — the camera changed under a live reader", { meta: {
12137
12990
  reason,
12138
12991
  status: this._status
@@ -12148,7 +13001,7 @@ var StreamBroker = class StreamBroker {
12148
13001
  }
12149
13002
  kickReconnect() {
12150
13003
  if (this.manualStop || this.stopping || !this.source) return;
12151
- if (!this.hasDemand()) return;
13004
+ if (!this.hasLiveIntent()) return;
12152
13005
  if (!this.reconnectTimer) return;
12153
13006
  const nowMs = Date.now();
12154
13007
  if (nowMs - this.lastKickAt < INITIAL_RECONNECT_DELAY_MS) return;
@@ -12190,7 +13043,7 @@ var StreamBroker = class StreamBroker {
12190
13043
  } });
12191
13044
  await this.pipeServer.start();
12192
13045
  this.logger?.debug("Pipe server started", { meta: { url: this.pipeServer.getUrl() } });
12193
- if (isLazyDialSource(source.type) && !this.hasDemand()) {
13046
+ if (isLazyDialSource(source.type) && !this.hasLiveIntent()) {
12194
13047
  this._suspended = true;
12195
13048
  this._status = "idle";
12196
13049
  this.logger?.info("Broker registered idle — no demand, source dial deferred");
@@ -12248,6 +13101,8 @@ var StreamBroker = class StreamBroker {
12248
13101
  clearInterval(this.placeholderTimer);
12249
13102
  this.placeholderTimer = void 0;
12250
13103
  }
13104
+ this.prestreamPump?.stop("broker stopped");
13105
+ this.prestreamWindowClosed = false;
12251
13106
  if (this.reconnectTimer) {
12252
13107
  clearTimeout(this.reconnectTimer);
12253
13108
  this.reconnectTimer = void 0;
@@ -12409,14 +13264,98 @@ var StreamBroker = class StreamBroker {
12409
13264
  this.checkDemand();
12410
13265
  }
12411
13266
  /** True when at least one consumer needs the stream alive. */
12412
- hasDemand() {
13267
+ /**
13268
+ * A signal the CAMERA reported about itself.
13269
+ *
13270
+ * Two terms must hold before anything is dialled, and they are the whole
13271
+ * safety of this feature:
13272
+ *
13273
+ * - the caller has already checked the signal is ENABLED for this camera
13274
+ * (the broker manager owns that setting), and
13275
+ * - somebody is actually waiting on the prestream. Waking a camera nobody is
13276
+ * watching is the one thing a battery camera must never do, and a signal
13277
+ * arriving with no consumer is exactly that.
13278
+ *
13279
+ * Returns what it did, so the caller's log line can say it. A hold that is
13280
+ * merely EXTENDED does not re-dial: the camera is already up.
13281
+ */
13282
+ reportWakeSignal(code, holdMs, at) {
13283
+ if (this.rtspRestreamer.getPrestreamSessionCount() === 0) return "no-consumer";
13284
+ const wasLive = holdIsLive(this.wakeHold, at);
13285
+ const { hold, outcome } = absorbSignal({
13286
+ current: this.wakeHold,
13287
+ now: at,
13288
+ holdMs
13289
+ });
13290
+ this.wakeHold = hold;
13291
+ this.logger?.info("wake signal from the camera", {
13292
+ tags: { deviceId: this.numericDeviceId },
13293
+ meta: {
13294
+ brokerId: this.deviceId,
13295
+ code,
13296
+ outcome,
13297
+ signals: hold.signals,
13298
+ untilMs: Math.max(0, hold.until - at),
13299
+ runMs: hold.runStartedAt > 0 ? at - hold.runStartedAt : 0
13300
+ }
13301
+ });
13302
+ this.armWakeHoldTimer(at);
13303
+ if (outcome === "quarantined") return "no-consumer";
13304
+ if (wasLive) return "hold-extended";
13305
+ this.checkDemand();
13306
+ return "woke";
13307
+ }
13308
+ /**
13309
+ * Look at the hold when it lapses, so the camera actually goes back to sleep
13310
+ * instead of waiting for an unrelated edge to notice.
13311
+ */
13312
+ armWakeHoldTimer(now) {
13313
+ if (this.wakeHoldTimer) clearTimeout(this.wakeHoldTimer);
13314
+ const delay = this.wakeHold.until - now;
13315
+ if (delay <= 0) return;
13316
+ this.wakeHoldTimer = setTimeout(() => {
13317
+ this.wakeHoldTimer = void 0;
13318
+ this.logger?.info("wake hold lapsed — back to the prestream picture", {
13319
+ tags: { deviceId: this.numericDeviceId },
13320
+ meta: {
13321
+ brokerId: this.deviceId,
13322
+ signals: this.wakeHold.signals,
13323
+ runMs: this.wakeHold.runStartedAt > 0 ? Date.now() - this.wakeHold.runStartedAt : 0
13324
+ }
13325
+ });
13326
+ this.checkDemand();
13327
+ }, delay);
13328
+ this.wakeHoldTimer.unref?.();
13329
+ }
13330
+ /**
13331
+ * Anything that must be FED, prestream sessions included.
13332
+ *
13333
+ * The only consumer of this is the prestream pump: it says "somebody is
13334
+ * watching, keep the picture going", and says nothing about whether the
13335
+ * camera may be dialled. {@link hasLiveIntent} answers that, and it is the
13336
+ * one every dial path asks.
13337
+ */
13338
+ hasAnyConsumer() {
13339
+ if (this.hasLiveIntent()) return true;
13340
+ return this.rtspRestreamer.getPrestreamSessionCount() > 0;
13341
+ }
13342
+ /**
13343
+ * Consumers that may DIAL. Every term of the former `hasDemand`, with the
13344
+ * RTSP session count filtered to exclude prestream dials, plus the wake hold.
13345
+ *
13346
+ * For a broker with no prestream session attached this is term-for-term what
13347
+ * `hasDemand` was, which is why every existing camera, consumer and test
13348
+ * behaves identically.
13349
+ */
13350
+ hasLiveIntent() {
12413
13351
  if (this.encodedCallbacks.size > 0) return true;
12414
13352
  if (this.rtpVideoCallbacks.size > 0) return true;
12415
13353
  if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
12416
13354
  if (this.audioChunkPlane.subscriberCount > 0) return true;
12417
- if (this.rtspRestreamer.getSessionCount() > 0) return true;
13355
+ if (this.rtspRestreamer.getLiveIntentSessionCount() > 0) return true;
12418
13356
  if (this.pipeServer.getClientCount() > 0) return true;
12419
13357
  if (this.prebufferHold !== null) return true;
13358
+ if (holdIsLive(this.wakeHold, Date.now())) return true;
12420
13359
  return false;
12421
13360
  }
12422
13361
  /**
@@ -12532,7 +13471,7 @@ var StreamBroker = class StreamBroker {
12532
13471
  checkDemand() {
12533
13472
  if (this.manualStop || this.stopping) return;
12534
13473
  this.reconcileAudioConsumer();
12535
- if (this.hasDemand()) {
13474
+ if (this.hasLiveIntent()) {
12536
13475
  if (!this.demandActive) {
12537
13476
  this.demandActive = true;
12538
13477
  this.demandObserver?.(true);
@@ -12556,9 +13495,19 @@ var StreamBroker = class StreamBroker {
12556
13495
  } else if (!this._suspended) {
12557
13496
  if (!this.suspendTimer) this.suspendTimer = setTimeout(() => {
12558
13497
  this.suspendTimer = void 0;
12559
- if (!this.hasDemand() && !this.manualStop && !this.stopping) this.suspend();
13498
+ if (!this.hasLiveIntent() && !this.manualStop && !this.stopping) this.suspend();
12560
13499
  }, SUSPEND_GRACE_MS);
12561
13500
  }
13501
+ this.prestreamWanted = this.hasAnyConsumer() && !this.hasLiveIntent();
13502
+ }
13503
+ /**
13504
+ * Whether a prestream picture should be flowing right now: somebody is
13505
+ * watching and the camera is not up. Read by the pump.
13506
+ */
13507
+ prestreamWanted = false;
13508
+ /** Whether the picture should be flowing — the pump's one question. */
13509
+ wantsPrestream() {
13510
+ return this.prestreamWanted;
12562
13511
  }
12563
13512
  /** Suspend the stream: kill ffmpeg/native client, clear buffers, go idle. Source is preserved for resume. */
12564
13513
  suspend() {
@@ -12583,6 +13532,8 @@ var StreamBroker = class StreamBroker {
12583
13532
  clearInterval(this.placeholderTimer);
12584
13533
  this.placeholderTimer = void 0;
12585
13534
  }
13535
+ this.prestreamPump?.stop("broker suspended");
13536
+ this.prestreamWindowClosed = false;
12586
13537
  this.preBuffer.clear();
12587
13538
  this.decoderSeedBuffer.clear();
12588
13539
  this.clipBuffer?.clear();
@@ -12666,6 +13617,10 @@ var StreamBroker = class StreamBroker {
12666
13617
  }
12667
13618
  pushEncodedPacket(packet) {
12668
13619
  if (this.stopping) return;
13620
+ if (packet.isPlaceholder !== true && !this.prestreamWindowClosed) {
13621
+ this.prestreamWindowClosed = true;
13622
+ this.prestreamPump?.stop("first real packet");
13623
+ }
12669
13624
  if (this.source?.type === "push") {
12670
13625
  this.stopErrorPlaceholder();
12671
13626
  if (this._status === "error") this._status = "streaming";
@@ -12707,17 +13662,17 @@ var StreamBroker = class StreamBroker {
12707
13662
  }
12708
13663
  }
12709
13664
  if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
12710
- if (packet.type === "video" && this.clipBuffer) this.clipBuffer.push(packet);
13665
+ if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
12711
13666
  this.fanOutEncoded(packet);
12712
13667
  if (packet.type === "video") {
12713
13668
  this.pipeServer.broadcast(packet.data);
12714
13669
  if (!this.rtspRestreamer.rtpPassthroughMode) this.rtspRestreamer.pushPacket(packet);
12715
- if (!this.firstKeyframeReceived && packet.keyframe) {
13670
+ if (!this.firstKeyframeReceived && packet.keyframe && packet.isPlaceholder !== true) {
12716
13671
  this.firstKeyframeReceived = true;
12717
13672
  this.logger?.info("First keyframe received — decoder can start");
12718
13673
  }
12719
13674
  }
12720
- if (packet.type === "video" && this.firstKeyframeReceived) {
13675
+ if (packet.type === "video" && this.firstKeyframeReceived && packet.isPlaceholder !== true) {
12721
13676
  const plane = this.frameHandlePlane;
12722
13677
  if (plane) {
12723
13678
  const decoderPacket = this.decoderPacketWithParamSets(packet);
@@ -14281,7 +15236,7 @@ var StreamBroker = class StreamBroker {
14281
15236
  }
14282
15237
  scheduleReconnect() {
14283
15238
  if (this.manualStop || !this.source) return;
14284
- if (!this.hasDemand()) {
15239
+ if (!this.hasLiveIntent()) {
14285
15240
  this._suspended = true;
14286
15241
  this._status = "idle";
14287
15242
  this.stopErrorPlaceholder();
@@ -14393,6 +15348,7 @@ var StreamBroker = class StreamBroker {
14393
15348
  });
14394
15349
  this._status = "error";
14395
15350
  this.notifySourceRefresh();
15351
+ if (!this.prestreamWindowClosed) this.prestreamPump?.start(source.url);
14396
15352
  this.scheduleReconnect();
14397
15353
  return;
14398
15354
  }
@@ -15122,7 +16078,23 @@ var DeviceOverrideSchema = object({
15122
16078
  derivedStreams: record(string(), DerivedStreamSchema).optional(),
15123
16079
  hwaccel: record(string(), FfmpegHwAccelSettingSchema).optional(),
15124
16080
  sourceParser: record(string(), SourceParserSettingSchema).optional(),
15125
- sourceParserArgs: record(string(), SourceParserArgsEntrySchema).optional()
16081
+ sourceParserArgs: record(string(), SourceParserArgsEntrySchema).optional(),
16082
+ /**
16083
+ * The on-demand PRESTREAM, per camera.
16084
+ *
16085
+ * Absent means the operator has never touched it, which is NOT the same as
16086
+ * off: `enabledSignals` absent means "every signal this camera recommends",
16087
+ * so a Reolink works the moment a prestream URL is dialled without an
16088
+ * operator hunting for a checkbox. An empty ARRAY is a deliberate "none" and
16089
+ * is honoured — the two are different answers and the shape keeps them
16090
+ * apart.
16091
+ */
16092
+ prestream: object({
16093
+ /** Signal codes allowed to wake this camera. Absent ⇒ the recommended set. */
16094
+ enabledSignals: array(string()).optional(),
16095
+ /** How long the camera stays up after the LAST signal. Absent ⇒ default. */
16096
+ holdMs: number().int().min(1e3).max(3e5).optional()
16097
+ }).optional()
15126
16098
  });
15127
16099
  /**
15128
16100
  * Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
@@ -15266,6 +16238,12 @@ var StreamBrokerManager = class StreamBrokerManager {
15266
16238
  profileRestreamerBindings = /* @__PURE__ */ new Map();
15267
16239
  /** Per-device published cam-stream registry. */
15268
16240
  cameraStreams = /* @__PURE__ */ new Map();
16241
+ /**
16242
+ * The encoded prestream frame per broker, with the digest of the picture it
16243
+ * was made from. One entry is one still image — kilobytes — and it is
16244
+ * dropped with the broker in `releaseBroker`.
16245
+ */
16246
+ prestreamFrames = /* @__PURE__ */ new Map();
15269
16247
  /** Monotonic insertion counter per device — feeds `CameraStreamInternal.order`. */
15270
16248
  streamOrderSeq = /* @__PURE__ */ new Map();
15271
16249
  /** Persisted operator intent: which cam stream serves which profile. */
@@ -15614,6 +16592,14 @@ var StreamBrokerManager = class StreamBrokerManager {
15614
16592
  const entry = this.rtspProvider.getEntry(brokerId);
15615
16593
  return entry ? { url: entry.url } : null;
15616
16594
  },
16595
+ sourceHasMedia: (brokerId) => {
16596
+ const broker = this.brokers.get(brokerId);
16597
+ if (broker === void 0) return null;
16598
+ return sourceWaitState({
16599
+ hasMedia: broker.getLastPacketAt() > 0,
16600
+ materializing: broker.isMaterializing()
16601
+ });
16602
+ },
15617
16603
  ffmpegConfig: () => this.ffmpegConfig,
15618
16604
  ...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
15619
16605
  resolveDecodeHwAccel: (deviceId, override) => this.resolveEgressHwAccel(deviceId, override),
@@ -16148,6 +17134,150 @@ var StreamBrokerManager = class StreamBrokerManager {
16148
17134
  * firehose so we don't filter the entire device-state stream
16149
17135
  * client-side and we get a typed payload.
16150
17136
  */
17137
+ /**
17138
+ * Build the per-broker PRESTREAM provider — the fill for the window between
17139
+ * "someone asked for this camera" and "the camera is awake".
17140
+ *
17141
+ * On this hub that window is 2.2-12.4 s for a lazy source, and it is not
17142
+ * merely ugly: Amazon allows SIX SECONDS for an Alexa SDP answer, so a cold
17143
+ * battery camera could not answer an Echo at all.
17144
+ *
17145
+ * Four facts are needed and all four are the manager's, which is why the
17146
+ * provider is a closure here rather than anything the broker knows about:
17147
+ * the probed geometry and codec ({@link planPrestream}), the camera's last
17148
+ * cached snapshot, its name, and the ffmpeg binary path.
17149
+ *
17150
+ * `null` is a REFUSAL, named in the log. Refusing is cheap — the consumer
17151
+ * gets the black screen it got before the prestream existed — and the
17152
+ * alternative for an unknown geometry is the decoder freeze the whole
17153
+ * synthetic-frame path is shaped around.
17154
+ */
17155
+ buildPrestreamProvider(deviceId, camStreamId, brokerId, logger) {
17156
+ return { prepare: async ({ sourceUrl }) => {
17157
+ const gate = prestreamEligible({
17158
+ batteryOperated: await this.isBatteryOperated(deviceId),
17159
+ declaresSignals: (await this.declaredSignalCodes(deviceId)).length > 0
17160
+ });
17161
+ if (!gate.eligible) {
17162
+ logger.info("prestream: not offered for this camera", {
17163
+ tags: { deviceId },
17164
+ meta: {
17165
+ brokerId,
17166
+ reason: gate.reason
17167
+ }
17168
+ });
17169
+ return null;
17170
+ }
17171
+ const stream = this.cameraStreams.get(deviceId)?.get(camStreamId);
17172
+ const codec = this.lastProbedByBroker.get(brokerId)?.codec ?? stream?.codec;
17173
+ const planned = planPrestream({
17174
+ sourceUrl,
17175
+ width: stream?.resolution?.width,
17176
+ height: stream?.resolution?.height,
17177
+ codec
17178
+ });
17179
+ if ("refusal" in planned) {
17180
+ logger.info("prestream: no fill for this camera", {
17181
+ tags: { deviceId },
17182
+ meta: {
17183
+ refusal: planned.refusal,
17184
+ camStreamId,
17185
+ width: stream?.resolution?.width ?? null,
17186
+ height: stream?.resolution?.height ?? null,
17187
+ codec: codec ?? null
17188
+ }
17189
+ });
17190
+ return null;
17191
+ }
17192
+ return await this.preparePrestreamFrame(deviceId, brokerId, planned.plan, logger);
17193
+ } };
17194
+ }
17195
+ /**
17196
+ * Fetch the picture, compose the treatment, encode ONCE and cache the bytes.
17197
+ *
17198
+ * The cache is keyed on the picture's own digest plus the geometry, so a
17199
+ * second wake of the same camera re-uses the frame and spawns nothing: the
17200
+ * picture changes at most hourly, and a fork+exec per wake per sleeping
17201
+ * camera is the cost this shape exists to avoid.
17202
+ */
17203
+ async preparePrestreamFrame(deviceId, brokerId, plan, logger) {
17204
+ if (!this.api) {
17205
+ logger.info("prestream: no api access — the window goes unfilled", { tags: { deviceId } });
17206
+ return null;
17207
+ }
17208
+ const api = this.api;
17209
+ let cachedJpeg = null;
17210
+ try {
17211
+ const image = await api.snapshot.getSnapshot.query({ deviceId });
17212
+ if (image) cachedJpeg = Buffer.from(image.base64, "base64");
17213
+ } catch (err) {
17214
+ logger.debug("prestream: cached snapshot read failed — falling back to the card", {
17215
+ tags: { deviceId },
17216
+ meta: { error: errMsg(err) }
17217
+ });
17218
+ }
17219
+ let deviceName = `#${String(deviceId)}`;
17220
+ try {
17221
+ const device = await api.deviceManager.getDevice.query({ deviceId });
17222
+ if (device?.name) deviceName = device.name;
17223
+ } catch (err) {
17224
+ logger.debug("prestream: device name read failed — using the numeric id", {
17225
+ tags: { deviceId },
17226
+ meta: { error: errMsg(err) }
17227
+ });
17228
+ }
17229
+ const key = `${createHash("sha1").update(cachedJpeg ?? Buffer.alloc(0)).update(deviceName).digest("hex")}:${String(plan.width)}x${String(plan.height)}:${plan.codec}`;
17230
+ const cached = this.prestreamFrames.get(brokerId);
17231
+ if (cached && cached.key === key) {
17232
+ logger.debug("prestream: re-using the encoded frame", {
17233
+ tags: { deviceId },
17234
+ meta: { bytes: cached.frame.annexB.length }
17235
+ });
17236
+ return cached.frame;
17237
+ }
17238
+ const picture = await composePrestreamPicture({
17239
+ cachedJpeg,
17240
+ deviceName,
17241
+ width: plan.width,
17242
+ height: plan.height,
17243
+ logger,
17244
+ deviceId
17245
+ });
17246
+ logger.info("prestream: picture composed", {
17247
+ tags: { deviceId },
17248
+ meta: {
17249
+ source: picture.source,
17250
+ width: plan.width,
17251
+ height: plan.height,
17252
+ codec: plan.codec
17253
+ }
17254
+ });
17255
+ let annexB;
17256
+ try {
17257
+ annexB = await encodePrestreamIdr({
17258
+ ffmpegPath: this.ffmpegConfig.binaryPath,
17259
+ plan,
17260
+ jpeg: picture.jpeg,
17261
+ logger,
17262
+ deviceId
17263
+ });
17264
+ } catch (err) {
17265
+ logger.warn("prestream: encode failed — the window goes unfilled", {
17266
+ tags: { deviceId },
17267
+ meta: { error: errMsg(err) }
17268
+ });
17269
+ return null;
17270
+ }
17271
+ const frame = {
17272
+ annexB,
17273
+ codec: plan.codec
17274
+ };
17275
+ this.prestreamFrames.set(brokerId, {
17276
+ key,
17277
+ frame
17278
+ });
17279
+ return frame;
17280
+ }
16151
17281
  subscribePlaceholderStateSources(bus) {
16152
17282
  bus.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
16153
17283
  const data = event.data;
@@ -16302,11 +17432,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16302
17432
  if (!api) return;
16303
17433
  await this.batteryWakeOnPlay.run(deviceId, {
16304
17434
  logger: this.logger,
16305
- isBatteryDevice: async (id) => {
16306
- const dev = await api.deviceManager.getDevice.query({ deviceId: id }).catch(() => null);
16307
- if (!dev) return null;
16308
- return dev.features.includes(DeviceFeature.BatteryOperated);
16309
- },
17435
+ isBatteryDevice: (id) => this.isBatteryOperated(id),
16310
17436
  readBatteryFacts: async (id) => {
16311
17437
  const slice = await api.deviceState.getCapSlice.query({
16312
17438
  deviceId: id,
@@ -17839,6 +18965,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17839
18965
  const parsed = this.parseBrokerId(brokerId);
17840
18966
  if (!parsed) continue;
17841
18967
  const { deviceId, camStreamId } = parsed;
18968
+ if (!healthEventIsOperatorFacing(camStreamId)) continue;
17842
18969
  const lastPacketAt = broker.getLastPacketAt();
17843
18970
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
17844
18971
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
@@ -18129,6 +19256,93 @@ var StreamBrokerManager = class StreamBrokerManager {
18129
19256
  * falls back to the global `ffmpeg.hwAccel`). Read by the transcode pipeline
18130
19257
  * per spawn; affects ONLY the transcode paths.
18131
19258
  */
19259
+ /**
19260
+ * A device reporting one of its own signals — the cap method the provider
19261
+ * calls. See `streamBroker.reportStreamSignal` for the contract.
19262
+ *
19263
+ * Everything that could refuse says WHY, because this is the path that
19264
+ * decides whether a battery camera wakes, and a silent refusal here is
19265
+ * indistinguishable from a signal that never arrived.
19266
+ */
19267
+ async reportStreamSignal(input) {
19268
+ const at = input.at ?? Date.now();
19269
+ const declared = await this.declaredSignalCodes(input.deviceId);
19270
+ if (!declared.includes(input.code)) {
19271
+ this.logger.warn("wake signal REFUSED — this camera never declared that code", {
19272
+ tags: { deviceId: input.deviceId },
19273
+ meta: {
19274
+ code: input.code,
19275
+ declared
19276
+ }
19277
+ });
19278
+ return {
19279
+ accepted: false,
19280
+ reason: "unknown-code"
19281
+ };
19282
+ }
19283
+ const settings = this.deviceOverrides.get(input.deviceId)?.prestream;
19284
+ if (!(settings?.enabledSignals ?? declared).includes(input.code)) return {
19285
+ accepted: false,
19286
+ reason: "not-enabled"
19287
+ };
19288
+ const holdMs = settings?.holdMs ?? 15e3;
19289
+ let outcome = "no-consumer";
19290
+ for (const [brokerId, broker] of this.brokers) {
19291
+ if (this.parseBrokerId(brokerId)?.deviceId !== input.deviceId) continue;
19292
+ const result = broker.reportWakeSignal(input.code, holdMs, at);
19293
+ if (result === "woke") outcome = "woke";
19294
+ else if (result === "hold-extended" && outcome !== "woke") outcome = "hold-extended";
19295
+ }
19296
+ return {
19297
+ accepted: outcome !== "no-consumer",
19298
+ reason: outcome
19299
+ };
19300
+ }
19301
+ /**
19302
+ * What this camera says it can emit. `[]` when it declares the cap and lists
19303
+ * nothing, and `[]` when it does not declare the cap at all — both mean no
19304
+ * signal of that name is acceptable, which is the same refusal.
19305
+ */
19306
+ async declaredSignalCodes(deviceId) {
19307
+ const read = this.deviceSignalReader;
19308
+ if (read === null) return [];
19309
+ try {
19310
+ return await read(deviceId);
19311
+ } catch {
19312
+ return [];
19313
+ }
19314
+ }
19315
+ /**
19316
+ * How the manager asks a DEVICE what signals it declares. Injected by the
19317
+ * host addon, which owns the device proxy. `null` until it is.
19318
+ */
19319
+ deviceSignalReader = null;
19320
+ setDeviceSignalReader(read) {
19321
+ this.deviceSignalReader = read;
19322
+ }
19323
+ /**
19324
+ * Whether this device runs on a battery.
19325
+ *
19326
+ * The DEVICE REGISTRY, never the published cam-streams: a sleeping camera has
19327
+ * none — which is precisely when this question gets asked — and
19328
+ * `deviceHasFeature` reads the stream map, so it answers `false` for every
19329
+ * sleeping battery camera. That reasoning was written for the wake-on-play
19330
+ * path and belongs to the question, not to one caller of it.
19331
+ *
19332
+ * `null` when nobody could say, and unknown is refused by every caller rather
19333
+ * than guessed: guessing yes puts a mains camera in a wake-hold loop, and
19334
+ * guessing no silently disables the feature on the cameras it exists for.
19335
+ */
19336
+ async isBatteryOperated(deviceId) {
19337
+ if (!this.api) return null;
19338
+ try {
19339
+ const features = (await this.api.deviceManager.getDevice.query({ deviceId }))?.features;
19340
+ if (!Array.isArray(features)) return null;
19341
+ return features.includes(DeviceFeature.BatteryOperated);
19342
+ } catch {
19343
+ return null;
19344
+ }
19345
+ }
18132
19346
  getStreamHwAccelOverride(deviceId, camStreamId) {
18133
19347
  return this.deviceOverrides.get(deviceId)?.hwaccel?.[camStreamId] ?? null;
18134
19348
  }
@@ -19296,6 +20510,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19296
20510
  broker.setRequestSourceRefresh(() => {
19297
20511
  this.emitRequestStreamSourceRefresh(deviceId, camStreamId, brokerId);
19298
20512
  });
20513
+ broker.setPrestreamProvider(this.buildPrestreamProvider(deviceId, camStreamId, brokerId, brokerLogger));
19299
20514
  try {
19300
20515
  await broker.start(source);
19301
20516
  } catch (err) {
@@ -19343,6 +20558,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19343
20558
  this.streamOfflineReasonByBroker.delete(brokerId);
19344
20559
  await broker.stop();
19345
20560
  this.brokers.delete(brokerId);
20561
+ this.prestreamFrames.delete(brokerId);
19346
20562
  this.sweepFrameSubscriptions(brokerId);
19347
20563
  this.rtspProvider.persistTokens();
19348
20564
  this.syncProfileRestreamers(deviceId);
@@ -19809,6 +21025,28 @@ var WEBRTC_FALLBACK_PROFILE = {
19809
21025
  },
19810
21026
  audio: "passthrough"
19811
21027
  };
21028
+ function shouldWriteLossLine(input) {
21029
+ if (input.lastWritten === null) return {
21030
+ write: true,
21031
+ reason: "first"
21032
+ };
21033
+ if (input.sample.hasReceiverReport !== input.lastWritten.hasReceiverReport) return {
21034
+ write: true,
21035
+ reason: "reporting-changed"
21036
+ };
21037
+ if (Math.abs(input.sample.lossPct - input.lastWritten.lossPct) >= 2) return {
21038
+ write: true,
21039
+ reason: "loss-moved"
21040
+ };
21041
+ if (input.ticksSinceWrite >= 10) return {
21042
+ write: true,
21043
+ reason: "heartbeat"
21044
+ };
21045
+ return {
21046
+ write: false,
21047
+ reason: "quiet"
21048
+ };
21049
+ }
19812
21050
  //#endregion
19813
21051
  //#region src/stream-broker/webrtc/mdns-resolve.ts
19814
21052
  /**
@@ -20784,6 +22022,14 @@ function selectAudioClock(mimeType) {
20784
22022
  }
20785
22023
  //#endregion
20786
22024
  //#region src/stream-broker/webrtc/session.ts
22025
+ /**
22026
+ * Adaptive WebRTC session — extends the base session pattern with:
22027
+ * - RTCP Receiver Report monitoring (packet loss, jitter, RTT)
22028
+ * - Source replacement (replaceTrack for seamless quality switching)
22029
+ * - Stats emission for the AdaptiveController
22030
+ *
22031
+ * Uses werift (optional peer dependency) for server-side WebRTC.
22032
+ */
20787
22033
  var _werift;
20788
22034
  async function loadWerift() {
20789
22035
  if (_werift) return _werift;
@@ -21090,6 +22336,10 @@ var AdaptiveSession = class AdaptiveSession {
21090
22336
  /** Log the camera's actual H.264 profile-level-id once per session. */
21091
22337
  profileLogged = false;
21092
22338
  onStats;
22339
+ consumerKind;
22340
+ /** The last loss sample actually WRITTEN, and how many ticks ago. */
22341
+ lastLossWritten = null;
22342
+ lossTicksSinceWrite = 0;
21093
22343
  debug;
21094
22344
  sourceCodec;
21095
22345
  /**
@@ -21399,6 +22649,7 @@ var AdaptiveSession = class AdaptiveSession {
21399
22649
  this.intercom = options.intercom;
21400
22650
  this.iceConfig = options.iceConfig;
21401
22651
  this.onStats = options.onStats;
22652
+ this.consumerKind = options.consumerKind ?? "unknown";
21402
22653
  this.debug = options.debug ?? false;
21403
22654
  this.sourceCodec = options.sourceCodec ?? "H264";
21404
22655
  this.transcodeToBaseline = options.transcodeToBaseline ?? false;
@@ -23401,6 +24652,37 @@ var AdaptiveSession = class AdaptiveSession {
23401
24652
  selectedRemoteType: nominated?.remoteType ?? "none",
23402
24653
  selectedRemoteAddr: nominated?.remoteAddr ?? "none"
23403
24654
  } });
24655
+ const rembRaw = this.videoSender?.receiverEstimatedMaxBitrate;
24656
+ const rembBps = typeof rembRaw === "bigint" && rembRaw > 0n ? Number(rembRaw) : -1;
24657
+ const sample = {
24658
+ hasReceiverReport: report !== void 0,
24659
+ lossPct: report ? Math.round(fractionLost / 256 * 100) : -1
24660
+ };
24661
+ this.lossTicksSinceWrite += 1;
24662
+ const verdict = shouldWriteLossLine({
24663
+ sample,
24664
+ lastWritten: this.lastLossWritten,
24665
+ ticksSinceWrite: this.lossTicksSinceWrite
24666
+ });
24667
+ if (verdict.write) {
24668
+ this.lastLossWritten = sample;
24669
+ this.lossTicksSinceWrite = 0;
24670
+ this.logger.info("webrtc loss", {
24671
+ tags: { deviceId: this.deviceId },
24672
+ meta: {
24673
+ sessionId: this.sessionId,
24674
+ consumerKind: this.consumerKind,
24675
+ why: verdict.reason,
24676
+ hasReceiverReport: sample.hasReceiverReport,
24677
+ rembBps,
24678
+ lossPct: sample.lossPct,
24679
+ packetsLost,
24680
+ jitterMs: report ? Math.round(jitter / 90) : -1,
24681
+ rttMs: report ? Math.round(rtt * 1e3) : -1,
24682
+ rtpPacketsSent: this.rtpPacketsSent
24683
+ }
24684
+ });
24685
+ }
23404
24686
  if (report && this.onStats) this.onStats({
23405
24687
  sessionId: this.sessionId,
23406
24688
  packetLoss: fractionLost / 256,
@@ -28629,7 +29911,8 @@ var BrokerWebrtcServer = class {
28629
29911
  logger: sessionLogger,
28630
29912
  deviceId,
28631
29913
  rtpBootstrap: useRtpRepacketizer ? () => broker.getRtpPreBuffer() : void 0,
28632
- debug: opts.streamingDebug === true
29914
+ debug: opts.streamingDebug === true,
29915
+ consumerKind: baseAttribution.kind
28633
29916
  });
28634
29917
  const seedFromSdp = (ps) => {
28635
29918
  if (sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
@@ -29012,7 +30295,11 @@ var BrokerWebrtcServer = class {
29012
30295
  /** Live signaling state for a session — used by the webrtc-session
29013
30296
  * provider's getSessionState. Returns a null pending when unknown. */
29014
30297
  getSessionRenegotiationState(sessionId) {
29015
- return { pendingRenegotiation: this.sessions.get(sessionId)?.session.pendingRenegotiation ?? null };
30298
+ const entry = this.sessions.get(sessionId);
30299
+ return {
30300
+ pendingRenegotiation: entry?.session.pendingRenegotiation ?? null,
30301
+ alive: entry !== void 0
30302
+ };
29016
30303
  }
29017
30304
  /**
29018
30305
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
@@ -29422,12 +30709,15 @@ var CameraStreamsProvider = class {
29422
30709
  const accept = requirements.acceptCodecs ?? [];
29423
30710
  const minHeight = requirements.minHeight ?? 0;
29424
30711
  const minWidth = requirements.minWidth ?? 0;
30712
+ const maxHeight = requirements.maxHeight ?? Number.POSITIVE_INFINITY;
30713
+ const allowTranscode = requirements.allowTranscode === true;
29425
30714
  const candidates = streams.filter((s) => {
29426
30715
  if (!isSelectable(s)) return false;
29427
30716
  if (excludeDerived && s.camStreamId.startsWith("derived:")) return false;
29428
- if (accept.length > 0 && !codecMatches(s.codec, accept)) return false;
30717
+ if (!allowTranscode && accept.length > 0 && !codecMatches(s.codec, accept)) return false;
29429
30718
  if ((s.resolution?.height ?? 0) < minHeight) return false;
29430
30719
  if ((s.resolution?.width ?? 0) < minWidth) return false;
30720
+ if ((s.resolution?.height ?? 0) > maxHeight) return false;
29431
30721
  return true;
29432
30722
  });
29433
30723
  if (candidates.length === 0) {
@@ -29472,24 +30762,35 @@ var CameraStreamsProvider = class {
29472
30762
  for (let i = 0; i < preferredProviders.length; i += 1) if (camStreamId.startsWith(preferredProviders[i])) return i;
29473
30763
  return preferredProviders.length;
29474
30764
  };
29475
- candidates.sort((a, b) => {
29476
- const ra = providerRank(a.camStreamId);
29477
- const rb = providerRank(b.camStreamId);
29478
- if (ra !== rb) return ra - rb;
29479
- const ha = a.resolution?.height ?? 0;
29480
- const hb = b.resolution?.height ?? 0;
29481
- return resolutionPreference === "highest" ? hb - ha : ha - hb;
29482
- });
29483
- const winner = candidates[0];
30765
+ const ranked = rankStreams(candidates.map((s) => ({
30766
+ camStreamId: s.camStreamId,
30767
+ ...s.codec === void 0 ? {} : { codec: s.codec },
30768
+ ...s.resolution ? {
30769
+ height: s.resolution.height,
30770
+ width: s.resolution.width
30771
+ } : {}
30772
+ })), {
30773
+ acceptCodecs: accept,
30774
+ allowTranscode,
30775
+ preferredProviders,
30776
+ resolutionPreference,
30777
+ ...preferences?.targetHeight === void 0 ? {} : { targetHeight: preferences.targetHeight }
30778
+ });
30779
+ if (ranked === null) return null;
30780
+ const winner = candidates.find((s) => s.camStreamId === ranked.candidate.camStreamId);
30781
+ if (winner === void 0) return null;
29484
30782
  const reasonParts = [];
29485
30783
  reasonParts.push(`codec=${winner.codec ?? "unknown"}`);
29486
30784
  if (winner.resolution) reasonParts.push(`res=${winner.resolution.width}x${winner.resolution.height}`);
29487
30785
  reasonParts.push(`provider=${providerRank(winner.camStreamId)}`);
30786
+ reasonParts.push(ranked.transcodes ? "transcode" : "passthrough");
30787
+ if (preferences?.targetHeight !== void 0) reasonParts.push(`target=${preferences.targetHeight}`);
29488
30788
  if (requireSibling.length > 0) reasonParts.push(`bypassed=${requireSibling.join("|")}`);
29489
30789
  return {
29490
30790
  camStreamId: winner.camStreamId,
29491
30791
  codec: winner.codec,
29492
30792
  resolution: winner.resolution,
30793
+ transcodes: ranked.transcodes,
29493
30794
  reason: reasonParts.join(" ")
29494
30795
  };
29495
30796
  }
@@ -29961,6 +31262,9 @@ var StreamBrokerAddon = class extends BaseAddon {
29961
31262
  const localNode = this.ctx.kernel.localNodeId;
29962
31263
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
29963
31264
  this.brokerManager.setApiAccess(this.ctx.api, localNodeId, this.ctx.kernel.hwaccel);
31265
+ this.brokerManager.setDeviceSignalReader(async (deviceId) => {
31266
+ return (await (await this.ctx.fetchDevice(deviceId)).streamSignals?.listSignals({}) ?? []).map((s) => s.code);
31267
+ });
29964
31268
  await this.loadFfmpegConfig();
29965
31269
  await this.declareRowCollections();
29966
31270
  await this.purgeFlattenedSettingsKeys();