@camstack/addon-pipeline 1.2.130 → 1.2.132

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 (35) hide show
  1. package/dist/{addon-utils-DH78p79i.js → addon-utils-DzCn6z7v.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +332 -20
  5. package/dist/detection-pipeline/index.mjs +330 -18
  6. package/dist/{dist-Wt81pN5Y.mjs → dist-BZYUUBUx.mjs} +1900 -1714
  7. package/dist/{dist-DOw-izoI.js → dist-DBP47QOz.js} +1900 -1714
  8. package/dist/{event-loop-stall-monitor-Bld7jNCb.js → event-loop-stall-monitor-CiH_MHfO.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-CtQCOZqm.mjs → event-loop-stall-monitor-IACE22fK.mjs} +1 -1
  10. package/dist/{lazy-sharp-BbNI2ZD-.js → lazy-sharp-B_kETjyQ.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-1_D3EZT9.mjs → process-memory-C7rsraCR.mjs} +1 -1
  16. package/dist/{process-memory-D9H5qjDl.js → process-memory-CtXQAA3d.js} +1 -1
  17. package/dist/recorder/index.js +326 -17
  18. package/dist/recorder/index.mjs +325 -16
  19. package/dist/{segment-demux-js-BW-Q-j2A.mjs → segment-demux-js-BL1UDdiI.mjs} +1 -1
  20. package/dist/{segment-demux-js-C2jBBqGW.js → segment-demux-js-wTzFi5UW.js} +1 -1
  21. package/dist/session-decode/decode-worker-child.js +2 -2
  22. package/dist/session-decode/decode-worker-child.mjs +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dw-iwP4y.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DlKJZ-_M.mjs} +3 -3
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bme2OZQp.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DXKTNEae.mjs} +1 -1
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D36MoVV1.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUw_aTfK.mjs} +1 -1
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-DEVCEYgu.mjs → hostInit-kDKgfUjp.mjs} +3 -3
  30. package/dist/stream-broker/index.js +2 -2
  31. package/dist/stream-broker/index.mjs +2 -2
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-nAZl_Rsv.js → worker-protocol-C3zaz0Jv.js} +1 -1
  34. package/dist/{worker-protocol-rpnr7Ldd.mjs → worker-protocol-Cut8984B.mjs} +1 -1
  35. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DOw-izoI.js");
1
+ const require_dist = require("./dist-DBP47QOz.js");
2
2
  let node_crypto = require("node:crypto");
3
3
  let node_perf_hooks = require("node:perf_hooks");
4
4
  //#region src/detection-pipeline/registry/model-catalogs.ts
@@ -1,4 +1,4 @@
1
- import { K as hfModelUrl, a as COCO_80_LABELS, j as audioKindId, o as COCO_TO_MACRO, r as AUDIO_MACRO_LABELS } from "./dist-Wt81pN5Y.mjs";
1
+ import { K as hfModelUrl, a as COCO_80_LABELS, j as audioKindId, o as COCO_TO_MACRO, r as AUDIO_MACRO_LABELS } from "./dist-BZYUUBUx.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { PerformanceObserver } from "node:perf_hooks";
4
4
  //#region src/detection-pipeline/registry/model-catalogs.ts
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DOw-izoI.js");
1
+ const require_dist = require("./dist-DBP47QOz.js");
2
2
  let node_url = require("node:url");
3
3
  let node_module = require("node:module");
4
4
  let node_path = require("node:path");
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DOw-izoI.js");
6
- const require_lazy_sharp = require("../lazy-sharp-BbNI2ZD-.js");
5
+ const require_dist = require("../dist-DBP47QOz.js");
6
+ const require_lazy_sharp = require("../lazy-sharp-B_kETjyQ.js");
7
7
  let node_path = require("node:path");
8
8
  let node_fs = require("node:fs");
9
9
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -1,4 +1,4 @@
1
- import { Dt as hydrateSchema, G as evaluateZoneRules, St as BaseAddon, Tt as DeviceType, X as motionDetectionCapability } from "../dist-Wt81pN5Y.mjs";
1
+ import { Dt as hydrateSchema, G as evaluateZoneRules, St as BaseAddon, Tt as DeviceType, X as motionDetectionCapability } from "../dist-BZYUUBUx.mjs";
2
2
  import { t as getSharp } from "../lazy-sharp-BtT5x-qP.mjs";
3
3
  import { join } from "node:path";
4
4
  import { readFileSync } from "node:fs";
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DOw-izoI.js");
6
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-Bld7jNCb.js");
7
- const require_worker_protocol = require("../worker-protocol-nAZl_Rsv.js");
5
+ const require_dist = require("../dist-DBP47QOz.js");
6
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-CiH_MHfO.js");
7
+ const require_worker_protocol = require("../worker-protocol-C3zaz0Jv.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
- const require_lazy_sharp = require("../lazy-sharp-BbNI2ZD-.js");
9
+ const require_lazy_sharp = require("../lazy-sharp-B_kETjyQ.js");
10
10
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
11
11
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
12
12
  let sharp = require("sharp");
@@ -1,6 +1,6 @@
1
- import { Bt as lazy, Et as createEvent, F as customAction, Gt as string, Ht as number, It as _enum, L as defineCustomActions, Lt as array, Rt as boolean, St as BaseAddon, Ut as object, Vt as literal, f as DEFAULT_NATIVE_LEASE_SETTINGS, gt as errMsg, it as pipelineRunnerCapability, jt as nodePin, nt as pickNativeLeaseOverride, p as DEVICE_BACKEND_TO_FORMAT, qt as EventCategory, tt as pickDetailCropConvention, u as DEFAULT_DETAIL_CROP_CONVENTION, z as deriveDetailCropRect } from "../dist-Wt81pN5Y.mjs";
2
- import { i as resolveFrameViewGeometry, r as localFrameRegistry, t as attributeStall, u as getStepDefinition } from "../event-loop-stall-monitor-CtQCOZqm.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-rpnr7Ldd.mjs";
1
+ import { Bt as lazy, Et as createEvent, F as customAction, Gt as string, Ht as number, It as _enum, L as defineCustomActions, Lt as array, Rt as boolean, St as BaseAddon, Ut as object, Vt as literal, f as DEFAULT_NATIVE_LEASE_SETTINGS, gt as errMsg, it as pipelineRunnerCapability, jt as nodePin, nt as pickNativeLeaseOverride, p as DEVICE_BACKEND_TO_FORMAT, qt as EventCategory, tt as pickDetailCropConvention, u as DEFAULT_DETAIL_CROP_CONVENTION, z as deriveDetailCropRect } from "../dist-BZYUUBUx.mjs";
2
+ import { i as resolveFrameViewGeometry, r as localFrameRegistry, t as attributeStall, u as getStepDefinition } from "../event-loop-stall-monitor-IACE22fK.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-Cut8984B.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-BtT5x-qP.mjs";
6
6
  import { n as withDetectionIntent } from "../restream-intent-B4BXZra7.mjs";
@@ -1,4 +1,4 @@
1
- import { Q as parseProcStatus } from "./dist-Wt81pN5Y.mjs";
1
+ import { Q as parseProcStatus } from "./dist-BZYUUBUx.mjs";
2
2
  import { execFile } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  //#region src/shared/node-topology-platform.ts
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DOw-izoI.js");
1
+ const require_dist = require("./dist-DBP47QOz.js");
2
2
  let node_child_process = require("node:child_process");
3
3
  let node_fs = require("node:fs");
4
4
  node_fs = require_dist.__toESM(node_fs);
@@ -1,7 +1,7 @@
1
- const require_dist = require("../dist-DOw-izoI.js");
1
+ const require_dist = require("../dist-DBP47QOz.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
3
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
4
- const require_addon_utils = require("../addon-utils-DH78p79i.js");
4
+ const require_addon_utils = require("../addon-utils-DzCn6z7v.js");
5
5
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let node_child_process = require("node:child_process");
@@ -350,8 +350,13 @@ function fragmentRangeFor(index, offsetTicks) {
350
350
  * "nothing to read" (D49's letter, applied to a byte budget: a fallible
351
351
  * computation must never silently look like an unbind).
352
352
  */
353
- function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
354
- const { samples, mfraOffset } = index;
353
+ /**
354
+ * The last sync sample at or before `offsetTicks` — the keyframe a decoder
355
+ * must start from. Shared by {@link fragmentsWithinBudget} and
356
+ * {@link fragmentsInWindow}: both walk forward from the same anchor, they
357
+ * only differ in the condition that stops the walk.
358
+ */
359
+ function findAnchor(samples, offsetTicks) {
355
360
  let lo = 0;
356
361
  let hi = samples.length - 1;
357
362
  let anchor = 0;
@@ -364,6 +369,11 @@ function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
364
369
  lo = mid + 1;
365
370
  } else hi = mid - 1;
366
371
  }
372
+ return anchor;
373
+ }
374
+ function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
375
+ const { samples, mfraOffset } = index;
376
+ const anchor = findAnchor(samples, offsetTicks);
367
377
  const endOf = (i) => samples[i + 1]?.moofOffset ?? mfraOffset;
368
378
  const anchorStart = samples[anchor]?.moofOffset ?? 0;
369
379
  const anchorEnd = endOf(anchor);
@@ -400,6 +410,70 @@ function fragmentsWithinBudget(index, offsetTicks, maxBytes) {
400
410
  oversizeFragment
401
411
  };
402
412
  }
413
+ /**
414
+ * Convert a TIME WINDOW `[fromTicks, toTicks)` into the set of consecutive
415
+ * fragments that covers it — the replay's primitive for "give me this many
416
+ * seconds of one segment", as opposed to {@link fragmentsWithinBudget}'s
417
+ * "give me as much as fits in N bytes" (the scrub GOP read's primitive).
418
+ *
419
+ * Stops walking forward the instant the NEXT fragment's own start time
420
+ * already reaches `toTicks`: at that point the fragments already collected
421
+ * cover the whole window, because each fragment's coverage runs up to the
422
+ * next one's start (the same relationship {@link fragmentsWithinBudget}'s
423
+ * `endOf` encodes). `maxBytes` is a SAFETY CEILING, not the primary stop
424
+ * condition — a caller with a well-formed window is expected to hit the time
425
+ * condition first; the byte cap exists so a pathological request cannot pull
426
+ * an unbounded amount of one segment into memory.
427
+ *
428
+ * Same never-empty guarantee as {@link fragmentsWithinBudget}: the anchor
429
+ * fragment is always returned, even when it alone exceeds `maxBytes`.
430
+ */
431
+ function fragmentsInWindow(index, fromTicks, toTicks, maxBytes) {
432
+ const { samples, mfraOffset } = index;
433
+ const anchor = findAnchor(samples, fromTicks);
434
+ const endOf = (i) => samples[i + 1]?.moofOffset ?? mfraOffset;
435
+ const windowCoveredAt = (i) => {
436
+ const next = samples[i + 1];
437
+ return next === void 0 || next.timeTicks >= toTicks;
438
+ };
439
+ const anchorStart = samples[anchor]?.moofOffset ?? 0;
440
+ const anchorEnd = endOf(anchor);
441
+ const fragments = [{
442
+ start: anchorStart,
443
+ end: anchorEnd,
444
+ sampleIndex: anchor
445
+ }];
446
+ let total = anchorEnd - anchorStart;
447
+ const oversizeFragment = total > maxBytes;
448
+ let i = anchor;
449
+ let reachesWindowEnd = windowCoveredAt(i);
450
+ while (!reachesWindowEnd) {
451
+ const nextIndex = i + 1;
452
+ const nextSample = samples[nextIndex];
453
+ if (nextSample === void 0) break;
454
+ const nextStart = nextSample.moofOffset;
455
+ const nextEnd = endOf(nextIndex);
456
+ const nextSize = nextEnd - nextStart;
457
+ if (total + nextSize > maxBytes) break;
458
+ fragments.push({
459
+ start: nextStart,
460
+ end: nextEnd,
461
+ sampleIndex: nextIndex
462
+ });
463
+ total += nextSize;
464
+ i = nextIndex;
465
+ reachesWindowEnd = windowCoveredAt(i);
466
+ }
467
+ const last = fragments[fragments.length - 1];
468
+ return {
469
+ fragments,
470
+ start: fragments[0].start,
471
+ end: last.end,
472
+ reachesSegmentEnd: last.end >= mfraOffset,
473
+ reachesWindowEnd,
474
+ oversizeFragment
475
+ };
476
+ }
403
477
  //#endregion
404
478
  //#region src/recorder/mfra-capture.ts
405
479
  /**
@@ -4440,6 +4514,34 @@ var SCRUB_GOP_BUDGET_MAX_BYTES = 15e5;
4440
4514
  function ticksToMs(ticks, timescale) {
4441
4515
  return Math.round(ticks / timescale * 1e3);
4442
4516
  }
4517
+ /** ms → ticks on the video timescale — the inverse of {@link ticksToMs}. */
4518
+ function msToTicks(ms, timescale) {
4519
+ return ms / 1e3 * timescale;
4520
+ }
4521
+ /**
4522
+ * Steps 1-2 shared by {@link readGopRange} and {@link readGopWindowRange}:
4523
+ * locate the `mfra` (seeded or from the tail), read the `ftyp`+`moov` head,
4524
+ * and pick the VIDEO track's sync table (never the audio one, which lists
4525
+ * every `moof` and would send a seek to bytes that do not open a GOP).
4526
+ * `null` on anything that does not look like a recorded segment.
4527
+ */
4528
+ async function resolveSegmentIndex(file, seededIndex, read) {
4529
+ const parsed = seededIndex === void 0 ? await parseTail(file, read) : null;
4530
+ if (seededIndex === void 0 && parsed === null) return null;
4531
+ const index = seededIndex ?? parsed.index;
4532
+ const firstMoof = index.samples[0]?.moofOffset ?? 0;
4533
+ if (firstMoof <= 0 || firstMoof >= file.size) return null;
4534
+ const head = await read(0, firstMoof);
4535
+ const init = parseInitInfo(head);
4536
+ if (init === null) return null;
4537
+ const videoIndex = index.trackId === init.videoTrackId ? index : parsed === null ? null : reparseTail(parsed.tail, file.size, init.videoTrackId);
4538
+ if (videoIndex === null) return null;
4539
+ return {
4540
+ head,
4541
+ init,
4542
+ videoIndex
4543
+ };
4544
+ }
4443
4545
  /**
4444
4546
  * Read the single GOP covering `epochMs` from `file`, by byte range.
4445
4547
  *
@@ -4455,17 +4557,10 @@ async function readGopRange(file, args) {
4455
4557
  bytesRead += chunk.length;
4456
4558
  return chunk;
4457
4559
  };
4458
- const parsed = args.seededIndex === void 0 ? await parseTail(file, read) : null;
4459
- if (args.seededIndex === void 0 && parsed === null) return null;
4460
- const index = args.seededIndex ?? parsed.index;
4461
- const firstMoof = index.samples[0]?.moofOffset ?? 0;
4462
- if (firstMoof <= 0 || firstMoof >= file.size) return null;
4463
- const head = await read(0, firstMoof);
4464
- const init = parseInitInfo(head);
4465
- if (init === null) return null;
4466
- const videoIndex = index.trackId === init.videoTrackId ? index : parsed === null ? null : reparseTail(parsed.tail, file.size, init.videoTrackId);
4467
- if (videoIndex === null) return null;
4468
- const offsetTicks = Math.max(0, args.epochMs - args.segmentStartMs) / 1e3 * init.videoTimescale;
4560
+ const resolved = await resolveSegmentIndex(file, args.seededIndex, read);
4561
+ if (resolved === null) return null;
4562
+ const { head, init, videoIndex } = resolved;
4563
+ const offsetTicks = msToTicks(Math.max(0, args.epochMs - args.segmentStartMs), init.videoTimescale);
4469
4564
  let start;
4470
4565
  let end;
4471
4566
  let firstSampleIndex;
@@ -4510,6 +4605,49 @@ async function readGopRange(file, args) {
4510
4605
  };
4511
4606
  }
4512
4607
  /**
4608
+ * Read the fragments covering `[fromMs, toMs)` from `file`, by byte range —
4609
+ * the WINDOW twin of {@link readGopRange}'s single instant. Same shape, same
4610
+ * standalone-demuxable `ftyp`+`moov` head, same `null`-on-unparseable
4611
+ * contract; the only difference is the stop condition
4612
+ * ({@link fragmentsInWindow} instead of {@link fragmentRangeFor} /
4613
+ * {@link fragmentsWithinBudget}).
4614
+ */
4615
+ async function readGopWindowRange(file, args) {
4616
+ let bytesRead = 0;
4617
+ const read = async (offset, length) => {
4618
+ const chunk = await file.read(offset, length);
4619
+ bytesRead += chunk.length;
4620
+ return chunk;
4621
+ };
4622
+ const resolved = await resolveSegmentIndex(file, args.seededIndex, read);
4623
+ if (resolved === null) return null;
4624
+ const { head, init, videoIndex } = resolved;
4625
+ const fromOffsetMs = Math.max(0, args.fromMs - args.segmentStartMs);
4626
+ const toOffsetMs = Math.max(fromOffsetMs, args.toMs - args.segmentStartMs);
4627
+ const plan = fragmentsInWindow(videoIndex, msToTicks(fromOffsetMs, init.videoTimescale), msToTicks(toOffsetMs, init.videoTimescale), args.maxBytes);
4628
+ const { start, end } = plan;
4629
+ if (start <= 0 || end <= start || end > file.size) return null;
4630
+ const fragment = await read(start, end - start);
4631
+ const firstSampleIndex = plan.fragments[0].sampleIndex;
4632
+ const lastSampleIndex = plan.fragments[plan.fragments.length - 1].sampleIndex;
4633
+ const startTicks = videoIndex.samples[firstSampleIndex]?.timeTicks ?? 0;
4634
+ const nextTicks = videoIndex.samples[lastSampleIndex + 1]?.timeTicks;
4635
+ const gopStartOffsetMs = ticksToMs(startTicks, init.videoTimescale);
4636
+ const gopDurMs = nextTicks !== void 0 ? ticksToMs(nextTicks - startTicks, init.videoTimescale) : Math.max(1, args.segmentDurMs - gopStartOffsetMs);
4637
+ const data = new Uint8Array(head.length + fragment.length);
4638
+ data.set(head, 0);
4639
+ data.set(fragment, head.length);
4640
+ return {
4641
+ data,
4642
+ gopStartMs: args.segmentStartMs + gopStartOffsetMs,
4643
+ gopDurMs,
4644
+ bytesRead,
4645
+ reachesSegmentEnd: plan.reachesSegmentEnd,
4646
+ reachesWindowEnd: plan.reachesWindowEnd,
4647
+ oversizeFragment: plan.oversizeFragment
4648
+ };
4649
+ }
4650
+ /**
4513
4651
  * Fetch + parse the `mfra` tail, honouring `MfraParseError.needBytes` with ONE
4514
4652
  * widened retry. Null on anything else — bytes that are not a segment.
4515
4653
  */
@@ -4801,6 +4939,16 @@ var RANGE_MERGE_GAP_MS = 5e3;
4801
4939
  * hour but covers the requested epoch is hydrated too. Recorder segments are
4802
4940
  * currently much shorter; the margin keeps this boundary future-safe. */
4803
4941
  var LOCATE_HYDRATE_MARGIN_MS = 6e4;
4942
+ /**
4943
+ * Safety ceiling for one `readWindowBytes` answer — NOT the primary stop
4944
+ * condition (the requested `toMs` is; see `fragmentsInWindow` in
4945
+ * `mfra-index.ts`). Sized off `high`'s own bandwidth hint above
4946
+ * (`PROFILE_BANDWIDTH.high` = 4 Mbps = 500 000 B/s): a 30 s replay window at
4947
+ * that rate is ~15 MB, so 20 MB covers it with headroom while still bounding
4948
+ * a pathological request. A window this call actually truncates is reported
4949
+ * (`reachesRequestedEnd: false`) rather than silently shortened.
4950
+ */
4951
+ var WINDOW_READ_MAX_BYTES = 2e7;
4804
4952
  /** Empty "no playback" manifest. */
4805
4953
  function noPlayback(deviceId) {
4806
4954
  return {
@@ -5318,6 +5466,76 @@ function buildRecordingProvider(deps) {
5318
5466
  gopDurMs: seg.durMs
5319
5467
  };
5320
5468
  },
5469
+ readWindowBytes: async ({ deviceId, profile, startMs, fromMs, toMs }) => {
5470
+ const { abs, seg } = resolveSegmentFile(deps, deviceId, profile, startMs);
5471
+ const segmentEndMs = seg.startMs + seg.durMs;
5472
+ if (toMs > segmentEndMs) {
5473
+ deps.logger.warn("recording: readWindowBytes refused — window runs past this segment, not silently clipped", {
5474
+ tags: { deviceId },
5475
+ meta: {
5476
+ profile,
5477
+ startMs,
5478
+ fromMs,
5479
+ toMs,
5480
+ segmentEndMs
5481
+ }
5482
+ });
5483
+ return {
5484
+ kind: "spans-multiple-segments",
5485
+ segmentEndMs
5486
+ };
5487
+ }
5488
+ const file = await (deps.openRangeReadable ?? openRangeFromDisk)(abs, seg.bytes);
5489
+ try {
5490
+ const gop = await readGopWindowRange(file, {
5491
+ segmentStartMs: seg.startMs,
5492
+ segmentDurMs: seg.durMs,
5493
+ fromMs,
5494
+ toMs,
5495
+ seededIndex: deps.mfraFor?.(deviceId, profile, startMs),
5496
+ maxBytes: WINDOW_READ_MAX_BYTES
5497
+ });
5498
+ if (gop !== null) {
5499
+ if (gop.reachesWindowEnd !== true) deps.logger.warn("recording: readWindowBytes truncated by its safety byte cap before covering the requested window", {
5500
+ tags: { deviceId },
5501
+ meta: {
5502
+ profile,
5503
+ startMs,
5504
+ fromMs,
5505
+ toMs,
5506
+ bytes: gop.data.length,
5507
+ WINDOW_READ_MAX_BYTES
5508
+ }
5509
+ });
5510
+ return {
5511
+ kind: "ok",
5512
+ data: gop.data,
5513
+ gopStartMs: gop.gopStartMs,
5514
+ gopDurMs: gop.gopDurMs,
5515
+ reachesRequestedEnd: gop.reachesWindowEnd === true
5516
+ };
5517
+ }
5518
+ } finally {
5519
+ await file.close();
5520
+ }
5521
+ deps.logger.warn("recording: readWindowBytes fell back to the WHOLE segment (mfra/moov unparseable)", {
5522
+ tags: { deviceId },
5523
+ meta: {
5524
+ profile,
5525
+ startMs,
5526
+ fromMs,
5527
+ toMs
5528
+ }
5529
+ });
5530
+ const data = await readFile(abs);
5531
+ return {
5532
+ kind: "ok",
5533
+ data: Uint8Array.from(data),
5534
+ gopStartMs: seg.startMs,
5535
+ gopDurMs: seg.durMs,
5536
+ reachesRequestedEnd: true
5537
+ };
5538
+ },
5321
5539
  setDeviceConfig: async ({ deviceId, config }) => {
5322
5540
  const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
5323
5541
  if (deps.onConfigChanged) try {
@@ -7522,7 +7740,46 @@ function resolveBandBufferMs(band) {
7522
7740
  */
7523
7741
  function eventsBandCanEverDemand(band) {
7524
7742
  if (band.mode !== "events") return true;
7525
- return (band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0) && resolveBandBufferMs(band).postMs > 0;
7743
+ return bandTriggerSources(band).length > 0 && resolveBandBufferMs(band).postMs > 0;
7744
+ }
7745
+ /**
7746
+ * LE sorgenti che questa banda ascolta — l'unico posto dove i `triggers` di
7747
+ * una banda diventano `TriggerSource[]`. `eventsBandCanEverDemand` e
7748
+ * `RecordingController.reportUnrecordable` lo leggono invece di ripetere
7749
+ * l'if-ladder motion/audio a mano.
7750
+ */
7751
+ function bandTriggerSources(band) {
7752
+ const t = band.triggers;
7753
+ if (t === void 0) return [];
7754
+ const sources = [];
7755
+ if (t.motion === true) sources.push("motion");
7756
+ if (t.audioThresholdDbfs !== void 0) sources.push("audio");
7757
+ return sources;
7758
+ }
7759
+ /**
7760
+ * Does this audio window's level clear the ACTIVE band's configured
7761
+ * threshold?
7762
+ *
7763
+ * `PipelineAudioInferenceResultPayload.frame.level.dbfs`
7764
+ * (`packages/types/src/types/detection.ts:448`) and
7765
+ * `RecordingTriggers.audioThresholdDbfs` are the SAME unit and the SAME
7766
+ * scale — dBFS, 0 = full scale, more negative = quieter — produced by
7767
+ * `audio-analyzer-provider.ts:77` (`20 * log10(rms)`). No conversion exists
7768
+ * between them because none is needed: the one other place in this repo that
7769
+ * compares the two values does the same bare `>=`
7770
+ * (`notification-center/audio-rule-matcher.ts:221`). Inventing a conversion
7771
+ * here would recreate the exact defect this track exists to close, only
7772
+ * harder to see because a wrong scale still fires occasionally.
7773
+ *
7774
+ * `band` must be the band ACTIVE AT the audio window's own timestamp — a
7775
+ * device can carry several bands with different thresholds (day/night), and
7776
+ * only one is ever "the" threshold at a given instant. The caller
7777
+ * (`RecordingController.onAudioLevel`) resolves it the same way
7778
+ * `postBufferMsFor` resolves the post-buffer.
7779
+ */
7780
+ function audioLevelQualifies(band, dbfs) {
7781
+ const threshold = band?.triggers?.audioThresholdDbfs;
7782
+ return threshold !== void 0 && dbfs >= threshold;
7526
7783
  }
7527
7784
  /** No trigger has been seen for this device yet. */
7528
7785
  var EMPTY_TRIGGER_STATE = {
@@ -9385,6 +9642,43 @@ var RecordingController = class {
9385
9642
  }
9386
9643
  }
9387
9644
  /**
9645
+ * Feed one audio window's LEVEL into the trigger pipeline — the counterpart
9646
+ * of `onTrigger('motion', …)` for the `audioThresholdDbfs` source, and the
9647
+ * feed that source was missing until 2026-08-26 (D62: authorized by the UI,
9648
+ * read by every pure function in `band-decision.ts`, alimented by nobody).
9649
+ *
9650
+ * Qualification happens HERE, not upstream like motion's `data.detected`,
9651
+ * because it depends on a PER-BAND threshold: `audioThresholdDbfs` is
9652
+ * authored per band, not once per device, so it needs the same config read
9653
+ * {@link postBufferMsFor} already does to resolve which band is active at
9654
+ * `atMs` before it can know which threshold applies.
9655
+ *
9656
+ * Fed from the event bus (telemetry, may be dropped — D8/D11), so a
9657
+ * config-read failure here drops only THIS window's qualification test
9658
+ * rather than guessing a threshold — logged, never silent, and never fatal:
9659
+ * the device's recordability does not depend on any single audio sample,
9660
+ * the next window (or motion) can still open the band.
9661
+ */
9662
+ async onAudioLevel(deviceId, dbfs, atMs) {
9663
+ if (this.stopped) return;
9664
+ let band;
9665
+ try {
9666
+ band = activeBandAt({ bands: (await this.deps.loadConfig(deviceId)).bands ?? [] }, new Date(atMs));
9667
+ } catch (err) {
9668
+ this.deps.logger.warn("recorder: audio trigger qualification skipped — config read failed", {
9669
+ tags: { deviceId },
9670
+ meta: {
9671
+ atMs,
9672
+ dbfs,
9673
+ error: require_dist.errMsg(err)
9674
+ }
9675
+ });
9676
+ return;
9677
+ }
9678
+ if (!audioLevelQualifies(band, dbfs)) return;
9679
+ await this.onTrigger(deviceId, "audio", atMs);
9680
+ }
9681
+ /**
9388
9682
  * The post-buffer that governs this device's trigger history right now: it is
9389
9683
  * both the MERGE GAP (a trigger landing inside the open live window belongs to
9390
9684
  * the running episode) and the retention horizon. Falls back to the shipped
@@ -9499,7 +9793,7 @@ var RecordingController = class {
9499
9793
  meta: {
9500
9794
  postBufferSec: band.postBufferSec,
9501
9795
  triggers: band.triggers,
9502
- reason: band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0 ? "postBufferSec is 0" : "no trigger configured"
9796
+ reason: bandTriggerSources(band).length > 0 ? "postBufferSec is 0" : "no trigger configured"
9503
9797
  }
9504
9798
  });
9505
9799
  }
@@ -11553,6 +11847,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
11553
11847
  eventMap = new EventMap();
11554
11848
  /** Unsubscribe handle for the EventCapture motion subscription. */
11555
11849
  eventUnsub = null;
11850
+ /**
11851
+ * Unsubscribe handle for the `PipelineAudioInferenceResult` audio-level
11852
+ * subscription — the feed `audioThresholdDbfs` was missing (D62, Traccia 0
11853
+ * of `docs/design/plans/2026-08-26-trigger-detection-e-sensori.md`). Kept
11854
+ * separate from `eventUnsub` (a distinct bus category) but guarded and torn
11855
+ * down the same way, idempotent re-entry included.
11856
+ */
11857
+ audioTriggerUnsub = null;
11556
11858
  /** Resolved recordings locations (id + root). Refreshed on boot + rescan.
11557
11859
  * Already ALIAS-GUARDED: a writable same-type duplicate of another
11558
11860
  * location's physical root arrives here demoted to read-only. */
@@ -12024,6 +12326,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12024
12326
  async onShutdown() {
12025
12327
  this.eventUnsub?.();
12026
12328
  this.eventUnsub = null;
12329
+ this.audioTriggerUnsub?.();
12330
+ this.audioTriggerUnsub = null;
12027
12331
  await this.controller?.stop();
12028
12332
  this.controller = null;
12029
12333
  this.segmentStore = null;
@@ -12098,6 +12402,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12098
12402
  this.controller?.onTrigger(deviceId, "motion", atMs);
12099
12403
  }
12100
12404
  });
12405
+ if (this.audioTriggerUnsub === null) this.audioTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
12406
+ const dbfs = event.data.frame.level?.dbfs;
12407
+ if (dbfs === void 0) return;
12408
+ this.controller?.onAudioLevel(event.data.deviceId, dbfs, event.data.frame.timestamp);
12409
+ });
12101
12410
  try {
12102
12411
  await this.segmentHours?.load();
12103
12412
  this.segmentHours?.hydrateIndex(this.index);