@camstack/addon-pipeline 1.2.49 → 1.2.51

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-J2DeRZAN.js → dist-BsBEe_rX.js} +67 -1
  6. package/dist/{dist-CLJKoYEP.mjs → dist-Dkvk5ykl.mjs} +62 -2
  7. package/dist/{event-loop-stall-monitor-B6dqQjiX.mjs → event-loop-stall-monitor-BvtjRcal.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-Bkbiw20z.js → event-loop-stall-monitor-DqJXi0_B.js} +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 +127 -114
  12. package/dist/pipeline-runner/index.mjs +127 -114
  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 +57 -13
  16. package/dist/session-decode/decode-worker-child.mjs +57 -13
  17. package/dist/stream-broker/_stub.js +1 -1
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DZikMEgD.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CUZ0nS_r.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-Mv3UeZUE.mjs +26 -0
  20. package/dist/stream-broker/{hostInit-i24FVz-3.mjs → hostInit-B8O23a6l.mjs} +2 -2
  21. package/dist/stream-broker/index.js +1 -1
  22. package/dist/stream-broker/index.mjs +1 -1
  23. package/dist/stream-broker/remoteEntry.js +1 -1
  24. package/dist/{worker-protocol-BxPNiino.mjs → worker-protocol-B0c3i2km.mjs} +1 -1
  25. package/dist/{worker-protocol-CNi5srgV.js → worker-protocol-CWhkLkHx.js} +1 -1
  26. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-m7YpaY98.js → MaskShapeCanvas-DI4BY7W2-kAtChdz2.js} +1 -1
  27. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-619YTOjy.js → MotionZonesSettings-NcxxQN8r-DEwkvjzt.js} +1 -1
  28. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-B-k8v0O5.js → PrivacyMaskSettings-APgPLF7p-Brv9VL0h.js} +1 -1
  29. package/embed-dist/assets/{index-CYO4OY8_.js → index-Dv7fSuDA.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-Cj2xMupA.mjs +0 -26
@@ -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-CLJKoYEP.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-B6dqQjiX.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-BxPNiino.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-Dkvk5ykl.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-BvtjRcal.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-B0c3i2km.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";
@@ -1816,116 +1816,6 @@ var CropConventionSource = class {
1816
1816
  }
1817
1817
  };
1818
1818
  //#endregion
1819
- //#region src/pipeline-runner/native-lease-settings-source.ts
1820
- /**
1821
- * Where the runner gets the cluster-wide native-frame LEASE knobs.
1822
- *
1823
- * ## Why a settings read and not the attach config
1824
- *
1825
- * Identical reasoning to `crop-convention-source.ts`, and worth restating
1826
- * because the temptation is the same: `RunnerCameraConfig` is the obvious
1827
- * orchestrator→runner channel and it is the wrong one. Eleven call sites build
1828
- * one; a cluster-wide constant threaded through eleven builders eventually
1829
- * reaches ten of them, and the cameras attached by the forgotten builder just
1830
- * quietly run a different lease window. Instead the runner reads the OWNER
1831
- * addon's global settings directly, hub-routed by `AddonCallGateway`, so an
1832
- * agent runner transparently reads the hub-central store with no placement
1833
- * branching.
1834
- *
1835
- * ## Deliberately NOT node-scoped
1836
- *
1837
- * The read passes no `nodeId`. With one, `addon-settings` would project that
1838
- * node's scoped value and two nodes could hold different lease windows for the
1839
- * same camera — so whether a stored crop is native pixels or the ≤640 fallback
1840
- * would depend on where the balancer happened to place it, which is invisible
1841
- * from the stored media.
1842
- *
1843
- * ## What it hands out
1844
- *
1845
- * A {@link NativeLeaseSettingsOverride} — only the knobs the operator SET. The
1846
- * decode worker resolves the rest (env var, then the shipped default); see
1847
- * `session-decode/native-lease-config.ts` for the precedence. Handing out a
1848
- * fully-resolved triple here would erase the "operator set nothing" state and
1849
- * with it the env-var escape hatch.
1850
- */
1851
- /**
1852
- * The addon whose GLOBAL settings own the knobs. `pipeline-orchestrator` is
1853
- * hub-resident and neutral — the same owner as the detail-crop convention and
1854
- * the per-node decoder-backend selector.
1855
- */
1856
- var NATIVE_LEASE_OWNER_ADDON_ID = "pipeline-orchestrator";
1857
- /**
1858
- * Whether two overrides name the same knobs with the same values.
1859
- *
1860
- * **Every knob has to appear here.** This is the equality that decides whether
1861
- * a re-read is ADOPTED at all: a knob missing from the comparison is read from
1862
- * the hub, judged identical, and discarded, so the operator's change never
1863
- * reaches a decode worker and nothing anywhere says so. `admission` shipped
1864
- * missing from this list and did exactly that — the setting was stored, the
1865
- * hub served it, the runner fetched it, and every worker kept spawning with the
1866
- * old value.
1867
- */
1868
- function sameOverride(a, b) {
1869
- return a.ttlMs === b.ttlMs && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission;
1870
- }
1871
- /**
1872
- * TTL-cached read of the cluster native-lease knobs.
1873
- *
1874
- * {@link current} is synchronous because the fork that consumes it is;
1875
- * {@link refresh} is what talks to the hub, and the runner awaits it on the
1876
- * path that precedes a decode-worker spawn.
1877
- */
1878
- var NativeLeaseSettingsSource = class {
1879
- override = {};
1880
- lastReadAtMs = Number.NEGATIVE_INFINITY;
1881
- inFlight = null;
1882
- logger;
1883
- now;
1884
- ttlMs;
1885
- constructor(options) {
1886
- this.logger = options.logger;
1887
- this.now = options.now ?? (() => Date.now());
1888
- this.ttlMs = options.ttlMs ?? 3e4;
1889
- }
1890
- /** The operator's overrides as last read — `{}` until a read lands. */
1891
- current() {
1892
- return this.override;
1893
- }
1894
- /**
1895
- * Re-read if the cached value has expired. Concurrent callers share one
1896
- * in-flight read; a failure keeps the LAST KNOWN value rather than reverting
1897
- * to `{}`, because a revert would silently hand the next-spawned workers a
1898
- * different RAM profile than the ones already running.
1899
- */
1900
- async refresh(api) {
1901
- if (api === void 0) return this.override;
1902
- if (this.now() - this.lastReadAtMs < this.ttlMs) return this.override;
1903
- this.inFlight ??= this.read(api).finally(() => {
1904
- this.inFlight = null;
1905
- });
1906
- await this.inFlight;
1907
- return this.override;
1908
- }
1909
- async read(api) {
1910
- try {
1911
- const next = pickNativeLeaseOverride(await api.addonSettings.getGlobalSettings.query({ addonId: NATIVE_LEASE_OWNER_ADDON_ID }));
1912
- this.lastReadAtMs = this.now();
1913
- if (sameOverride(next, this.override)) return;
1914
- this.logger.info("native lease settings changed — applies to NEW decode sessions", { meta: {
1915
- from: { ...this.override },
1916
- to: { ...next }
1917
- } });
1918
- this.override = next;
1919
- } catch (err) {
1920
- this.logger.warn("native lease settings read failed — keeping the last known value", { meta: {
1921
- owner: NATIVE_LEASE_OWNER_ADDON_ID,
1922
- inUse: { ...this.override },
1923
- error: err instanceof Error ? err.message : String(err)
1924
- } });
1925
- }
1926
- }
1927
- };
1928
- //#endregion
1929
1819
  //#region src/pipeline-runner/detail-subtree.ts
1930
1820
  /** Did the native-lease fetch report a miss (rather than return pixels)? */
1931
1821
  function isNativeCropMiss(fetch) {
@@ -2500,6 +2390,116 @@ function resolveNativeCropSource(isFullFrame, nativeHit) {
2500
2390
  return isFullFrame ? "ram-fullframe" : "miss";
2501
2391
  }
2502
2392
  //#endregion
2393
+ //#region src/pipeline-runner/native-lease-settings-source.ts
2394
+ /**
2395
+ * Where the runner gets the cluster-wide native-frame LEASE knobs.
2396
+ *
2397
+ * ## Why a settings read and not the attach config
2398
+ *
2399
+ * Identical reasoning to `crop-convention-source.ts`, and worth restating
2400
+ * because the temptation is the same: `RunnerCameraConfig` is the obvious
2401
+ * orchestrator→runner channel and it is the wrong one. Eleven call sites build
2402
+ * one; a cluster-wide constant threaded through eleven builders eventually
2403
+ * reaches ten of them, and the cameras attached by the forgotten builder just
2404
+ * quietly run a different lease window. Instead the runner reads the OWNER
2405
+ * addon's global settings directly, hub-routed by `AddonCallGateway`, so an
2406
+ * agent runner transparently reads the hub-central store with no placement
2407
+ * branching.
2408
+ *
2409
+ * ## Deliberately NOT node-scoped
2410
+ *
2411
+ * The read passes no `nodeId`. With one, `addon-settings` would project that
2412
+ * node's scoped value and two nodes could hold different lease windows for the
2413
+ * same camera — so whether a stored crop is native pixels or the ≤640 fallback
2414
+ * would depend on where the balancer happened to place it, which is invisible
2415
+ * from the stored media.
2416
+ *
2417
+ * ## What it hands out
2418
+ *
2419
+ * A {@link NativeLeaseSettingsOverride} — only the knobs the operator SET. The
2420
+ * decode worker resolves the rest (env var, then the shipped default); see
2421
+ * `session-decode/native-lease-config.ts` for the precedence. Handing out a
2422
+ * fully-resolved triple here would erase the "operator set nothing" state and
2423
+ * with it the env-var escape hatch.
2424
+ */
2425
+ /**
2426
+ * The addon whose GLOBAL settings own the knobs. `pipeline-orchestrator` is
2427
+ * hub-resident and neutral — the same owner as the detail-crop convention and
2428
+ * the per-node decoder-backend selector.
2429
+ */
2430
+ var NATIVE_LEASE_OWNER_ADDON_ID = "pipeline-orchestrator";
2431
+ /**
2432
+ * Whether two overrides name the same knobs with the same values.
2433
+ *
2434
+ * **Every knob has to appear here.** This is the equality that decides whether
2435
+ * a re-read is ADOPTED at all: a knob missing from the comparison is read from
2436
+ * the hub, judged identical, and discarded, so the operator's change never
2437
+ * reaches a decode worker and nothing anywhere says so. `admission` shipped
2438
+ * missing from this list and did exactly that — the setting was stored, the
2439
+ * hub served it, the runner fetched it, and every worker kept spawning with the
2440
+ * old value.
2441
+ */
2442
+ function sameOverride(a, b) {
2443
+ return a.ttlMs === b.ttlMs && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission;
2444
+ }
2445
+ /**
2446
+ * TTL-cached read of the cluster native-lease knobs.
2447
+ *
2448
+ * {@link current} is synchronous because the fork that consumes it is;
2449
+ * {@link refresh} is what talks to the hub, and the runner awaits it on the
2450
+ * path that precedes a decode-worker spawn.
2451
+ */
2452
+ var NativeLeaseSettingsSource = class {
2453
+ override = {};
2454
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
2455
+ inFlight = null;
2456
+ logger;
2457
+ now;
2458
+ ttlMs;
2459
+ constructor(options) {
2460
+ this.logger = options.logger;
2461
+ this.now = options.now ?? (() => Date.now());
2462
+ this.ttlMs = options.ttlMs ?? 3e4;
2463
+ }
2464
+ /** The operator's overrides as last read — `{}` until a read lands. */
2465
+ current() {
2466
+ return this.override;
2467
+ }
2468
+ /**
2469
+ * Re-read if the cached value has expired. Concurrent callers share one
2470
+ * in-flight read; a failure keeps the LAST KNOWN value rather than reverting
2471
+ * to `{}`, because a revert would silently hand the next-spawned workers a
2472
+ * different RAM profile than the ones already running.
2473
+ */
2474
+ async refresh(api) {
2475
+ if (api === void 0) return this.override;
2476
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.override;
2477
+ this.inFlight ??= this.read(api).finally(() => {
2478
+ this.inFlight = null;
2479
+ });
2480
+ await this.inFlight;
2481
+ return this.override;
2482
+ }
2483
+ async read(api) {
2484
+ try {
2485
+ const next = pickNativeLeaseOverride(await api.addonSettings.getGlobalSettings.query({ addonId: NATIVE_LEASE_OWNER_ADDON_ID }));
2486
+ this.lastReadAtMs = this.now();
2487
+ if (sameOverride(next, this.override)) return;
2488
+ this.logger.info("native lease settings changed — applies to NEW decode sessions", { meta: {
2489
+ from: { ...this.override },
2490
+ to: { ...next }
2491
+ } });
2492
+ this.override = next;
2493
+ } catch (err) {
2494
+ this.logger.warn("native lease settings read failed — keeping the last known value", { meta: {
2495
+ owner: NATIVE_LEASE_OWNER_ADDON_ID,
2496
+ inUse: { ...this.override },
2497
+ error: err instanceof Error ? err.message : String(err)
2498
+ } });
2499
+ }
2500
+ }
2501
+ };
2502
+ //#endregion
2503
2503
  //#region src/pipeline-runner/frame-governor.ts
2504
2504
  var FrameGovernor = class {
2505
2505
  config;
@@ -4978,7 +4978,20 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4978
4978
  if (resolved.tier === "native" && resolved.pixels !== null) return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
4979
4979
  if (resolved.tier !== "ram-fullframe") return null;
4980
4980
  const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
4981
- if (!crop) return null;
4981
+ if (!crop) {
4982
+ this.ctx?.logger.warn("native full-frame miss — the retained store no longer holds this frame", {
4983
+ tags: { deviceId: this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
4984
+ meta: {
4985
+ handle: `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`,
4986
+ handleNodeId: input.handle.nodeId,
4987
+ nativeMissReason: resolved.missReason,
4988
+ nativeHandleAgeMs: resolved.handleAgeMs,
4989
+ maxWidth: input.maxWidth ?? null,
4990
+ hint: "both the native lease and the retained-frame window have closed for this frame — the detail dispatch that asked for it produces NO crop"
4991
+ }
4992
+ });
4993
+ return null;
4994
+ }
4982
4995
  return this.encodeNativeCropResult({
4983
4996
  bytes: crop.bytes,
4984
4997
  width: crop.width,
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-J2DeRZAN.js");
2
+ const require_dist = require("../dist-BsBEe_rX.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
@@ -1,4 +1,4 @@
1
- import { $ as errMsg, At as EventCategory, Dt as record, G as recordingCapability, K as recordingExportCapability, Ot as string, Y as storageEvictableCapability, _t as selectAssignedProfileSlots, at as BaseAddon, bt as array, ct as DeviceType, f as ExportRecordSchema, g as OpsLogEntrySchema, j as deriveRecordingMode, mt as nodePin, u as EVENT_PAD_MS, ut as hydrateSchema, y as RecordingConfigSchema } from "../dist-CLJKoYEP.mjs";
1
+ import { K as recordingCapability, M as deriveRecordingMode, Ot as record, X as storageEvictableCapability, _ as OpsLogEntrySchema, b as RecordingConfigSchema, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, dt as hydrateSchema, et as errMsg, ht as nodePin, jt as EventCategory, kt as string, lt as DeviceType, ot as BaseAddon, p as ExportRecordSchema, q as recordingExportCapability, vt as selectAssignedProfileSlots, xt as array } from "../dist-Dkvk5ykl.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-QuxtOdK1.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -3487,12 +3487,56 @@ function activeBandAt(config, date) {
3487
3487
  //#endregion
3488
3488
  //#region src/recorder/addon/band-decision.ts
3489
3489
  /**
3490
+ * Pure glue between a device's `RecordingConfig` and the v2 band engine: decide
3491
+ * whether recording should currently be running, and which finalized segments
3492
+ * an `events` band retains.
3493
+ *
3494
+ * The decision is enabled AND the band active at `now` (per the v2
3495
+ * `activeBandAt`/`bandActiveAt`) demands recording: a `continuous` band always
3496
+ * demands; an `events` band demands only within `postBufferSec` of a qualifying
3497
+ * trigger (B3). No bands / disabled => not recording.
3498
+ *
3499
+ * `preBufferSec` is retroactive — it is NOT a live-demand input; it retains
3500
+ * already-recorded segments BEFORE a trigger at keep/discard time
3501
+ * ({@link segmentRetainedForEvents}). This mirrors the old recorder's proven
3502
+ * policy split (start-on-trigger live gate + retroactive pre-buffer keep).
3503
+ *
3504
+ * Kept pure (clock + trigger state injected) so the attach/detach decision is
3505
+ * unit-testable without ffmpeg, the broker, the event bus, or a real wall clock.
3506
+ */
3507
+ /**
3508
+ * THE one place a band's pre/post buffers are read. An ABSENT field takes
3509
+ * {@link DEFAULT_EVENTS_BAND_BUFFER_SEC}; an explicit value — including `0` —
3510
+ * is honoured verbatim.
3511
+ *
3512
+ * Both callers below used to inline `?? 0`, which read "the operator omitted the
3513
+ * field" as "the operator asked for a zero-length window". For the live gate
3514
+ * that is not a policy, it is an impossibility: nothing can ever be recorded.
3515
+ * Two cameras sat in exactly that state.
3516
+ */
3517
+ function resolveBandBufferMs(band) {
3518
+ return {
3519
+ preMs: (band.preBufferSec ?? DEFAULT_EVENTS_BAND_BUFFER_SEC.preBufferSec) * 1e3,
3520
+ postMs: (band.postBufferSec ?? DEFAULT_EVENTS_BAND_BUFFER_SEC.postBufferSec) * 1e3
3521
+ };
3522
+ }
3523
+ /**
3524
+ * Can this band EVER open a live recording window? False for an `events` band
3525
+ * whose resolved post-buffer is zero (only reachable by authoring an explicit
3526
+ * `0`) or which lists no trigger at all: both are "enabled, and records nothing,
3527
+ * forever". The recorder warns on it rather than dropping the device silently.
3528
+ */
3529
+ function eventsBandCanEverDemand(band) {
3530
+ if (band.mode !== "events") return true;
3531
+ return (band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0) && resolveBandBufferMs(band).postMs > 0;
3532
+ }
3533
+ /**
3490
3534
  * Is an `events` band's live demand window open at `atMs`? True iff a trigger
3491
3535
  * the band listens for fired within `postBufferSec` of now. `preBufferSec` is
3492
3536
  * deliberately NOT consulted here (see file header).
3493
3537
  */
3494
3538
  function eventsBandDemanded(band, triggers, atMs) {
3495
- const postMs = (band.postBufferSec ?? 0) * 1e3;
3539
+ const { postMs } = resolveBandBufferMs(band);
3496
3540
  const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
3497
3541
  if (band.triggers?.motion && fresh(triggers.lastMotionMs)) return true;
3498
3542
  if (band.triggers?.audioThresholdDbfs != null && fresh(triggers.lastAudioMs)) return true;
@@ -3519,8 +3563,7 @@ function shouldRecordAt(config, triggers, at) {
3519
3563
  * This is where `preBufferSec` retroactively retains pre-trigger footage.
3520
3564
  */
3521
3565
  function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
3522
- const preMs = (band.preBufferSec ?? 0) * 1e3;
3523
- const postMs = (band.postBufferSec ?? 0) * 1e3;
3566
+ const { preMs, postMs } = resolveBandBufferMs(band);
3524
3567
  const overlaps = (lastMs) => lastMs != null && segStartMs <= lastMs + postMs && segEndMs >= lastMs - preMs;
3525
3568
  if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
3526
3569
  if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
@@ -4053,6 +4096,12 @@ var RecordingController = class {
4053
4096
  * alive, which is the only question here.
4054
4097
  */
4055
4098
  lastSegmentAt = /* @__PURE__ */ new Map();
4099
+ /**
4100
+ * Devices already named by {@link reportUnrecordable} — one warn per device
4101
+ * per "enabled but can never record" episode, re-armed as soon as the device
4102
+ * records again.
4103
+ */
4104
+ unrecordableReported = /* @__PURE__ */ new Set();
4056
4105
  restore = null;
4057
4106
  tickTimer = null;
4058
4107
  stopped = false;
@@ -4229,10 +4278,12 @@ var RecordingController = class {
4229
4278
  if (this.stopped) return;
4230
4279
  const config = await this.deps.loadConfig(deviceId);
4231
4280
  if (!shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()))) {
4281
+ this.reportUnrecordable(deviceId, config);
4232
4282
  await this.detachDevice(deviceId);
4233
4283
  this.restore?.remove(deviceId);
4234
4284
  return;
4235
4285
  }
4286
+ this.unrecordableReported.delete(deviceId);
4236
4287
  if (this.active.has(deviceId)) return;
4237
4288
  if (this.attaching.has(deviceId)) return;
4238
4289
  this.attaching.add(deviceId);
@@ -4243,6 +4294,42 @@ var RecordingController = class {
4243
4294
  }
4244
4295
  }
4245
4296
  /**
4297
+ * Name the `!wantRecording` drop when the device is ENABLED but its config can
4298
+ * never produce footage — the branch that hid this bug. Two shapes qualify,
4299
+ * and both mean "the operator asked for recording and will get none, forever":
4300
+ * an enabled config with no bands at all, and an `events` band that cannot
4301
+ * open a live window (no trigger listed, or an explicit `postBufferSec: 0`).
4302
+ *
4303
+ * NOT reported: a band that simply does not cover `now` (a night-only band at
4304
+ * noon), or an events band waiting for its next trigger. Those are the
4305
+ * feature working.
4306
+ *
4307
+ * One line per device until it records again ({@link unrecordableReported} is
4308
+ * cleared the moment `wantRecording` turns true), so the 30 s tick cannot turn
4309
+ * this into a log flood.
4310
+ */
4311
+ reportUnrecordable(deviceId, config) {
4312
+ if (!config.enabled) return;
4313
+ if (this.unrecordableReported.has(deviceId)) return;
4314
+ const bands = config.bands ?? [];
4315
+ if (bands.length === 0) {
4316
+ this.unrecordableReported.add(deviceId);
4317
+ this.deps.logger.warn("recorder: device is enabled but has no recording bands — nothing will ever be recorded", { tags: { deviceId } });
4318
+ return;
4319
+ }
4320
+ const band = activeBandAt({ bands }, new Date(this.now()));
4321
+ if (band === null || eventsBandCanEverDemand(band)) return;
4322
+ this.unrecordableReported.add(deviceId);
4323
+ this.deps.logger.warn("recorder: events band can never open a recording window — nothing will ever be recorded", {
4324
+ tags: { deviceId },
4325
+ meta: {
4326
+ postBufferSec: band.postBufferSec,
4327
+ triggers: band.triggers,
4328
+ reason: band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0 ? "postBufferSec is 0" : "no trigger configured"
4329
+ }
4330
+ });
4331
+ }
4332
+ /**
4246
4333
  * The attach body — extracted so the {@link attaching} in-flight guard in
4247
4334
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
4248
4335
  * SegmentWriter + watcher per profile, and records the set in `active`. A throw
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../chunk-emK7D4bc.js");
3
- const require_worker_protocol = require("../worker-protocol-CNi5srgV.js");
3
+ const require_worker_protocol = require("../worker-protocol-CWhkLkHx.js");
4
4
  let node_url = require("node:url");
5
5
  let node_fs = require("node:fs");
6
6
  node_fs = require_chunk.__toESM(node_fs);
@@ -343,7 +343,7 @@ var LeaseActivityGate = class {
343
343
  windowMs;
344
344
  now;
345
345
  armedUntil = 0;
346
- disabled = false;
346
+ disabledUntil = 0;
347
347
  constructor(windowMs, now = Date.now) {
348
348
  this.windowMs = windowMs;
349
349
  this.now = now;
@@ -354,23 +354,44 @@ var LeaseActivityGate = class {
354
354
  this.armedUntil = this.now() + this.windowMs;
355
355
  }
356
356
  /**
357
- * Force the gate closed regardless of demand — for a download path that has
358
- * PROVEN broken this dial (an add/receive failure). Rebuilding the vaapi
359
- * filtergraph per failing frame both leaks (node-av reclaims hw pools only
360
- * by process exit) and burns GPU churn, so a broken path stays off until
361
- * {@link reset} (the next dial). Overrides `windowMs: 0` too.
357
+ * Force the gate closed regardless of demand for `cooldownMs` — for a
358
+ * download path that has PROVEN broken (an add/receive failure). Rebuilding
359
+ * the vaapi filtergraph per failing frame both leaks (node-av reclaims hw
360
+ * pools only by process exit) and burns GPU churn, so a broken path must stay
361
+ * off. Overrides `windowMs: 0` too.
362
+ *
363
+ * ## Why this is a COOLDOWN and not a latch
364
+ *
365
+ * It was a latch (`disable()`), held until {@link reset} — i.e. until the next
366
+ * DIAL. On a continuously recording camera a dial lasts hours, so two EIO
367
+ * `hwdownload` failures killed the native lease for the whole session and
368
+ * every detail dispatch's rung-1 crop missed for as long as it ran. That is
369
+ * the 93-97% `nativeCropMisses` measured on 2026-08-08: dev 618 had 24 dials
370
+ * disabled in a single day, and dev 592 serviced ~15 crop requests/s for
371
+ * 500 s with a `leaseOffered` delta of exactly 0 — `arm()` firing constantly
372
+ * against a gate that could never reopen.
373
+ *
374
+ * The OOM guard the latch existed for is preserved by the cooldown's LENGTH,
375
+ * not by its permanence: one filtergraph rebuild per cooldown instead of one
376
+ * per frame. The caller escalates `cooldownMs` on repeated failures, so a
377
+ * genuinely broken dial converges on the old behaviour while a transient GPU
378
+ * fault recovers on its own.
379
+ *
380
+ * A later call always wins, even a shorter one: the caller escalates
381
+ * monotonically, and "the most recent failure decides" is the only rule that
382
+ * stays true if it ever stops.
362
383
  */
363
- disable() {
364
- this.disabled = true;
384
+ disableFor(cooldownMs) {
385
+ this.disabledUntil = this.now() + cooldownMs;
365
386
  }
366
- /** New dial: clear the broken flag AND any stale demand (caller re-arms explicitly). */
387
+ /** New dial: clear the cooldown AND any stale demand (caller re-arms explicitly). */
367
388
  reset() {
368
- this.disabled = false;
389
+ this.disabledUntil = 0;
369
390
  this.armedUntil = 0;
370
391
  }
371
392
  /** Whether lease capture should run for the current frame. */
372
393
  get active() {
373
- if (this.disabled) return false;
394
+ if (this.now() < this.disabledUntil) return false;
374
395
  if (this.windowMs === 0) return true;
375
396
  return this.now() < this.armedUntil;
376
397
  }
@@ -856,6 +877,25 @@ var NATIVE_LEASE_TTL_MS = NATIVE_LEASE_KNOBS.values.ttlMs;
856
877
  */
857
878
  var NATIVE_LEASE_ACTIVITY_WINDOW_MS = NATIVE_LEASE_KNOBS.values.activityMs;
858
879
  /**
880
+ * How long lease capture stays off after the download path proves broken, and
881
+ * the ceiling that backoff climbs to. Doubling from the base, so the Nth
882
+ * failure in a dial costs `base × 2^(N-2)` — 60 s, 120 s, 240 s … capped.
883
+ *
884
+ * These replace a latch that was held for the REST OF THE DIAL. On a
885
+ * continuously recording camera a dial is hours, and the 2026-08-08 harvest
886
+ * measured what that costs: dev 618 at 93% `nativeCropMisses` across 24
887
+ * disabled dials, dev 592 with a `leaseOffered` delta of 0 over 500 s while
888
+ * answering ~15 crop requests/s. Every one of those misses is a detail
889
+ * dispatch whose rung-1 native crop could never resolve.
890
+ *
891
+ * The bound that mattered is still bounded: the OOM the latch was written for
892
+ * came from rebuilding a vaapi filtergraph PER FAILING FRAME (~25/s), and one
893
+ * rebuild per cooldown is three orders of magnitude below that — while a
894
+ * transient GPU fault now recovers without waiting for a re-dial.
895
+ */
896
+ var LEASE_DOWNLOAD_COOLDOWN_BASE_MS = 6e4;
897
+ var LEASE_DOWNLOAD_COOLDOWN_MAX_MS = 6e5;
898
+ /**
859
899
  * `pinnedRgbCrop` — pinned-color RGB24 native-crop retention. DEFAULT **ON**
860
900
  * (2026-07-20 rollout); set `CAMSTACK_SESSION_PINNED_RGB_CROP=0` to opt out.
861
901
  *
@@ -1549,7 +1589,11 @@ var DecodeWorkerChild = class {
1549
1589
  this.nativeLeaseDownloadFilter = null;
1550
1590
  this.nativeLeaseDownloadFilterKey = "";
1551
1591
  this.leaseDownloadFailuresThisDial++;
1552
- if (this.leaseDownloadFailuresThisDial >= 2) this.leaseGate.disable();
1592
+ if (this.leaseDownloadFailuresThisDial >= 2) {
1593
+ const cooldownMs = Math.min(LEASE_DOWNLOAD_COOLDOWN_MAX_MS, LEASE_DOWNLOAD_COOLDOWN_BASE_MS * 2 ** (this.leaseDownloadFailuresThisDial - 2));
1594
+ this.leaseGate.disableFor(cooldownMs);
1595
+ this.emitStderr(`decode-worker-child: native lease capture OFF for ${cooldownMs}ms — ${this.leaseDownloadFailuresThisDial} download failures this dial; native crops will MISS until it expires (detail dispatches fall back to the retained full frame)\n`);
1596
+ }
1553
1597
  return null;
1554
1598
  }
1555
1599
  }