@camstack/addon-pipeline 1.2.50 → 1.2.52

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 (32) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-Ci2-N-XC.js → dist-CzFJeiG1.js} +16214 -16021
  6. package/dist/{dist-hRt5QB5i.mjs → dist-gB3o0cyM.mjs} +16122 -15935
  7. package/dist/{event-loop-stall-monitor-B_EOn2Q1.js → event-loop-stall-monitor-DSPDQYjc.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-DUSB3ymZ.mjs → event-loop-stall-monitor-PxIaVeyh.mjs} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +88 -18
  12. package/dist/pipeline-runner/index.mjs +88 -18
  13. package/dist/recorder/index.js +91 -4
  14. package/dist/recorder/index.mjs +91 -4
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.mjs +1 -1
  17. package/dist/stream-broker/_stub.js +1 -1
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DwYxOvHx.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DAeBhR8B.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bl9zivOk.mjs +26 -0
  20. package/dist/stream-broker/{hostInit-yLEcTDS8.mjs → hostInit-DniAXKLy.mjs} +2 -2
  21. package/dist/stream-broker/index.js +336 -311
  22. package/dist/stream-broker/index.mjs +336 -311
  23. package/dist/stream-broker/remoteEntry.js +1 -1
  24. package/dist/{worker-protocol-CxpdncMB.js → worker-protocol-BQlELGzv.js} +1 -1
  25. package/dist/{worker-protocol-D_6eAFbv.mjs → worker-protocol-BhhX7J4-.mjs} +1 -1
  26. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-kAtChdz2.js → MaskShapeCanvas-DI4BY7W2-B1gpA9sI.js} +1 -1
  27. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DEwkvjzt.js → MotionZonesSettings-NcxxQN8r-DV6vvNDq.js} +1 -1
  28. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Brv9VL0h.js → PrivacyMaskSettings-APgPLF7p-BmUdRoZ9.js} +1 -1
  29. package/embed-dist/assets/{index-Dv7fSuDA.js → index-DsGzhGCW.js} +12 -12
  30. package/embed-dist/index.html +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-FL2mrnl7.mjs +0 -26
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-Ci2-N-XC.js");
1
+ const require_dist = require("./dist-CzFJeiG1.js");
2
2
  let node_perf_hooks = require("node:perf_hooks");
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -1,4 +1,4 @@
1
- import { L as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-hRt5QB5i.mjs";
1
+ import { R as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-gB3o0cyM.mjs";
2
2
  import { PerformanceObserver } from "node:perf_hooks";
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Ci2-N-XC.js");
5
+ const require_dist = require("../dist-CzFJeiG1.js");
6
6
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { B as motionDetectionCapability, I as evaluateZoneRules, at as BaseAddon, ct as DeviceType, ut as hydrateSchema } from "../dist-hRt5QB5i.mjs";
1
+ import { L as evaluateZoneRules, V as motionDetectionCapability, dt as hydrateSchema, lt as DeviceType, ot as BaseAddon } from "../dist-gB3o0cyM.mjs";
2
2
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-Ci2-N-XC.js");
7
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-B_EOn2Q1.js");
8
- const require_worker_protocol = require("../worker-protocol-CxpdncMB.js");
6
+ const require_dist = require("../dist-CzFJeiG1.js");
7
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DSPDQYjc.js");
8
+ const require_worker_protocol = require("../worker-protocol-BQlELGzv.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
11
11
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
@@ -543,11 +543,35 @@ function cropRgb(frame, bbox, maxWidth) {
543
543
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
544
544
  var SIGKILL_FALLBACK_MS = 1e3;
545
545
  /**
546
- * Default timeout for the `pull` round-trip preceding the FIRST delivered
547
- * frame. Task 0 live-measured cold-start (restream dial + hwaccel context +
548
- * keyframe wait) at 5-8s, so this must tolerate a slow-but-healthy start.
546
+ * How often the cold-start wait REPORTS that it is still waiting. Task 0
547
+ * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
548
+ * 5-8s, so this must tolerate a slow-but-healthy start.
549
+ *
550
+ * It is no longer the point at which the session is abandoned — see
551
+ * {@link DEFAULT_FIRST_FRAME_MAX_WAIT_MS}.
549
552
  */
550
553
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
554
+ /**
555
+ * Hard ceiling on the TOTAL wait for the FIRST frame of a dial.
556
+ *
557
+ * The first decodable frame is not a fixed cost: it is one IDR interval plus
558
+ * every GOP the decoder has to discard. Measured on device 618 (2560x1440
559
+ * h264, 8.0 s IDR interval, `Invalid NAL unit` / `corrupt decoded frame` in its
560
+ * bitstream), dial → first decoded frame over the broker's own muted restream
561
+ * was 1.8 / 6.5 / 7.0 / 7.2 / 9.2 / 10.0 / 10.8 / 12.9 / 14.9 / 20.0 / 30.3 /
562
+ * >40 s across 12 trials — against a healthy 587's 2.0 s ± 0.02.
563
+ *
564
+ * Abandoning at 20 s was therefore not a timeout, it was a coin flip, and the
565
+ * recovery made it worse: ending the session RE-DIALS, which throws the
566
+ * accumulated wait away and re-pays the RTSP setup + probe from zero, and two
567
+ * ends inside 60 s DETACH the camera. That loop cost 618 25.9% of its assigned
568
+ * decode airtime in one day (325 of 1257 ten-second windows delivered nothing).
569
+ *
570
+ * The wait is bounded instead of deadlined: the worker keeps the outstanding
571
+ * `pull` latched, so re-arming costs nothing but the wait, and the ceiling is
572
+ * what stops a genuinely dead input from holding a spawn permit forever.
573
+ */
574
+ var DEFAULT_FIRST_FRAME_MAX_WAIT_MS = 45e3;
551
575
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
552
576
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
553
577
  /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
@@ -567,8 +591,10 @@ function toError(err) {
567
591
  var SessionDecodeSession = class {
568
592
  child;
569
593
  logger;
594
+ deviceId;
570
595
  firstFrameTimeoutMs;
571
596
  frameTimeoutMs;
597
+ firstFrameMaxWaitMs;
572
598
  pendingPull = null;
573
599
  pendingToBuffers = /* @__PURE__ */ new Map();
574
600
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
@@ -580,11 +606,15 @@ var SessionDecodeSession = class {
580
606
  sigkillTimer = null;
581
607
  pullTimeoutTimer = null;
582
608
  firstFrameReceived = false;
583
- constructor(child, logger, firstFrameTimeoutMs, frameTimeoutMs) {
609
+ /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
610
+ firstPullAt = 0;
611
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs) {
584
612
  this.child = child;
585
613
  this.logger = logger;
614
+ this.deviceId = deviceId;
586
615
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
587
616
  this.frameTimeoutMs = frameTimeoutMs;
617
+ this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
588
618
  this.child.on("message", (m) => this.handleMessage(m));
589
619
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
590
620
  }
@@ -604,6 +634,7 @@ var SessionDecodeSession = class {
604
634
  */
605
635
  pullNext() {
606
636
  if (this.exited) return Promise.resolve({ kind: "ended" });
637
+ if (this.firstPullAt === 0) this.firstPullAt = Date.now();
607
638
  return new Promise((resolve, reject) => {
608
639
  this.pendingPull = {
609
640
  resolve,
@@ -785,25 +816,63 @@ var SessionDecodeSession = class {
785
816
  }
786
817
  /**
787
818
  * Fires when a `pull` round-trip outlives its liveness timeout — the child
788
- * stopped producing frames WITHOUT exiting (a hung decode). Settles the
789
- * outstanding pull as `ended` (not a throw) so the consumer's async
790
- * generator resumes cleanly, runs its `finally`, and tears the child down
791
- * via {@link teardown} which sends `stop`, `kill()`s, and arms the 1s
792
- * SIGKILL fallback.
819
+ * has not produced a frame and has not exited.
820
+ *
821
+ * BEFORE the first frame this is not yet a failure. The worker latches the
822
+ * outstanding `pull` (`decode-worker-child.handlePull` sets `pendingPull` and
823
+ * delivers on the next decoded frame), so re-arming the timer costs nothing
824
+ * but the wait and the session keeps the dial, the RTSP setup and the probe
825
+ * it has already paid for. Only the {@link firstFrameMaxWaitMs} ceiling ends
826
+ * it — see that constant for the measurement that set it.
827
+ *
828
+ * AFTER the first frame the deadline is unchanged: a steady stream that stops
829
+ * is a hung decode, and ending settles the outstanding pull as `ended` (not a
830
+ * throw) so the consumer's async generator resumes cleanly, runs its
831
+ * `finally`, and tears the child down via {@link teardown}.
832
+ *
833
+ * Every line here carries `tags: { deviceId }` and a `reason`, because
834
+ * "detaching" a camera is the loudest dropped work in this path and an
835
+ * operator has to be able to answer "why did 618 stop" from the logs alone.
793
836
  */
794
837
  handlePullTimeout(timeoutMs) {
795
838
  this.pullTimeoutTimer = null;
796
839
  const waiter = this.pendingPull;
797
840
  if (!waiter) return;
841
+ const waitedMs = this.firstPullAt === 0 ? timeoutMs : Date.now() - this.firstPullAt;
842
+ if (!this.firstFrameReceived && waitedMs < this.firstFrameMaxWaitMs) {
843
+ this.logger.warn("session-decode-coordinator: still waiting for its first decodable frame — holding the dial", {
844
+ tags: { deviceId: this.deviceId },
845
+ meta: {
846
+ waitedMs,
847
+ reportEveryMs: timeoutMs,
848
+ maxWaitMs: this.firstFrameMaxWaitMs,
849
+ hint: "long IDR interval or a discarded GOP looks exactly like this"
850
+ }
851
+ });
852
+ this.armPullTimeout();
853
+ return;
854
+ }
798
855
  this.pendingPull = null;
799
- this.logger.warn(`session-decode-coordinator: decode worker produced no frame within ${timeoutMs}ms — ending session`, { meta: {
800
- timeoutMs,
801
- firstFrameReceived: this.firstFrameReceived
802
- } });
856
+ const reason = this.firstFrameReceived ? "stalled-after-first-frame" : "no-first-frame";
857
+ this.logger.warn(this.firstFrameReceived ? "session-decode-coordinator: gave up waiting for the next frame — ending session" : "session-decode-coordinator: gave up waiting for the first decodable frame — ending session", {
858
+ tags: { deviceId: this.deviceId },
859
+ meta: {
860
+ reason,
861
+ waitedMs,
862
+ timeoutMs,
863
+ ...this.firstFrameReceived ? {} : { maxWaitMs: this.firstFrameMaxWaitMs }
864
+ }
865
+ });
803
866
  waiter.resolve({ kind: "ended" });
804
867
  }
868
+ /**
869
+ * Arm the next round-trip deadline. Before the first frame the timer is
870
+ * clamped to what is LEFT of the ceiling, so the give-up lands exactly on
871
+ * {@link firstFrameMaxWaitMs} instead of on the next report boundary past it
872
+ * — otherwise a 45 s ceiling reported every 20 s would really give up at 60 s.
873
+ */
805
874
  armPullTimeout() {
806
- const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : this.firstFrameTimeoutMs;
875
+ const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : Math.max(1, Math.min(this.firstFrameTimeoutMs, this.firstFrameMaxWaitMs - (this.firstPullAt === 0 ? 0 : Date.now() - this.firstPullAt)));
807
876
  this.pullTimeoutTimer = setTimeout(() => this.handlePullTimeout(timeoutMs), timeoutMs);
808
877
  this.pullTimeoutTimer.unref?.();
809
878
  }
@@ -925,8 +994,9 @@ function buildFrameImage(session, reply) {
925
994
  function createSessionDecodeCoordinator(deps) {
926
995
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
927
996
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
997
+ const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
928
998
  return { async *generateVideoFrames(source, opts) {
929
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, firstFrameTimeoutMs, frameTimeoutMs);
999
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs);
930
1000
  session.start(source, opts);
931
1001
  try {
932
1002
  while (true) {
@@ -1,6 +1,6 @@
1
- import { $ as errMsg, A as deriveDetailCropRect, At as EventCategory, Ct as lazy, D as customAction, Et as object, H as pickNativeLeaseOverride, Ot as string, Tt as number, V as pickDetailCropConvention, W as pipelineRunnerCapability, at as BaseAddon, bt as array, k as defineCustomActions, l as DEVICE_BACKEND_TO_FORMAT, lt as createEvent, mt as nodePin, s as DEFAULT_DETAIL_CROP_CONVENTION, wt as literal, xt as boolean, yt as _enum } from "../dist-hRt5QB5i.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-DUSB3ymZ.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-D_6eAFbv.mjs";
1
+ import { A as defineCustomActions, Dt as object, Et as number, G as pipelineRunnerCapability, H as pickDetailCropConvention, O as customAction, St as boolean, Tt as literal, U as pickNativeLeaseOverride, bt as _enum, et as errMsg, ht as nodePin, j as deriveDetailCropRect, jt as EventCategory, kt as string, ot as BaseAddon, s as DEFAULT_DETAIL_CROP_CONVENTION, u as DEVICE_BACKEND_TO_FORMAT, ut as createEvent, wt as lazy, xt as array } from "../dist-gB3o0cyM.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-PxIaVeyh.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-BhhX7J4-.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
6
6
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
@@ -536,11 +536,35 @@ function cropRgb(frame, bbox, maxWidth) {
536
536
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
537
537
  var SIGKILL_FALLBACK_MS = 1e3;
538
538
  /**
539
- * Default timeout for the `pull` round-trip preceding the FIRST delivered
540
- * frame. Task 0 live-measured cold-start (restream dial + hwaccel context +
541
- * keyframe wait) at 5-8s, so this must tolerate a slow-but-healthy start.
539
+ * How often the cold-start wait REPORTS that it is still waiting. Task 0
540
+ * live-measured cold-start (restream dial + hwaccel context + keyframe wait) at
541
+ * 5-8s, so this must tolerate a slow-but-healthy start.
542
+ *
543
+ * It is no longer the point at which the session is abandoned — see
544
+ * {@link DEFAULT_FIRST_FRAME_MAX_WAIT_MS}.
542
545
  */
543
546
  var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
547
+ /**
548
+ * Hard ceiling on the TOTAL wait for the FIRST frame of a dial.
549
+ *
550
+ * The first decodable frame is not a fixed cost: it is one IDR interval plus
551
+ * every GOP the decoder has to discard. Measured on device 618 (2560x1440
552
+ * h264, 8.0 s IDR interval, `Invalid NAL unit` / `corrupt decoded frame` in its
553
+ * bitstream), dial → first decoded frame over the broker's own muted restream
554
+ * was 1.8 / 6.5 / 7.0 / 7.2 / 9.2 / 10.0 / 10.8 / 12.9 / 14.9 / 20.0 / 30.3 /
555
+ * >40 s across 12 trials — against a healthy 587's 2.0 s ± 0.02.
556
+ *
557
+ * Abandoning at 20 s was therefore not a timeout, it was a coin flip, and the
558
+ * recovery made it worse: ending the session RE-DIALS, which throws the
559
+ * accumulated wait away and re-pays the RTSP setup + probe from zero, and two
560
+ * ends inside 60 s DETACH the camera. That loop cost 618 25.9% of its assigned
561
+ * decode airtime in one day (325 of 1257 ten-second windows delivered nothing).
562
+ *
563
+ * The wait is bounded instead of deadlined: the worker keeps the outstanding
564
+ * `pull` latched, so re-arming costs nothing but the wait, and the ceiling is
565
+ * what stops a genuinely dead input from holding a spawn permit forever.
566
+ */
567
+ var DEFAULT_FIRST_FRAME_MAX_WAIT_MS = 45e3;
544
568
  /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
545
569
  var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
546
570
  /** Narrow an 'exit' payload to {@link WorkerExitInfo} (fakes may emit undefined). */
@@ -560,8 +584,10 @@ function toError(err) {
560
584
  var SessionDecodeSession = class {
561
585
  child;
562
586
  logger;
587
+ deviceId;
563
588
  firstFrameTimeoutMs;
564
589
  frameTimeoutMs;
590
+ firstFrameMaxWaitMs;
565
591
  pendingPull = null;
566
592
  pendingToBuffers = /* @__PURE__ */ new Map();
567
593
  /** Outstanding `nativeCrop` requests, keyed by the monotonic `requestId`. */
@@ -573,11 +599,15 @@ var SessionDecodeSession = class {
573
599
  sigkillTimer = null;
574
600
  pullTimeoutTimer = null;
575
601
  firstFrameReceived = false;
576
- constructor(child, logger, firstFrameTimeoutMs, frameTimeoutMs) {
602
+ /** `Date.now()` of the FIRST `pull` — the origin of the cold-start ceiling. */
603
+ firstPullAt = 0;
604
+ constructor(child, logger, deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs) {
577
605
  this.child = child;
578
606
  this.logger = logger;
607
+ this.deviceId = deviceId;
579
608
  this.firstFrameTimeoutMs = firstFrameTimeoutMs;
580
609
  this.frameTimeoutMs = frameTimeoutMs;
610
+ this.firstFrameMaxWaitMs = firstFrameMaxWaitMs;
581
611
  this.child.on("message", (m) => this.handleMessage(m));
582
612
  this.child.on("exit", (m) => this.handleExit(toWorkerExitInfo(m)));
583
613
  }
@@ -597,6 +627,7 @@ var SessionDecodeSession = class {
597
627
  */
598
628
  pullNext() {
599
629
  if (this.exited) return Promise.resolve({ kind: "ended" });
630
+ if (this.firstPullAt === 0) this.firstPullAt = Date.now();
600
631
  return new Promise((resolve, reject) => {
601
632
  this.pendingPull = {
602
633
  resolve,
@@ -778,25 +809,63 @@ var SessionDecodeSession = class {
778
809
  }
779
810
  /**
780
811
  * Fires when a `pull` round-trip outlives its liveness timeout — the child
781
- * stopped producing frames WITHOUT exiting (a hung decode). Settles the
782
- * outstanding pull as `ended` (not a throw) so the consumer's async
783
- * generator resumes cleanly, runs its `finally`, and tears the child down
784
- * via {@link teardown} which sends `stop`, `kill()`s, and arms the 1s
785
- * SIGKILL fallback.
812
+ * has not produced a frame and has not exited.
813
+ *
814
+ * BEFORE the first frame this is not yet a failure. The worker latches the
815
+ * outstanding `pull` (`decode-worker-child.handlePull` sets `pendingPull` and
816
+ * delivers on the next decoded frame), so re-arming the timer costs nothing
817
+ * but the wait and the session keeps the dial, the RTSP setup and the probe
818
+ * it has already paid for. Only the {@link firstFrameMaxWaitMs} ceiling ends
819
+ * it — see that constant for the measurement that set it.
820
+ *
821
+ * AFTER the first frame the deadline is unchanged: a steady stream that stops
822
+ * is a hung decode, and ending settles the outstanding pull as `ended` (not a
823
+ * throw) so the consumer's async generator resumes cleanly, runs its
824
+ * `finally`, and tears the child down via {@link teardown}.
825
+ *
826
+ * Every line here carries `tags: { deviceId }` and a `reason`, because
827
+ * "detaching" a camera is the loudest dropped work in this path and an
828
+ * operator has to be able to answer "why did 618 stop" from the logs alone.
786
829
  */
787
830
  handlePullTimeout(timeoutMs) {
788
831
  this.pullTimeoutTimer = null;
789
832
  const waiter = this.pendingPull;
790
833
  if (!waiter) return;
834
+ const waitedMs = this.firstPullAt === 0 ? timeoutMs : Date.now() - this.firstPullAt;
835
+ if (!this.firstFrameReceived && waitedMs < this.firstFrameMaxWaitMs) {
836
+ this.logger.warn("session-decode-coordinator: still waiting for its first decodable frame — holding the dial", {
837
+ tags: { deviceId: this.deviceId },
838
+ meta: {
839
+ waitedMs,
840
+ reportEveryMs: timeoutMs,
841
+ maxWaitMs: this.firstFrameMaxWaitMs,
842
+ hint: "long IDR interval or a discarded GOP looks exactly like this"
843
+ }
844
+ });
845
+ this.armPullTimeout();
846
+ return;
847
+ }
791
848
  this.pendingPull = null;
792
- this.logger.warn(`session-decode-coordinator: decode worker produced no frame within ${timeoutMs}ms — ending session`, { meta: {
793
- timeoutMs,
794
- firstFrameReceived: this.firstFrameReceived
795
- } });
849
+ const reason = this.firstFrameReceived ? "stalled-after-first-frame" : "no-first-frame";
850
+ this.logger.warn(this.firstFrameReceived ? "session-decode-coordinator: gave up waiting for the next frame — ending session" : "session-decode-coordinator: gave up waiting for the first decodable frame — ending session", {
851
+ tags: { deviceId: this.deviceId },
852
+ meta: {
853
+ reason,
854
+ waitedMs,
855
+ timeoutMs,
856
+ ...this.firstFrameReceived ? {} : { maxWaitMs: this.firstFrameMaxWaitMs }
857
+ }
858
+ });
796
859
  waiter.resolve({ kind: "ended" });
797
860
  }
861
+ /**
862
+ * Arm the next round-trip deadline. Before the first frame the timer is
863
+ * clamped to what is LEFT of the ceiling, so the give-up lands exactly on
864
+ * {@link firstFrameMaxWaitMs} instead of on the next report boundary past it
865
+ * — otherwise a 45 s ceiling reported every 20 s would really give up at 60 s.
866
+ */
798
867
  armPullTimeout() {
799
- const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : this.firstFrameTimeoutMs;
868
+ const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : Math.max(1, Math.min(this.firstFrameTimeoutMs, this.firstFrameMaxWaitMs - (this.firstPullAt === 0 ? 0 : Date.now() - this.firstPullAt)));
800
869
  this.pullTimeoutTimer = setTimeout(() => this.handlePullTimeout(timeoutMs), timeoutMs);
801
870
  this.pullTimeoutTimer.unref?.();
802
871
  }
@@ -918,8 +987,9 @@ function buildFrameImage(session, reply) {
918
987
  function createSessionDecodeCoordinator(deps) {
919
988
  const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
920
989
  const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
990
+ const firstFrameMaxWaitMs = Math.max(deps.firstFrameMaxWaitMs ?? DEFAULT_FIRST_FRAME_MAX_WAIT_MS, firstFrameTimeoutMs);
921
991
  return { async *generateVideoFrames(source, opts) {
922
- const session = new SessionDecodeSession(deps.fork(source), deps.logger, firstFrameTimeoutMs, frameTimeoutMs);
992
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, source.deviceId, firstFrameTimeoutMs, frameTimeoutMs, firstFrameMaxWaitMs);
923
993
  session.start(source, opts);
924
994
  try {
925
995
  while (true) {
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-Ci2-N-XC.js");
2
+ const require_dist = require("../dist-CzFJeiG1.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_addon_utils = require("../addon-utils-DveoBR6G.js");
5
5
  let node_crypto = require("node:crypto");
@@ -3489,12 +3489,56 @@ function activeBandAt(config, date) {
3489
3489
  //#endregion
3490
3490
  //#region src/recorder/addon/band-decision.ts
3491
3491
  /**
3492
+ * Pure glue between a device's `RecordingConfig` and the v2 band engine: decide
3493
+ * whether recording should currently be running, and which finalized segments
3494
+ * an `events` band retains.
3495
+ *
3496
+ * The decision is enabled AND the band active at `now` (per the v2
3497
+ * `activeBandAt`/`bandActiveAt`) demands recording: a `continuous` band always
3498
+ * demands; an `events` band demands only within `postBufferSec` of a qualifying
3499
+ * trigger (B3). No bands / disabled => not recording.
3500
+ *
3501
+ * `preBufferSec` is retroactive — it is NOT a live-demand input; it retains
3502
+ * already-recorded segments BEFORE a trigger at keep/discard time
3503
+ * ({@link segmentRetainedForEvents}). This mirrors the old recorder's proven
3504
+ * policy split (start-on-trigger live gate + retroactive pre-buffer keep).
3505
+ *
3506
+ * Kept pure (clock + trigger state injected) so the attach/detach decision is
3507
+ * unit-testable without ffmpeg, the broker, the event bus, or a real wall clock.
3508
+ */
3509
+ /**
3510
+ * THE one place a band's pre/post buffers are read. An ABSENT field takes
3511
+ * {@link DEFAULT_EVENTS_BAND_BUFFER_SEC}; an explicit value — including `0` —
3512
+ * is honoured verbatim.
3513
+ *
3514
+ * Both callers below used to inline `?? 0`, which read "the operator omitted the
3515
+ * field" as "the operator asked for a zero-length window". For the live gate
3516
+ * that is not a policy, it is an impossibility: nothing can ever be recorded.
3517
+ * Two cameras sat in exactly that state.
3518
+ */
3519
+ function resolveBandBufferMs(band) {
3520
+ return {
3521
+ preMs: (band.preBufferSec ?? require_dist.DEFAULT_EVENTS_BAND_BUFFER_SEC.preBufferSec) * 1e3,
3522
+ postMs: (band.postBufferSec ?? require_dist.DEFAULT_EVENTS_BAND_BUFFER_SEC.postBufferSec) * 1e3
3523
+ };
3524
+ }
3525
+ /**
3526
+ * Can this band EVER open a live recording window? False for an `events` band
3527
+ * whose resolved post-buffer is zero (only reachable by authoring an explicit
3528
+ * `0`) or which lists no trigger at all: both are "enabled, and records nothing,
3529
+ * forever". The recorder warns on it rather than dropping the device silently.
3530
+ */
3531
+ function eventsBandCanEverDemand(band) {
3532
+ if (band.mode !== "events") return true;
3533
+ return (band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0) && resolveBandBufferMs(band).postMs > 0;
3534
+ }
3535
+ /**
3492
3536
  * Is an `events` band's live demand window open at `atMs`? True iff a trigger
3493
3537
  * the band listens for fired within `postBufferSec` of now. `preBufferSec` is
3494
3538
  * deliberately NOT consulted here (see file header).
3495
3539
  */
3496
3540
  function eventsBandDemanded(band, triggers, atMs) {
3497
- const postMs = (band.postBufferSec ?? 0) * 1e3;
3541
+ const { postMs } = resolveBandBufferMs(band);
3498
3542
  const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
3499
3543
  if (band.triggers?.motion && fresh(triggers.lastMotionMs)) return true;
3500
3544
  if (band.triggers?.audioThresholdDbfs != null && fresh(triggers.lastAudioMs)) return true;
@@ -3521,8 +3565,7 @@ function shouldRecordAt(config, triggers, at) {
3521
3565
  * This is where `preBufferSec` retroactively retains pre-trigger footage.
3522
3566
  */
3523
3567
  function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
3524
- const preMs = (band.preBufferSec ?? 0) * 1e3;
3525
- const postMs = (band.postBufferSec ?? 0) * 1e3;
3568
+ const { preMs, postMs } = resolveBandBufferMs(band);
3526
3569
  const overlaps = (lastMs) => lastMs != null && segStartMs <= lastMs + postMs && segEndMs >= lastMs - preMs;
3527
3570
  if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
3528
3571
  if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
@@ -4055,6 +4098,12 @@ var RecordingController = class {
4055
4098
  * alive, which is the only question here.
4056
4099
  */
4057
4100
  lastSegmentAt = /* @__PURE__ */ new Map();
4101
+ /**
4102
+ * Devices already named by {@link reportUnrecordable} — one warn per device
4103
+ * per "enabled but can never record" episode, re-armed as soon as the device
4104
+ * records again.
4105
+ */
4106
+ unrecordableReported = /* @__PURE__ */ new Set();
4058
4107
  restore = null;
4059
4108
  tickTimer = null;
4060
4109
  stopped = false;
@@ -4231,10 +4280,12 @@ var RecordingController = class {
4231
4280
  if (this.stopped) return;
4232
4281
  const config = await this.deps.loadConfig(deviceId);
4233
4282
  if (!shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()))) {
4283
+ this.reportUnrecordable(deviceId, config);
4234
4284
  await this.detachDevice(deviceId);
4235
4285
  this.restore?.remove(deviceId);
4236
4286
  return;
4237
4287
  }
4288
+ this.unrecordableReported.delete(deviceId);
4238
4289
  if (this.active.has(deviceId)) return;
4239
4290
  if (this.attaching.has(deviceId)) return;
4240
4291
  this.attaching.add(deviceId);
@@ -4245,6 +4296,42 @@ var RecordingController = class {
4245
4296
  }
4246
4297
  }
4247
4298
  /**
4299
+ * Name the `!wantRecording` drop when the device is ENABLED but its config can
4300
+ * never produce footage — the branch that hid this bug. Two shapes qualify,
4301
+ * and both mean "the operator asked for recording and will get none, forever":
4302
+ * an enabled config with no bands at all, and an `events` band that cannot
4303
+ * open a live window (no trigger listed, or an explicit `postBufferSec: 0`).
4304
+ *
4305
+ * NOT reported: a band that simply does not cover `now` (a night-only band at
4306
+ * noon), or an events band waiting for its next trigger. Those are the
4307
+ * feature working.
4308
+ *
4309
+ * One line per device until it records again ({@link unrecordableReported} is
4310
+ * cleared the moment `wantRecording` turns true), so the 30 s tick cannot turn
4311
+ * this into a log flood.
4312
+ */
4313
+ reportUnrecordable(deviceId, config) {
4314
+ if (!config.enabled) return;
4315
+ if (this.unrecordableReported.has(deviceId)) return;
4316
+ const bands = config.bands ?? [];
4317
+ if (bands.length === 0) {
4318
+ this.unrecordableReported.add(deviceId);
4319
+ this.deps.logger.warn("recorder: device is enabled but has no recording bands — nothing will ever be recorded", { tags: { deviceId } });
4320
+ return;
4321
+ }
4322
+ const band = activeBandAt({ bands }, new Date(this.now()));
4323
+ if (band === null || eventsBandCanEverDemand(band)) return;
4324
+ this.unrecordableReported.add(deviceId);
4325
+ this.deps.logger.warn("recorder: events band can never open a recording window — nothing will ever be recorded", {
4326
+ tags: { deviceId },
4327
+ meta: {
4328
+ postBufferSec: band.postBufferSec,
4329
+ triggers: band.triggers,
4330
+ reason: band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0 ? "postBufferSec is 0" : "no trigger configured"
4331
+ }
4332
+ });
4333
+ }
4334
+ /**
4248
4335
  * The attach body — extracted so the {@link attaching} in-flight guard in
4249
4336
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
4250
4337
  * SegmentWriter + watcher per profile, and records the set in `active`. A throw