@camstack/addon-decoder-nodeav 1.1.4 → 1.1.6

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 (3) hide show
  1. package/dist/index.js +594 -17
  2. package/dist/index.mjs +595 -18
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  let _camstack_shm_ring = require("@camstack/shm-ring");
6
6
  let node_crypto = require("node:crypto");
7
+ let node_fs = require("node:fs");
7
8
  //#region src/frame-ring-sink.ts
8
9
  /**
9
10
  * `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
@@ -71,10 +72,16 @@ var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
71
72
  * segment (resolution change) a distinct name so a stale consumer mapping is
72
73
  * never silently reused.
73
74
  */
75
+ /**
76
+ * Shared prefix for every decoder shm segment name. Startup orphan reclamation
77
+ * (`purgeOrphanSegments`) keys off this to find segments left behind by a
78
+ * crashed prior instance.
79
+ */
80
+ var SEGMENT_NAME_PREFIX = "csf.";
74
81
  function makeSegmentName(seed, generation) {
75
82
  let hash = 5381;
76
83
  for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
77
- return `csf.${(hash >>> 0).toString(36)}.${generation}`;
84
+ return `${SEGMENT_NAME_PREFIX}${(hash >>> 0).toString(36)}.${generation}`;
78
85
  }
79
86
  /**
80
87
  * The decoder-side owner of one stream's shared-memory frame ring.
@@ -4897,7 +4904,7 @@ function _instanceof(cls, params = {}) {
4897
4904
  return inst;
4898
4905
  }
4899
4906
  //#endregion
4900
- //#region ../types/dist/sleep-BiDFW0E7.mjs
4907
+ //#region ../types/dist/sleep-CZDdRBua.mjs
4901
4908
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4902
4909
  EventCategory["SystemBoot"] = "system.boot";
4903
4910
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -7504,7 +7511,16 @@ var DecoderStatsSchema = object({
7504
7511
  inputFps: number(),
7505
7512
  outputFps: number(),
7506
7513
  avgDecodeTimeMs: number(),
7507
- droppedFrames: number()
7514
+ droppedFrames: number(),
7515
+ /**
7516
+ * Pull-mode adaptive-fps telemetry (optional — only pull sessions run the
7517
+ * lag-driven controller; push sessions omit these). `lagMs` is the EWMA of
7518
+ * the decoder's real-time drift (rising = falling behind live); `adaptiveFps`
7519
+ * is the current lag-throttled emit rate (≤ `effectiveFps` ceiling).
7520
+ */
7521
+ lagMs: number().optional(),
7522
+ effectiveFps: number().optional(),
7523
+ adaptiveFps: number().optional()
7508
7524
  });
7509
7525
  var DecoderSessionConfigSchema = object({
7510
7526
  codec: string(),
@@ -7545,7 +7561,15 @@ var DecoderSessionConfigSchema = object({
7545
7561
  * other — `pullFrames` returns nothing for an `'shm'` session and
7546
7562
  * `pullHandles` returns nothing for a `'callback'` session.
7547
7563
  */
7548
- frameSink: _enum(["callback", "shm"]).default("callback")
7564
+ frameSink: _enum(["callback", "shm"]).default("callback"),
7565
+ /**
7566
+ * Per-camera decoder DEBUG facility. When `true`, a pull-mode session emits
7567
+ * a throttled (~1Hz) structured `decoder debug` line (effective/adaptive fps,
7568
+ * real-time lag, dropped-frame delta, avg decode time, hwaccel). Mirrors the
7569
+ * stream-broker's `streamingDebug` gate — off by default so production logs
7570
+ * stay quiet and the emit path pays zero per-frame cost when disabled.
7571
+ */
7572
+ debug: boolean().optional()
7549
7573
  });
7550
7574
  var EncodeProfileSchema = object({
7551
7575
  video: object({
@@ -9703,6 +9727,75 @@ DeviceType.Cover, method(object({ deviceId: number().int().nonnegative() }), _vo
9703
9727
  auth: "admin"
9704
9728
  });
9705
9729
  /**
9730
+ * Vendor-neutral day/night (IR-cut) control — the per-camera config cap
9731
+ * shared by reolink / hikvision / amcrest. Models the common firmware
9732
+ * surface: the IR-cut switching MODE plus the two knobs that gate it
9733
+ * (photocell `sensitivity` + `switchDelaySec`). Each vendor maps these
9734
+ * onto its own ISAPI / Baichuan / Dahua-CGI fields; the cap standardises
9735
+ * the shape so ONE derived-form renders every camera.
9736
+ *
9737
+ * Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
9738
+ * `getOptions` advertises per-camera availability, `getStatus` (auto-
9739
+ * injected from `status`) reports the live values, and a single
9740
+ * `setSettings` mutation applies a partial change. No hand-written
9741
+ * settings-contribution methods — the framework derives the UI + save
9742
+ * routing from this surface.
9743
+ */
9744
+ /** IR-cut switching mode. `schedule` = time-of-day table configured on the camera. */
9745
+ var DayNightModeSchema = _enum([
9746
+ "auto",
9747
+ "day",
9748
+ "night",
9749
+ "schedule"
9750
+ ]);
9751
+ /** Normalized numeric range descriptor — `{ min, max, step }` per the
9752
+ * getOptions availability convention. Normalized values are 0–100. */
9753
+ var NormalizedRangeSchema$1 = object({
9754
+ min: number(),
9755
+ max: number(),
9756
+ step: number()
9757
+ });
9758
+ object({
9759
+ mode: DayNightModeSchema,
9760
+ /** IR-cut trigger sensitivity, NORMALIZED 0–100 (higher = switches to night sooner). */
9761
+ sensitivity: number().optional(),
9762
+ /** Delay before the IR-cut filter flips, in seconds. */
9763
+ switchDelaySec: number().optional(),
9764
+ lastFetchedAt: number()
9765
+ });
9766
+ /**
9767
+ * Per-camera availability descriptor — drives which controls the admin UI
9768
+ * renders. Booleans as `supportsX`; numeric ranges as `{ min, max, step }`
9769
+ * (normalized 0–100); the mode choice-set as an array. A provider returns
9770
+ * honest, camera-probed values — never hardcoded.
9771
+ */
9772
+ var DayNightOptionsSchema = object({
9773
+ /** Modes this camera accepts. Empty → the camera has no configurable day/night mode. */
9774
+ modes: array(DayNightModeSchema),
9775
+ supportsSensitivity: boolean(),
9776
+ /** Present when `supportsSensitivity` — the normalized 0–100 range. */
9777
+ sensitivity: NormalizedRangeSchema$1.optional(),
9778
+ supportsSwitchDelay: boolean(),
9779
+ /** Present when `supportsSwitchDelay` — the allowed delay range in seconds. */
9780
+ switchDelaySec: NormalizedRangeSchema$1.optional()
9781
+ });
9782
+ /**
9783
+ * Partial change to the day/night config — every field optional. A
9784
+ * provider ignores fields it does not support.
9785
+ */
9786
+ var DayNightSettingsPatchSchema = object({
9787
+ mode: DayNightModeSchema.optional(),
9788
+ sensitivity: number().optional(),
9789
+ switchDelaySec: number().optional()
9790
+ });
9791
+ DeviceType.Camera, method(object({ deviceId: number() }), DayNightOptionsSchema), method(object({
9792
+ deviceId: number(),
9793
+ settings: DayNightSettingsPatchSchema
9794
+ }), _void(), {
9795
+ kind: "mutation",
9796
+ auth: "admin"
9797
+ });
9798
+ /**
9706
9799
  * Identity envelope for a device's upstream-system metadata.
9707
9800
  *
9708
9801
  * Two jobs:
@@ -10058,6 +10151,130 @@ object({
10058
10151
  });
10059
10152
  DeviceType.Image;
10060
10153
  /**
10154
+ * Vendor-neutral image / picture-adjustment cap — the per-camera config
10155
+ * cap shared by reolink / hikvision / amcrest. Models the common ISP
10156
+ * surface: the four picture sliders (brightness / contrast / saturation /
10157
+ * sharpness), orientation (mirror / flip / rotate), white-balance,
10158
+ * exposure and backlight-compensation modes.
10159
+ *
10160
+ * NORMALIZATION: every slider is a normalized int 0–100. Vendors expose
10161
+ * these natively as 0–100 or 0–255 (or other ranges); each provider maps
10162
+ * its native range to/from this normalized 0–100 space so the cap surface
10163
+ * (and the derived form) is identical across cameras. `warmth` (manual
10164
+ * white-balance) is likewise normalized 0–100.
10165
+ *
10166
+ * Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
10167
+ * `getOptions` advertises per-camera availability, `getStatus` (auto-
10168
+ * injected from `status`) reports the live values, and a single
10169
+ * `setSettings` mutation applies a partial change. No hand-written
10170
+ * settings-contribution methods — the framework derives the UI + save
10171
+ * routing from this surface.
10172
+ */
10173
+ /** Sensor/image rotation, degrees clockwise. */
10174
+ var ImageRotateSchema = _enum([
10175
+ "0",
10176
+ "90",
10177
+ "180",
10178
+ "270"
10179
+ ]);
10180
+ /** White-balance mode. `manual` unlocks the normalized `warmth` knob. */
10181
+ var WhiteBalanceModeSchema = _enum(["auto", "manual"]);
10182
+ /** Exposure mode. */
10183
+ var ExposureModeSchema = _enum(["auto", "manual"]);
10184
+ /**
10185
+ * Backlight-compensation mode:
10186
+ * - `off` — disabled
10187
+ * - `blc` — backlight compensation
10188
+ * - `wdr` — wide dynamic range
10189
+ * - `hlc` — highlight compensation
10190
+ */
10191
+ var BacklightModeSchema = _enum([
10192
+ "off",
10193
+ "blc",
10194
+ "wdr",
10195
+ "hlc"
10196
+ ]);
10197
+ /** Normalized numeric range descriptor — `{ min, max, step }` per the
10198
+ * getOptions availability convention. Slider values are normalized 0–100. */
10199
+ var NormalizedRangeSchema = object({
10200
+ min: number(),
10201
+ max: number(),
10202
+ step: number()
10203
+ });
10204
+ object({
10205
+ /** Normalized 0–100. */
10206
+ brightness: number().optional(),
10207
+ /** Normalized 0–100. */
10208
+ contrast: number().optional(),
10209
+ /** Normalized 0–100. */
10210
+ saturation: number().optional(),
10211
+ /** Normalized 0–100. */
10212
+ sharpness: number().optional(),
10213
+ mirror: boolean().optional(),
10214
+ flip: boolean().optional(),
10215
+ rotate: ImageRotateSchema.optional(),
10216
+ whiteBalance: WhiteBalanceModeSchema.optional(),
10217
+ /** Manual white-balance warmth, NORMALIZED 0–100. Meaningful only when `whiteBalance === 'manual'`. */
10218
+ warmth: number().optional(),
10219
+ exposureMode: ExposureModeSchema.optional(),
10220
+ backlightMode: BacklightModeSchema.optional(),
10221
+ lastFetchedAt: number()
10222
+ });
10223
+ /**
10224
+ * Per-camera availability descriptor — drives which controls the admin UI
10225
+ * renders. Booleans as `supportsX`; numeric ranges as `{ min, max, step }`
10226
+ * (the normalized 0–100 range); enums as arrays of supported values (empty
10227
+ * array → control hidden). A provider returns honest, camera-probed values
10228
+ * — never hardcoded.
10229
+ */
10230
+ var ImageSettingsOptionsSchema = object({
10231
+ supportsBrightness: boolean(),
10232
+ brightness: NormalizedRangeSchema.optional(),
10233
+ supportsContrast: boolean(),
10234
+ contrast: NormalizedRangeSchema.optional(),
10235
+ supportsSaturation: boolean(),
10236
+ saturation: NormalizedRangeSchema.optional(),
10237
+ supportsSharpness: boolean(),
10238
+ sharpness: NormalizedRangeSchema.optional(),
10239
+ supportsMirror: boolean(),
10240
+ supportsFlip: boolean(),
10241
+ /** Supported rotation values. Empty → rotation not configurable. */
10242
+ rotateOptions: array(ImageRotateSchema),
10243
+ /** Supported white-balance modes. Empty → white-balance not configurable. */
10244
+ whiteBalanceModes: array(WhiteBalanceModeSchema),
10245
+ supportsWarmth: boolean(),
10246
+ /** Present when `supportsWarmth` — the normalized 0–100 range. */
10247
+ warmth: NormalizedRangeSchema.optional(),
10248
+ /** Supported exposure modes. Empty → exposure not configurable. */
10249
+ exposureModes: array(ExposureModeSchema),
10250
+ /** Supported backlight modes. Empty → backlight-compensation not configurable. */
10251
+ backlightModes: array(BacklightModeSchema)
10252
+ });
10253
+ /**
10254
+ * Partial change to the image config — every field optional. Slider values
10255
+ * are normalized 0–100. A provider ignores fields it does not support.
10256
+ */
10257
+ var ImageSettingsPatchSchema = object({
10258
+ brightness: number().optional(),
10259
+ contrast: number().optional(),
10260
+ saturation: number().optional(),
10261
+ sharpness: number().optional(),
10262
+ mirror: boolean().optional(),
10263
+ flip: boolean().optional(),
10264
+ rotate: ImageRotateSchema.optional(),
10265
+ whiteBalance: WhiteBalanceModeSchema.optional(),
10266
+ warmth: number().optional(),
10267
+ exposureMode: ExposureModeSchema.optional(),
10268
+ backlightMode: BacklightModeSchema.optional()
10269
+ });
10270
+ DeviceType.Camera, method(object({ deviceId: number() }), ImageSettingsOptionsSchema), method(object({
10271
+ deviceId: number(),
10272
+ settings: ImageSettingsPatchSchema
10273
+ }), _void(), {
10274
+ kind: "mutation",
10275
+ auth: "admin"
10276
+ });
10277
+ /**
10061
10278
  * Robotic lawn-mower cap. Models HA `lawn_mower.*` entities — anything
10062
10279
  * with a mowing lifecycle plus a dock action.
10063
10280
  *
@@ -10952,6 +11169,16 @@ var RunnerCameraConfigSchema = object({
10952
11169
  * this gate is bypassed.
10953
11170
  */
10954
11171
  onboardMotionDrivesAnalyzer: boolean().default(true),
11172
+ /**
11173
+ * Master toggle for the occupancy re-check. When `false` (DEFAULT) the runner
11174
+ * never arms the periodic recheck timer, regardless of `occupancyRecheckSec` —
11175
+ * this is off by default because the recheck re-subscribes a detection session
11176
+ * every N seconds while `watching`, a major source of pull-decoder re-dial
11177
+ * churn (each cycle creates+tears a session → RTSP re-dial → latency). The
11178
+ * `occupancyRecheckSec` / `occupancyRecheckFrames` sliders only take effect
11179
+ * (and only render) when this is enabled.
11180
+ */
11181
+ occupancyRecheckEnabled: boolean().default(false),
10955
11182
  occupancyRecheckSec: number().min(occupancyRecheckSecField.min).max(occupancyRecheckSecField.max).default(occupancyRecheckSecField.default),
10956
11183
  occupancyRecheckFrames: number().min(occupancyRecheckFramesField.min).max(occupancyRecheckFramesField.max).default(occupancyRecheckFramesField.default),
10957
11184
  /**
@@ -19372,6 +19599,18 @@ Object.freeze({
19372
19599
  addonId: null,
19373
19600
  access: "view"
19374
19601
  },
19602
+ "dayNight.getOptions": {
19603
+ capName: "day-night",
19604
+ capScope: "device",
19605
+ addonId: null,
19606
+ access: "view"
19607
+ },
19608
+ "dayNight.setSettings": {
19609
+ capName: "day-night",
19610
+ capScope: "device",
19611
+ addonId: null,
19612
+ access: "create"
19613
+ },
19375
19614
  "decoder.createSession": {
19376
19615
  capName: "decoder",
19377
19616
  capScope: "system",
@@ -20302,6 +20541,18 @@ Object.freeze({
20302
20541
  addonId: null,
20303
20542
  access: "create"
20304
20543
  },
20544
+ "imageSettings.getOptions": {
20545
+ capName: "image-settings",
20546
+ capScope: "device",
20547
+ addonId: null,
20548
+ access: "view"
20549
+ },
20550
+ "imageSettings.setSettings": {
20551
+ capName: "image-settings",
20552
+ capScope: "device",
20553
+ addonId: null,
20554
+ access: "create"
20555
+ },
20305
20556
  "integrations.create": {
20306
20557
  capName: "integrations",
20307
20558
  capScope: "system",
@@ -23080,6 +23331,71 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
23080
23331
  return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
23081
23332
  }
23082
23333
  //#endregion
23334
+ //#region src/shm-orphan-purge.ts
23335
+ /**
23336
+ * Startup reclamation of orphaned shared-memory segments.
23337
+ *
23338
+ * A decoder writes frames into named `/dev/shm` segments and unlinks each one
23339
+ * on graceful session teardown (`DecoderFrameRingSink.destroy`). When the
23340
+ * decoder process dies *ungracefully* — SIGBUS, OOM-kill, or a SIGKILL during
23341
+ * redeploy — that teardown never runs and the segment is orphaned: it stays in
23342
+ * the tmpfs forever, since nothing else knows its name. Across many
23343
+ * crashes/redeploys these accumulate until `/dev/shm` fills, at which point the
23344
+ * next `mmap` write faults with an uncatchable SIGBUS and the decoder
23345
+ * crash-loops into its circuit breaker (which is exactly the incident this
23346
+ * guards against).
23347
+ *
23348
+ * The reclamation is safe *at process startup*: a freshly-booting decoder owns
23349
+ * no live sessions, so every pre-existing segment with its prefix is by
23350
+ * definition an orphan from a dead instance. `shm_unlink` only removes the
23351
+ * name — any consumer still holding a mapping keeps reading valid memory until
23352
+ * it closes (POSIX deferred reclaim + the ring seqlock), so unlinking is safe
23353
+ * even if a stale reader is momentarily still attached.
23354
+ *
23355
+ * POSIX-only: segments surface as files under `/dev/shm` on Linux. On platforms
23356
+ * without that directory (Windows, macOS) the scan finds nothing — no-op.
23357
+ *
23358
+ * NOTE: this lives inside the decoder addon (not `@camstack/shm-ring`) so it
23359
+ * ships in the self-contained addon bundle via `camstack deploy`, reusing the
23360
+ * already-deployed `unlinkSegment`; no host base-image rebuild required.
23361
+ */
23362
+ /** Default tmpfs directory where POSIX shared-memory segments appear on Linux. */
23363
+ var DEFAULT_SHM_DIR = "/dev/shm";
23364
+ /**
23365
+ * Unlink every shared-memory segment whose name starts with `prefix`.
23366
+ *
23367
+ * Intended to run ONCE at decoder startup, before any session is created, to
23368
+ * reclaim segments orphaned by a previously-crashed instance. A per-file unlink
23369
+ * failure is swallowed so one stuck segment cannot block reclaiming the rest.
23370
+ */
23371
+ function purgeOrphanSegments(prefix, options = {}) {
23372
+ const dir = options.dir ?? DEFAULT_SHM_DIR;
23373
+ const unlink = options.unlink ?? _camstack_shm_ring.unlinkSegment;
23374
+ let entries;
23375
+ try {
23376
+ entries = (0, node_fs.readdirSync)(dir);
23377
+ } catch {
23378
+ return {
23379
+ scanned: 0,
23380
+ removed: 0,
23381
+ names: []
23382
+ };
23383
+ }
23384
+ const names = [];
23385
+ for (const name of entries) {
23386
+ if (!name.startsWith(prefix)) continue;
23387
+ try {
23388
+ unlink(name);
23389
+ names.push(name);
23390
+ } catch {}
23391
+ }
23392
+ return {
23393
+ scanned: entries.length,
23394
+ removed: names.length,
23395
+ names
23396
+ };
23397
+ }
23398
+ //#endregion
23083
23399
  //#region src/shared/notifying-ring-buffer.ts
23084
23400
  /**
23085
23401
  * A {@link RingBuffer} that can notify a single blocked consumer the moment an
@@ -23265,6 +23581,36 @@ async function resolveDecoderBackend(api, nodeId, logger) {
23265
23581
  return DEFAULT_DECODER_BACKEND;
23266
23582
  }
23267
23583
  //#endregion
23584
+ //#region src/pull-demuxer-options.ts
23585
+ /**
23586
+ * libav demuxer options for the node-av PULL decode path — RTP sources
23587
+ * (`isRtpSource()`) dialing the broker's RTSP restream (`rtsp://…/muted`).
23588
+ *
23589
+ * Without low-latency flags libav buffers up to `analyzeduration` (default
23590
+ * **5s**) of the live stream during `avformat_find_stream_info`, and that buffer
23591
+ * becomes a fixed ~5s offset on the decoded-frame timeline — detection overlays
23592
+ * then lag the WebRTC raw-RTP passthrough (which never decodes) by ~5s. Adding
23593
+ * `fflags: nobuffer` + a short `analyzeduration` collapses that offset to
23594
+ * sub-second, mirroring the ffmpeg backend's `-fflags +nobuffer` on its pull
23595
+ * path (decoder-ffmpeg/ffmpeg-args.ts).
23596
+ *
23597
+ * ‼ `analyzeduration`/`probesize` stay SMALL BUT NON-ZERO. Probe-zeroing
23598
+ * (`analyzeduration 0`) on the pull RTSP path STARVES the Reolink rfc4571
23599
+ * restream demuxer (garbage / zero frames — looked like a ~3000fps churn
23600
+ * runaway); that optimisation is push-mode-only. Keep them > 0.
23601
+ */
23602
+ function buildPullDemuxerOptions() {
23603
+ return {
23604
+ rtsp_transport: "tcp",
23605
+ fflags: "nobuffer",
23606
+ analyzeduration: "1000000",
23607
+ probesize: "1000000",
23608
+ max_delay: "0",
23609
+ reorder_queue_size: "0",
23610
+ user_agent: "decoder"
23611
+ };
23612
+ }
23613
+ //#endregion
23268
23614
  //#region src/scaler-geometry.ts
23269
23615
  /**
23270
23616
  * Decide the scaler action for an incoming frame's source geometry.
@@ -23287,6 +23633,49 @@ function resolveScalerAction(current, incoming) {
23287
23633
  * not hot-loop `Demuxer.open`.
23288
23634
  */
23289
23635
  var PULL_REDIAL_MS = 3e3;
23636
+ /**
23637
+ * Short re-dial backoff for the adaptive "drop-to-live" path. When the decoder
23638
+ * has fallen hopelessly behind the live edge ({@link DROP_TO_LIVE_LAG_MS}) the
23639
+ * loop tears the input down and re-dials fast so the restream's burst-prime
23640
+ * re-seats it at the live edge, instead of waiting the full {@link PULL_REDIAL_MS}
23641
+ * (which is for transient stream blips, not a deliberate re-seat).
23642
+ */
23643
+ var PULL_FAST_REDIAL_MS = 250;
23644
+ /** Adaptive controller cadence — recompute emit-fps from lag at most this often. */
23645
+ var ADAPTIVE_TICK_MS = 1e3;
23646
+ /** Lag EWMA above this (ms) → halve the adaptive emit-fps. */
23647
+ var ADAPTIVE_LAG_HIGH_MS = 750;
23648
+ /** Lag EWMA below this (ms), sustained {@link ADAPTIVE_RECOVER_SUSTAIN_MS} → grow emit-fps. */
23649
+ var ADAPTIVE_LAG_LOW_MS = 250;
23650
+ /** How long lag must stay below {@link ADAPTIVE_LAG_LOW_MS} before growing emit-fps. */
23651
+ var ADAPTIVE_RECOVER_SUSTAIN_MS = 5e3;
23652
+ /** Floor the adaptive controller never sheds below (fps). */
23653
+ var ADAPTIVE_MIN_FPS = 2;
23654
+ /**
23655
+ * Ceiling the adaptive controller recovers toward when the subscriber requested
23656
+ * an UNLIMITED rate (`maxFps <= 0`). Source cameras run ≤ this, so the clamp is
23657
+ * a no-op in practice while keeping the /2 shed + *1.5 recover math bounded.
23658
+ */
23659
+ var ADAPTIVE_FALLBACK_CEILING_FPS = 30;
23660
+ /**
23661
+ * Lag EWMA above this (ms) = hopelessly behind → drop-to-live re-seat.
23662
+ *
23663
+ * This bounds the MAX glass-to-box media staleness: `lagMsEwma` is how far the
23664
+ * decoder's media clock trails live, i.e. how old the *content* of an emitted
23665
+ * frame is (distinct from `frameAge`, which only measures decode→pick pipeline
23666
+ * delay and does NOT capture this media lag). On a host that decodes marginally
23667
+ * below real-time the lag sawtooths up to this ceiling, then re-seats to live —
23668
+ * so this constant directly caps how stale the boxes can get. 1200 ms (was
23669
+ * 2000) trades slightly more frequent 250 ms re-seats for fresher overlays; the
23670
+ * re-seat cost is negligible (~1.4 drops/cam/min at the observed drift). The
23671
+ * real fix — keeping decode at real-time so the lag never builds — is the
23672
+ * co-located / subprocess-decode epic.
23673
+ */
23674
+ var DROP_TO_LIVE_LAG_MS = 1200;
23675
+ /** Never trigger drop-to-live more than once per this window (ms). */
23676
+ var DROP_TO_LIVE_MIN_INTERVAL_MS = 3e3;
23677
+ /** Decoder DEBUG facility flush cadence (~1Hz). */
23678
+ var DEBUG_FLUSH_MS = 1e3;
23290
23679
  /** Map our canonical backend name to the node-av `AV_HWDEVICE_TYPE_*` constant. */
23291
23680
  function backendToHwDeviceConst(backend, consts) {
23292
23681
  switch (backend) {
@@ -23448,6 +23837,34 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23448
23837
  scalerSrcFmt = -1;
23449
23838
  lastEmitTime = 0;
23450
23839
  minIntervalMs;
23840
+ /** Gates {@link emitDebugFlash}; set from `config.debug === true`. Off = zero cost. */
23841
+ debugEnabled;
23842
+ /** Wall-clock of the last `decoder debug` flush — throttles the facility to ~1Hz. */
23843
+ lastDebugFlush = 0;
23844
+ /** `droppedFrames` snapshot at the last debug flush — window base for the drop delta. */
23845
+ debugWindowDropped = 0;
23846
+ /** Subscriber-requested emit ceiling (fps). `maxFps<=0` clamps to the fallback ceiling. */
23847
+ ceilingFps;
23848
+ /** Current lag-throttled emit rate (fps) — starts at the ceiling, floored at ADAPTIVE_MIN_FPS. */
23849
+ adaptiveFps;
23850
+ /** Frames actually emitted/sec over the last adaptive-tick window (feeds getStats + debug). */
23851
+ effectiveFps = 0;
23852
+ /** `outputFrames` snapshot at the last adaptive tick — window base for effective-fps. */
23853
+ adaptiveWindowFrames = 0;
23854
+ /** First sampled frame's pts (timebase units); `null` until the current dial seeds it. */
23855
+ basePts = null;
23856
+ /** Wall-clock at `basePts` — the real-time anchor for the drift computation. */
23857
+ baseWall = 0;
23858
+ /** EWMA of real-time drift (ms). Rising = decoder falling behind the live edge. */
23859
+ lagMsEwma = 0;
23860
+ /** Wall-clock the lag EWMA first dropped below ADAPTIVE_LAG_LOW_MS (0 = not low). */
23861
+ lowLagSince = 0;
23862
+ /** Wall-clock of the last adaptive-controller tick. */
23863
+ lastAdaptiveCheck = 0;
23864
+ /** Wall-clock of the last drop-to-live re-seat — rate-limits the re-dial. */
23865
+ lastDropToLive = 0;
23866
+ /** Set by drop-to-live so `runPullLoop` uses the SHORT re-dial backoff for one iteration. */
23867
+ pullFastRedial = false;
23451
23868
  inputPackets = 0;
23452
23869
  outputFrames = 0;
23453
23870
  droppedFrames = 0;
@@ -23482,6 +23899,9 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23482
23899
  if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
23483
23900
  this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
23484
23901
  this.minIntervalMs = config.maxFps > 0 ? 1e3 / config.maxFps : 0;
23902
+ this.debugEnabled = config.debug === true;
23903
+ this.ceilingFps = config.maxFps > 0 ? config.maxFps : ADAPTIVE_FALLBACK_CEILING_FPS;
23904
+ this.adaptiveFps = this.ceilingFps;
23485
23905
  this.outputMode = NodeAvDecoderSession.resolveOutputMode(config.outputFormat);
23486
23906
  this.hwaccelPref = options?.hwaccel ?? "auto";
23487
23907
  this.hwaccelResolver = options?.hwaccelResolver ?? null;
@@ -23796,7 +24216,9 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23796
24216
  this.closePullInput();
23797
24217
  }
23798
24218
  if (this.destroyed || !this.pullActive) break;
23799
- await this.pullSleep(PULL_REDIAL_MS);
24219
+ const backoffMs = this.pullFastRedial ? PULL_FAST_REDIAL_MS : PULL_REDIAL_MS;
24220
+ this.pullFastRedial = false;
24221
+ await this.pullSleep(backoffMs);
23800
24222
  }
23801
24223
  }
23802
24224
  /**
@@ -23807,7 +24229,7 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23807
24229
  * stream ends or the session is torn down.
23808
24230
  */
23809
24231
  async pullDialAndDecode(nav, C, url) {
23810
- const demuxer = await nav.Demuxer.open(url, { options: { rtsp_transport: "tcp" } });
24232
+ const demuxer = await nav.Demuxer.open(url, { options: buildPullDemuxerOptions() });
23811
24233
  if (this.destroyed || !this.pullActive) {
23812
24234
  demuxer[Symbol.dispose]?.();
23813
24235
  return;
@@ -23817,7 +24239,8 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23817
24239
  if (!videoStream) throw new Error("node-av decoder: pull input has no video stream");
23818
24240
  const decoder = await nav.Decoder.create(videoStream, {
23819
24241
  ...this.pullHwContext ? { hardware: this.pullHwContext } : {},
23820
- rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P }
24242
+ rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P },
24243
+ exitOnError: false
23821
24244
  });
23822
24245
  if (this.destroyed || !this.pullActive) {
23823
24246
  decoder[Symbol.dispose]?.();
@@ -23830,11 +24253,45 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23830
24253
  hwAccel: this.activeHwAccel,
23831
24254
  streamIndex: videoStream.index
23832
24255
  } });
23833
- for await (const frame of decoder.frames(demuxer.packets(videoStream.index))) {
23834
- if (this.destroyed || !this.pullActive) break;
23835
- if (!frame) continue;
23836
- this.inputPackets++;
24256
+ const probeTb = videoStream.timeBase;
24257
+ const tbNum = probeTb?.num ?? 0;
24258
+ const tbDen = probeTb?.den ?? 0;
24259
+ this.resetLagTracker(Date.now());
24260
+ await this.consumePullFrames(decoder.frames(demuxer.packets(videoStream.index)), tbNum, tbDen);
24261
+ }
24262
+ /**
24263
+ * Consume decoded pull frames until the stream ends or the session is torn
24264
+ * down. Each yielded frame is a node-av `Frame` — for the HW path a `clone`
24265
+ * that refs a VAAPI surface + the decoder's whole `hw_frames_ctx`, so a SINGLE
24266
+ * unfreed frame pins the dial's entire GPU surface pool (i915 GEM/shmem) until
24267
+ * the process dies. Therefore EVERY exit path (teardown break, drop-to-live
24268
+ * re-seat, normal emit, or a throw from the lag/emit calls) MUST `frame.free()`
24269
+ * — the `try/finally` below is the single release point that guarantees it.
24270
+ *
24271
+ * Extracted from {@link pullDialAndDecode} so this free-on-all-paths contract
24272
+ * is unit-testable with a stub async iterable (no live libav / GPU needed).
24273
+ */
24274
+ async consumePullFrames(frames, tbNum, tbDen) {
24275
+ for await (const frame of frames) {
24276
+ if (!frame) {
24277
+ if (this.destroyed || !this.pullActive) break;
24278
+ continue;
24279
+ }
23837
24280
  try {
24281
+ if (this.destroyed || !this.pullActive) break;
24282
+ this.inputPackets++;
24283
+ const wallNow = Date.now();
24284
+ this.updateLagTracker(frame.pts, tbNum, tbDen, wallNow);
24285
+ this.runAdaptiveController(wallNow);
24286
+ this.emitDebugFlash(wallNow);
24287
+ if (this.shouldDropToLive(wallNow)) {
24288
+ this.logger.warn("node-av decoder: lag beyond recovery — dropping to live edge", { meta: {
24289
+ lagMs: Math.round(this.lagMsEwma),
24290
+ redialInMs: PULL_FAST_REDIAL_MS
24291
+ } });
24292
+ this.pullFastRedial = true;
24293
+ break;
24294
+ }
23838
24295
  this.emitDecodedFrame(frame);
23839
24296
  } finally {
23840
24297
  frame.free();
@@ -23842,10 +24299,11 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23842
24299
  }
23843
24300
  }
23844
24301
  /**
23845
- * Resolve and build the HW context for pull mode ONCE. Explicit backend
23846
- * single try; `'auto'` → the kernel resolver's ordered list; no resolver →
23847
- * software. The first `HardwareContext.create` that succeeds wins; all
23848
- * failures fall through to software decode (`hardware: null`).
24302
+ * Resolve and build the HW context for pull mode ONCE (reused across every
24303
+ * re-dial, freed in `destroy`). Explicit backend → single try; `'auto'` → the
24304
+ * kernel resolver's ordered list; no resolver software. The first
24305
+ * `HardwareContext.create` that succeeds wins; all failures fall through to
24306
+ * software decode (`hardware: null`).
23849
24307
  */
23850
24308
  async ensurePullHwContext(nav, C) {
23851
24309
  if (this.hwaccelPref === "none") {
@@ -23865,7 +24323,7 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23865
24323
  if (!deviceType) continue;
23866
24324
  const hw = nav.HardwareContext.create(deviceType);
23867
24325
  if (!hw) {
23868
- this.logger.warn("node-av: pull hwaccel context create failed — trying next", { meta: { backend } });
24326
+ this.logger.debug("node-av: pull hwaccel context create failed — trying next", { meta: { backend } });
23869
24327
  continue;
23870
24328
  }
23871
24329
  this.pullHwContext = hw;
@@ -23900,6 +24358,107 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23900
24358
  }, ms);
23901
24359
  });
23902
24360
  }
24361
+ /**
24362
+ * Re-anchor the real-time lag tracker + per-dial controllers at the start of
24363
+ * each dial. A fresh dial (including a drop-to-live re-seat) begins at the
24364
+ * live edge, so the drift EWMA resets to 0 — otherwise the just-torn dial's
24365
+ * accumulated lag would re-trigger drop-to-live immediately. The learned
24366
+ * `adaptiveFps` is intentionally NOT reset: a re-seat should keep the shed
24367
+ * emit-rate and recover from there once lag stays low.
24368
+ */
24369
+ resetLagTracker(wallNow) {
24370
+ this.basePts = null;
24371
+ this.baseWall = 0;
24372
+ this.lagMsEwma = 0;
24373
+ this.lowLagSince = 0;
24374
+ this.lastAdaptiveCheck = wallNow;
24375
+ this.adaptiveWindowFrames = this.outputFrames;
24376
+ this.lastDebugFlush = wallNow;
24377
+ this.debugWindowDropped = this.droppedFrames;
24378
+ }
24379
+ /**
24380
+ * Update the EWMA of the decoder's real-time drift from a decoded frame's
24381
+ * pts. The first frame of a dial anchors `basePts`/`baseWall`; every later
24382
+ * frame compares elapsed wall-clock against elapsed media time —
24383
+ * `lagMs = wallElapsed - mediaElapsed`. A rising EWMA means the decoder is
24384
+ * falling behind the live edge (wall time outrunning the media timeline).
24385
+ */
24386
+ updateLagTracker(pts, tbNum, tbDen, wallNow) {
24387
+ if (tbDen === 0) return;
24388
+ if (this.basePts === null) {
24389
+ this.basePts = pts;
24390
+ this.baseWall = wallNow;
24391
+ return;
24392
+ }
24393
+ const sample = wallNow - this.baseWall - Number(pts - this.basePts) * 1e3 * tbNum / tbDen;
24394
+ this.lagMsEwma = .9 * this.lagMsEwma + .1 * sample;
24395
+ }
24396
+ /**
24397
+ * Lag-driven adaptive emit-fps controller (P1), self-throttled to
24398
+ * {@link ADAPTIVE_TICK_MS}. Under load (lag EWMA high) it halves the emit-fps
24399
+ * — fewer frames transferred + GPU-scaled + emitted, so the decoder stays
24400
+ * real-time instead of accumulating lag; once lag stays low for a sustained
24401
+ * window it grows the emit-fps back toward the subscriber's ceiling.
24402
+ * `minIntervalMs` (the throttle both emit paths honour) is recomputed from
24403
+ * `adaptiveFps`, NOT the raw ceiling.
24404
+ */
24405
+ runAdaptiveController(wallNow) {
24406
+ const windowMs = wallNow - this.lastAdaptiveCheck;
24407
+ if (windowMs < ADAPTIVE_TICK_MS) return;
24408
+ this.lastAdaptiveCheck = wallNow;
24409
+ const framesDelta = this.outputFrames - this.adaptiveWindowFrames;
24410
+ this.adaptiveWindowFrames = this.outputFrames;
24411
+ this.effectiveFps = windowMs > 0 ? framesDelta * 1e3 / windowMs : 0;
24412
+ const lag = this.lagMsEwma;
24413
+ if (lag > ADAPTIVE_LAG_HIGH_MS) {
24414
+ this.adaptiveFps = Math.max(ADAPTIVE_MIN_FPS, this.adaptiveFps / 2);
24415
+ this.lowLagSince = 0;
24416
+ } else if (lag < ADAPTIVE_LAG_LOW_MS) {
24417
+ if (this.lowLagSince === 0) this.lowLagSince = wallNow;
24418
+ else if (wallNow - this.lowLagSince >= ADAPTIVE_RECOVER_SUSTAIN_MS) {
24419
+ this.adaptiveFps = Math.min(this.ceilingFps, this.adaptiveFps * 1.5);
24420
+ this.lowLagSince = wallNow;
24421
+ }
24422
+ } else this.lowLagSince = 0;
24423
+ this.minIntervalMs = this.adaptiveFps > 0 ? 1e3 / this.adaptiveFps : 0;
24424
+ }
24425
+ /**
24426
+ * Whether the decoder is hopelessly behind the live edge and should tear the
24427
+ * input down for a fast re-seat. Rate-limited to at most once per
24428
+ * {@link DROP_TO_LIVE_MIN_INTERVAL_MS} so a bad stretch can't hot-loop the
24429
+ * dial. Records the trigger time as a side effect when it returns `true`.
24430
+ */
24431
+ shouldDropToLive(wallNow) {
24432
+ if (this.lagMsEwma <= DROP_TO_LIVE_LAG_MS) return false;
24433
+ if (wallNow - this.lastDropToLive < DROP_TO_LIVE_MIN_INTERVAL_MS) return false;
24434
+ this.lastDropToLive = wallNow;
24435
+ return true;
24436
+ }
24437
+ /**
24438
+ * Per-camera DEBUG facility (mirrors the stream-broker's `streamingDebug`
24439
+ * gate). When `config.debug` is set, flush a single structured `decoder
24440
+ * debug` line at ~1Hz — effective/adaptive fps, real-time lag, dropped-frame
24441
+ * delta, avg decode time, hwaccel. `deviceId`/`tag` ride on the logger tags.
24442
+ * Cheap: all values come from running counters, and the whole method is a
24443
+ * no-op (one comparison) when debug is disabled or the window hasn't elapsed.
24444
+ */
24445
+ emitDebugFlash(wallNow) {
24446
+ if (!this.debugEnabled) return;
24447
+ if (wallNow - this.lastDebugFlush < DEBUG_FLUSH_MS) return;
24448
+ this.lastDebugFlush = wallNow;
24449
+ const droppedDelta = this.droppedFrames - this.debugWindowDropped;
24450
+ this.debugWindowDropped = this.droppedFrames;
24451
+ const decodeMsAvg = this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0;
24452
+ this.logger.info("decoder debug", { meta: {
24453
+ effectiveFps: Number(this.effectiveFps.toFixed(1)),
24454
+ adaptiveFps: Number(this.adaptiveFps.toFixed(1)),
24455
+ ceilingFps: this.ceilingFps,
24456
+ lagMs: Math.round(this.lagMsEwma),
24457
+ droppedFrames: droppedDelta,
24458
+ decodeMs: Number(decodeMsAvg.toFixed(2)),
24459
+ hwAccel: this.activeHwAccel
24460
+ } });
24461
+ }
23903
24462
  pushPacket(packet) {
23904
24463
  if (this.destroyed) return;
23905
24464
  if (this.pullActive) {
@@ -23956,6 +24515,10 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
23956
24515
  }
23957
24516
  emitDecodedFrame(frame) {
23958
24517
  const now = performance.now();
24518
+ if (frame.isHwFrame()) {
24519
+ this.droppedFrames++;
24520
+ return;
24521
+ }
23959
24522
  if (this.minIntervalMs > 0 && now - this.lastEmitTime < this.minIntervalMs) {
23960
24523
  this.droppedFrames++;
23961
24524
  return;
@@ -24325,12 +24888,19 @@ var NodeAvDecoderSession = class NodeAvDecoderSession {
24325
24888
  }
24326
24889
  getStats() {
24327
24890
  const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
24328
- return {
24891
+ const base = {
24329
24892
  inputFps: this.inputPackets / uptimeSec,
24330
24893
  outputFps: this.outputFrames / uptimeSec,
24331
24894
  avgDecodeTimeMs: this.outputFrames > 0 ? this.totalDecodeTimeMs / this.outputFrames : 0,
24332
24895
  droppedFrames: this.droppedFrames
24333
24896
  };
24897
+ if (!this.pullActive) return base;
24898
+ return {
24899
+ ...base,
24900
+ lagMs: this.lagMsEwma,
24901
+ effectiveFps: this.effectiveFps,
24902
+ adaptiveFps: this.adaptiveFps
24903
+ };
24334
24904
  }
24335
24905
  get isPullMode() {
24336
24906
  return this.pullActive;
@@ -24840,6 +25410,7 @@ var NodeAvAudioEncodeSession = class {
24840
25410
  //#region src/audio-codec/provider.ts
24841
25411
  var DEFAULT_IDLE_MS = 3e4;
24842
25412
  var MAX_PCM_QUEUE_CHUNKS = 500;
25413
+ var MAX_ENCODED_QUEUE_CHUNKS = 500;
24843
25414
  var REAPER_INTERVAL_MS = 5e3;
24844
25415
  /** Grace wait for the encoder to drain its tail after `flushEncode`. */
24845
25416
  var FLUSH_DRAIN_MS = 60;
@@ -25046,6 +25617,7 @@ var NodeAvAudioCodecProvider = class {
25046
25617
  ...s.config.bitrateKbps !== void 0 ? { bitrateKbps: s.config.bitrateKbps } : {}
25047
25618
  }, this.deps.logger, (chunk) => {
25048
25619
  s.encodedQueue.push(chunk);
25620
+ if (s.encodedQueue.length > MAX_ENCODED_QUEUE_CHUNKS) s.encodedQueue.splice(0, s.encodedQueue.length - MAX_ENCODED_QUEUE_CHUNKS);
25049
25621
  });
25050
25622
  }
25051
25623
  pcmFormat(format) {
@@ -25209,6 +25781,11 @@ var DecoderNodeAvAddon = class extends BaseAddon {
25209
25781
  return registrations;
25210
25782
  }
25211
25783
  this.ctx.logger.info("node-av decoder addon initialized", { meta: { selectedBackend: backend } });
25784
+ const purged = purgeOrphanSegments(SEGMENT_NAME_PREFIX);
25785
+ if (purged.removed > 0) this.ctx.logger.warn("node-av decoder: reclaimed orphaned shm segments at startup", { meta: {
25786
+ removed: purged.removed,
25787
+ scanned: purged.scanned
25788
+ } });
25212
25789
  this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
25213
25790
  if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
25214
25791
  this.ctx.logger.warn("nodeav: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });