@camstack/types 1.2.162 → 1.2.164

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 (38) hide show
  1. package/dist/addon.js +4 -3
  2. package/dist/addon.mjs +4 -3
  3. package/dist/capabilities/alerts.cap.d.ts +5 -5
  4. package/dist/capabilities/index.d.ts +7 -5
  5. package/dist/capabilities/motion.cap.d.ts +2 -0
  6. package/dist/capabilities/notifier.cap.d.ts +3 -3
  7. package/dist/capabilities/pet-feeder.cap.d.ts +4 -4
  8. package/dist/capabilities/pipeline-runner.cap.d.ts +19 -0
  9. package/dist/capabilities/snapshot.cap.d.ts +2 -2
  10. package/dist/capabilities/storage-occupancy.cap.d.ts +72 -0
  11. package/dist/capabilities/storage-provider.cap.d.ts +110 -0
  12. package/dist/capabilities/storage.cap.d.ts +43 -0
  13. package/dist/capabilities/stream-broker.cap.d.ts +1 -1
  14. package/dist/device/device-profile.d.ts +1 -1
  15. package/dist/enums/event-category.d.ts +13 -0
  16. package/dist/enums.js +1 -1
  17. package/dist/enums.mjs +1 -1
  18. package/dist/{event-category-b8kBSOTT.js → event-category-BVo6ta6_.js} +13 -0
  19. package/dist/{event-category-zAv7pMUz.mjs → event-category-CnLqLOKs.mjs} +13 -0
  20. package/dist/generated/addon-api.d.ts +22 -0
  21. package/dist/generated/capability-router-map.d.ts +5 -2
  22. package/dist/generated/collection-array-methods.d.ts +1 -1
  23. package/dist/generated/method-access-map.d.ts +1 -1
  24. package/dist/generated/system-proxy.d.ts +1 -1
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.js +914 -561
  27. package/dist/index.mjs +895 -562
  28. package/dist/interfaces/event-bus.d.ts +23 -2
  29. package/dist/interfaces/storage-location-mode.d.ts +135 -0
  30. package/dist/interfaces/storage-location.d.ts +54 -6
  31. package/dist/interfaces/stream-broker.d.ts +15 -0
  32. package/dist/node.d.ts +13 -11
  33. package/dist/node.js +935 -876
  34. package/dist/node.mjs +936 -879
  35. package/dist/{sleep-DDIFZGbc.js → sleep-BZtO-eFY.js} +1 -1
  36. package/dist/{sleep-Cfij6Jj9.mjs → sleep-Bd-Y4RUt.mjs} +1 -1
  37. package/dist/storage/physical-root.d.ts +25 -0
  38. package/package.json +1 -1
package/dist/node.js CHANGED
@@ -458,448 +458,513 @@ async function installPythonRequirements(pythonPath, requirementsFile, logger) {
458
458
  } });
459
459
  }
460
460
  //#endregion
461
- //#region src/process/child-cost-registry.ts
461
+ //#region src/ffmpeg/fmp4-fragment-child.ts
462
+ var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
463
+ /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
464
+ var FRAGMENT_LOG_EVERY = 30;
465
+ var Fmp4FragmentChild = class {
466
+ deps;
467
+ args;
468
+ child = null;
469
+ splitter = new require_canonical_hash.Fmp4BoxSplitter();
470
+ stopped = false;
471
+ unitsOut = 0;
472
+ activeHwAccel = null;
473
+ constructor(deps, args) {
474
+ this.deps = deps;
475
+ this.args = args;
476
+ }
477
+ /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
478
+ async start() {
479
+ const requested = this.args.invocation.decodeHwAccel;
480
+ this.activeHwAccel = requested;
481
+ try {
482
+ await this.spawnAttempt(requested);
483
+ return;
484
+ } catch (err) {
485
+ if (this.stopped) throw err;
486
+ if (requested === null || require_canonical_hash.isSoftwareDecode(requested)) throw err;
487
+ this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
488
+ tags: { deviceId: this.args.deviceId },
489
+ meta: {
490
+ sourceId: this.args.sourceId,
491
+ decodeHwAccel: requested,
492
+ error: require_err_msg.errMsg(err)
493
+ }
494
+ });
495
+ this.killChild();
496
+ this.splitter = new require_canonical_hash.Fmp4BoxSplitter();
497
+ this.activeHwAccel = null;
498
+ await this.spawnAttempt(null);
499
+ }
500
+ }
501
+ /** The backend the child ACTUALLY ran with — `null` for software. */
502
+ activeDecodeHwAccel() {
503
+ const value = this.activeHwAccel;
504
+ return value === null || value === "none" || value === "copy" ? null : value;
505
+ }
506
+ /** Kill ffmpeg and end the plane. Idempotent. */
507
+ async stop() {
508
+ if (this.stopped) return;
509
+ this.stopped = true;
510
+ this.killChild();
511
+ this.args.plane.end("the fragment child stopped");
512
+ }
513
+ spawnAttempt(decodeHwAccel) {
514
+ const args = require_canonical_hash.buildFfmpegArgs({
515
+ ...this.args.invocation,
516
+ decodeHwAccel,
517
+ sink: {
518
+ kind: "stdout",
519
+ container: "mp4",
520
+ fragmentMs: this.args.fragmentMs
521
+ }
522
+ });
523
+ this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
524
+ tags: { deviceId: this.args.deviceId },
525
+ meta: {
526
+ sourceId: this.args.sourceId,
527
+ fragmentMs: this.args.fragmentMs,
528
+ decodeHwAccel: decodeHwAccel ?? "software",
529
+ argv: args.join(" ")
530
+ }
531
+ });
532
+ return new Promise((resolve, reject) => {
533
+ const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
534
+ "ignore",
535
+ "pipe",
536
+ "pipe"
537
+ ] });
538
+ this.child = child;
539
+ let settled = false;
540
+ /**
541
+ * This attempt FAILED. Set before the kill, because SIGTERM makes the
542
+ * child exit and that exit must not be reported as a death: the retry —
543
+ * or the caller's rejection — already owns what happens next. Without it
544
+ * the timeout path ends the plane the software retry is about to fill,
545
+ * and the consumer sees a stream that stopped for no reason. A "which
546
+ * spawn is current" counter does NOT cover this: the retry has not been
547
+ * spawned when the kill's exit arrives.
548
+ */
549
+ let failed = false;
550
+ /**
551
+ * This attempt is still the live producer: it has not failed (a failure
552
+ * hands ownership to the retry, or to the caller's rejection) and nothing
553
+ * has stopped the child. Those two cover every way an attempt stops being
554
+ * current — `start` only respawns after a rejection.
555
+ */
556
+ const isCurrent = () => !this.stopped && !failed;
557
+ const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
558
+ const settle = (fail) => {
559
+ if (settled) return;
560
+ settled = true;
561
+ clearTimeout(timer);
562
+ if (fail) {
563
+ failed = true;
564
+ reject(fail);
565
+ } else resolve();
566
+ };
567
+ const timer = setTimeout(() => {
568
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
569
+ this.killChild();
570
+ }, timeoutMs);
571
+ timer.unref?.();
572
+ child.stdout?.on("data", (chunk) => {
573
+ for (const unit of this.splitter.push(chunk)) {
574
+ this.unitsOut += 1;
575
+ this.args.plane.publish(unit);
576
+ if (unit.kind === "init") {
577
+ this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
578
+ tags: { deviceId: this.args.deviceId },
579
+ meta: {
580
+ sourceId: this.args.sourceId,
581
+ bytes: unit.data.length
582
+ }
583
+ });
584
+ settle();
585
+ } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
586
+ tags: { deviceId: this.args.deviceId },
587
+ meta: {
588
+ sourceId: this.args.sourceId,
589
+ unitsOut: this.unitsOut,
590
+ bytes: unit.data.length,
591
+ subscribers: this.args.plane.subscriberCount
592
+ }
593
+ });
594
+ }
595
+ const fault = this.splitter.fault;
596
+ if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
597
+ });
598
+ child.stderr?.setEncoding("utf8");
599
+ child.stderr?.on("data", (line) => {
600
+ this.deps.logger.debug("fmp4 fragment child ffmpeg", {
601
+ tags: { deviceId: this.args.deviceId },
602
+ meta: {
603
+ sourceId: this.args.sourceId,
604
+ line: line.trim()
605
+ }
606
+ });
607
+ });
608
+ child.once("error", (err) => {
609
+ if (!settled) {
610
+ settle(err);
611
+ return;
612
+ }
613
+ if (!isCurrent()) return;
614
+ this.args.plane.end("the fragment child errored");
615
+ this.deps.onChildExit?.(err);
616
+ });
617
+ child.once("exit", (code, signal) => {
618
+ if (!settled) {
619
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
620
+ return;
621
+ }
622
+ if (!isCurrent()) return;
623
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
624
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
625
+ tags: { deviceId: this.args.deviceId },
626
+ meta: {
627
+ sourceId: this.args.sourceId,
628
+ code,
629
+ signal,
630
+ unitsOut: this.unitsOut
631
+ }
632
+ });
633
+ this.args.plane.end("the fragment child exited");
634
+ this.deps.onChildExit?.(error);
635
+ });
636
+ });
637
+ }
638
+ /**
639
+ * The byte stream stopped being splittable. Not recoverable — the splitter
640
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
641
+ * has to be told, loudly, with the reason.
642
+ */
643
+ onFault(reason, wasLive, current, settle) {
644
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
645
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
646
+ tags: { deviceId: this.args.deviceId },
647
+ meta: {
648
+ sourceId: this.args.sourceId,
649
+ unitsOut: this.unitsOut,
650
+ interstitial: this.splitter.discardedInterstitialTypes,
651
+ reason
652
+ }
653
+ });
654
+ this.killChild();
655
+ settle(error);
656
+ if (wasLive && current) {
657
+ this.args.plane.end("the fragment child produced unsplittable output");
658
+ this.deps.onChildExit?.(error);
659
+ }
660
+ }
661
+ killChild() {
662
+ const child = this.child;
663
+ this.child = null;
664
+ if (child && !child.killed) try {
665
+ child.kill("SIGTERM");
666
+ } catch (err) {
667
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
668
+ tags: { deviceId: this.args.deviceId },
669
+ meta: {
670
+ sourceId: this.args.sourceId,
671
+ error: require_err_msg.errMsg(err)
672
+ }
673
+ });
674
+ }
675
+ }
676
+ };
677
+ //#endregion
678
+ //#region src/ffmpeg/fmp4-fragment-plane.ts
462
679
  /**
463
- * The registry an addon claims its OWN child processes in — the spawn-site
464
- * half of `load-contribution.cap.ts`.
465
- *
466
- * ## Where a claim is made, and where it is released
467
- *
468
- * At the spawn site, and nowhere else. It is the only place that knows both
469
- * halves: the recorder's controller knows it is starting ffmpeg for camera 615
470
- * profile `high`; the decode coordinator knows the session it is forking a
471
- * worker for. Nothing has to deduce it afterwards, and no entity needs a
472
- * global list of which pid belongs to which camera.
680
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
681
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
473
682
  *
474
- * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
475
- * way to release. That is not decoration: the alternative — `release(pid)` —
476
- * lets a late release from a dead generation delete the live claim of a
477
- * process that inherited the same pid, which is precisely how a per-camera
478
- * chart charges one camera for another camera's work. A handle can only ever
479
- * remove the entry it created.
683
+ * ## Why a plane and not a callback
480
684
  *
481
- * ## What the registry does NOT do
685
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
686
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
687
+ * CamStack as an additional videoclip source alongside the recorder and the NC
688
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
689
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
690
+ * a second ffmpeg child of the same camera. So this is the same shape the
691
+ * broker's other multi-consumer surfaces already have
692
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
693
+ * one producer.
482
694
  *
483
- * It keeps no history, no counters and no timers. It holds live claims and
484
- * answers questions about them; when the process holding it dies, every claim
485
- * in it dies with it, which is correct — those children were its children.
695
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
696
+ * clip source; both are named here so the seam is not re-invented, and neither
697
+ * is built.
486
698
  *
487
- * ## The numbers
699
+ * ## The init segment is RETAINED
488
700
  *
489
- * {@link ChildCostRegistry.contributions} reads each claimed child's
490
- * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
491
- * it is asked. On demand, never on a timer: a new periodic per-node sampler is
492
- * the defect half of `docs/architecture/load-ledger.md` documents. A counter
493
- * can be differenced by whoever already keeps a history; a rate cannot be
494
- * un-averaged.
701
+ * A subscriber that attaches mid-stream — the clip consumer joining an already
702
+ * running HKSV session, which is the whole dual-use case — receives the
703
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
704
+ * retention its fragments are undecodable and the failure looks like a codec
705
+ * problem.
495
706
  *
496
- * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
497
- * are ABSENT — never zero. Zero would say the camera cost nothing.
498
- */
499
- /**
500
- * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
501
- * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
502
- * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
503
- * kernel configuration we ship to. A wrong value would scale every CPU number
504
- * by a constant — visible immediately, not a silent skew.
505
- */
506
- var CLOCK_TICKS_PER_SEC = 100;
507
- /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
508
- var PAGE_BYTES = 4096;
509
- /**
510
- * The handle a spawn site gets when nobody is collecting — a test harness, or
511
- * a runner built before its addon registered a registry. Reporting nothing is
512
- * the correct behaviour: the process still appears in the node's process
513
- * snapshot, unclaimed, which is exactly what "nobody reported this" should
514
- * look like.
515
- */
516
- var NO_COST_CLAIM = { release: () => void 0 };
517
- var nodeProcStatReader = {
518
- readStat: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/stat`, "utf8"),
519
- readStatm: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/statm`, "utf8")
520
- };
521
- /**
522
- * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
707
+ * ## A slow subscriber is CLOSED, never silently gapped
523
708
  *
524
- * The `comm` field is field 2, wrapped in parentheses, and can itself contain
525
- * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
526
- * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
527
- * (field 14) is index 11 and `stime` (field 15) is index 12.
709
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
710
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
711
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
712
+ * shows a clip that fails to play with nothing anywhere saying why. So a
713
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
714
+ * other subscriptions are untouched.
528
715
  *
529
- * `null` for a line that does not parse: a process that exited between the
530
- * claim and the read leaves a truncated or empty file, and that is normal.
531
- */
532
- function parseProcCpuSeconds(line) {
533
- const close = line.lastIndexOf(")");
534
- if (close < 0) return null;
535
- const rest = line.slice(close + 1).trim().split(/\s+/);
536
- const utime = Number(rest[11]);
537
- const stime = Number(rest[12]);
538
- if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
539
- return (utime + stime) / CLOCK_TICKS_PER_SEC;
540
- }
541
- /**
542
- * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
543
- * in pages. `null` when the line does not parse.
544
- */
545
- function parseProcRssBytes(line) {
546
- const fields = line.trim().split(/\s+/);
547
- const residentPages = Number(fields[1]);
548
- if (!Number.isFinite(residentPages)) return null;
549
- return residentPages * PAGE_BYTES;
550
- }
551
- /**
552
- * What the OS will say about one process right now: cumulative CPU seconds and
553
- * resident bytes, each ABSENT when it cannot be read.
716
+ * ## The PREBUFFER (phase 3)
554
717
  *
555
- * Exported because not every cost has a spawn site inside a registry. The
556
- * shared inference pool is the case that forced it: its process is created
557
- * deep inside the engine factory and belongs to no camera, so it is reported
558
- * as an `unattributable` contribution built from the live pool's pids rather
559
- * than from a claim — but its numbers must be read the same way, by the same
560
- * parsers, or two "CPU seconds" in one payload would mean two things.
718
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
719
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
720
+ * motion edge has none. So the plane optionally retains the last few fragments
721
+ * and replays them to a subscriber that asks for them.
722
+ *
723
+ * Three things this ring gets right, each of which is a measured fact rather
724
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
725
+ *
726
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
727
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
728
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
729
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
730
+ * splitter deliberately never computes a fragment's duration (a second
731
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
732
+ * ago the bytes turned up, which is exactly what arrival time answers.
733
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
734
+ * its queue capacity raised by N for them, because closing a subscriber as a
735
+ * slow consumer for the prebuffer it explicitly asked for would be the
736
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
737
+ * ring of 4, the guaranteed one.
738
+ *
739
+ * ## `isLast`
740
+ *
741
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
742
+ * `isLast` — a generator that finishes without it produces the twelve-second
743
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
744
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
745
+ * has ended and nothing remains queued behind it. A subscription that ends
746
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
747
+ * the future delegate must not open an HDS stream it cannot feed.
561
748
  */
562
- async function readProcessCost(pid, reader = nodeProcStatReader) {
563
- let cpuSeconds = null;
564
- let rssBytes = null;
565
- try {
566
- cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
567
- } catch {
568
- cpuSeconds = null;
569
- }
570
- try {
571
- rssBytes = parseProcRssBytes(await reader.readStatm(pid));
572
- } catch {
573
- rssBytes = null;
574
- }
575
- return {
576
- ...cpuSeconds === null ? {} : { cpuSeconds },
577
- ...rssBytes === null ? {} : { rssBytes }
578
- };
579
- }
580
- var ChildCostRegistry = class {
581
- reader;
749
+ var DEFAULT_QUEUE_CAPACITY = 4;
750
+ var Fmp4FragmentPlane = class {
751
+ logger;
752
+ prebuffer;
582
753
  now;
583
- claims = /* @__PURE__ */ new Map();
584
- constructor(reader = nodeProcStatReader, now = Date.now) {
585
- this.reader = reader;
754
+ subscriptions = /* @__PURE__ */ new Map();
755
+ /** The last init unit seen, handed to every later subscriber. */
756
+ retainedInit = null;
757
+ ended = false;
758
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
759
+ ring = [];
760
+ ringBytes = 0;
761
+ constructor(logger, prebuffer, now = Date.now) {
762
+ this.logger = logger;
763
+ this.prebuffer = prebuffer;
586
764
  this.now = now;
587
765
  }
588
- /**
589
- * Record one child. The returned handle is the only way to remove it.
590
- *
591
- * A claim with no pid is dropped rather than stored: it could carry no
592
- * measurement, and an entry with a camera and no numbers reads on a chart as
593
- * a camera that cost nothing.
594
- */
595
- claim(input) {
596
- const pid = input.pid;
597
- if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
598
- const key = Symbol("child-cost-claim");
599
- this.claims.set(key, {
600
- role: input.role,
601
- deviceId: input.deviceId,
602
- unit: input.unit,
603
- attribution: input.attribution ?? "measured",
604
- pid,
605
- startedAtMs: this.now()
606
- });
607
- return { release: () => {
608
- this.claims.delete(key);
609
- } };
766
+ get subscriberCount() {
767
+ return this.subscriptions.size;
610
768
  }
611
- /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
612
- list() {
613
- return [...this.claims.values()].map((c) => ({
614
- role: c.role,
615
- deviceId: c.deviceId,
616
- unit: c.unit,
617
- attribution: c.attribution,
618
- pid: c.pid
619
- }));
769
+ /** True once {@link end} has been called — no further units are accepted. */
770
+ get isEnded() {
771
+ return this.ended;
772
+ }
773
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
774
+ prebufferStats() {
775
+ const oldest = this.ring[0];
776
+ return {
777
+ fragments: this.ring.length,
778
+ bytes: this.ringBytes,
779
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
780
+ };
781
+ }
782
+ subscribe(input) {
783
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
784
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
785
+ const sub = {
786
+ id: `fmp4-${(0, node_crypto.randomUUID)()}`,
787
+ tag: input.tag,
788
+ subscribedAt: this.now(),
789
+ capacity: requested + replay.length,
790
+ queue: [],
791
+ delivered: 0,
792
+ closedReason: null,
793
+ wake: null,
794
+ iterating: false
795
+ };
796
+ this.subscriptions.set(sub.id, sub);
797
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
798
+ for (const retained of replay) this.enqueue(sub, retained.unit);
799
+ if (this.ended) this.closeSubscription(sub, "ended");
800
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
801
+ subscriptionId: sub.id,
802
+ tag: sub.tag,
803
+ hasRetainedInit: this.retainedInit !== null,
804
+ prebufferFragments: replay.length,
805
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
806
+ } });
807
+ return this.facade(sub);
620
808
  }
621
809
  /**
622
- * This addon's contribution: one entry per live claim, with whatever the OS
623
- * will tell us about that child right now.
624
- *
625
- * A child that has exited between the claim and this read contributes an
626
- * entry with no numbers rather than no entry — the unit exists, the addon
627
- * believes it is running, and hiding it would make a dying writer look like
628
- * a writer that was never started.
810
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
811
+ * emits exactly one per child, and a second means the child was respawned, in
812
+ * which case the old one describes a stream that no longer exists.
629
813
  */
630
- async contributions() {
631
- const out = [];
632
- for (const claim of this.claims.values()) out.push({
633
- role: claim.role,
634
- deviceId: claim.deviceId,
635
- attribution: claim.attribution,
636
- unit: claim.unit,
637
- pid: claim.pid,
638
- startedAtMs: claim.startedAtMs,
639
- ...await readProcessCost(claim.pid, this.reader)
640
- });
641
- return out;
814
+ publish(unit) {
815
+ if (this.ended) return;
816
+ if (unit.kind === "init") {
817
+ this.retainedInit = unit;
818
+ this.ring.length = 0;
819
+ this.ringBytes = 0;
820
+ } else this.retain(unit);
821
+ for (const sub of this.subscriptions.values()) {
822
+ if (sub.closedReason !== null) continue;
823
+ this.enqueue(sub, unit);
824
+ }
642
825
  }
643
- };
644
- //#endregion
645
- //#region src/storage/filesystem-storage-provider.ts
646
- var STORAGE_LOCATION_TYPES = [
647
- "data",
648
- "media",
649
- "recordings",
650
- "recordings-high",
651
- "recordings-low",
652
- "recordings-clips",
653
- "event-images",
654
- "models",
655
- "addons-data",
656
- "cache",
657
- "logs",
658
- "backups"
659
- ];
660
- var DEFAULT_LOCATION_SUBDIRS = {
661
- data: "db",
662
- media: "media",
663
- recordings: "recordings",
664
- "recordings-high": "recordings-high",
665
- "recordings-low": "recordings-low",
666
- "recordings-clips": "recordings-clips",
667
- "event-images": "event-images",
668
- models: "models",
669
- "addons-data": "addons-data",
670
- cache: "/tmp/camstack-cache",
671
- logs: "logs",
672
- backups: "backups"
673
- };
674
- /**
675
- * Filesystem storage provider — serves all location types from a local directory tree.
676
- *
677
- * Default layout:
678
- * {rootPath}/recordings-high/
679
- * {rootPath}/recordings-low/
680
- * {rootPath}/recordings-clips/
681
- * {rootPath}/event-images/
682
- * {rootPath}/models/
683
- * {rootPath}/addons-data/
684
- * {rootPath}/logs/
685
- * /tmp/camstack-cache/ (cache is always local)
686
- *
687
- * Individual location paths can be overridden.
688
- */
689
- var FilesystemStorageProvider = class {
690
- id = "local";
691
- name = "Local Filesystem";
692
- supportedLocations = [...STORAGE_LOCATION_TYPES];
693
- rootPath;
694
- locationPaths;
695
- constructor(rootPath, overrides) {
696
- this.rootPath = node_path.resolve(rootPath);
697
- this.locationPaths = /* @__PURE__ */ new Map();
698
- for (const loc of STORAGE_LOCATION_TYPES) {
699
- const override = overrides?.[loc];
700
- if (override) this.locationPaths.set(loc, node_path.resolve(override));
701
- else {
702
- const subdir = DEFAULT_LOCATION_SUBDIRS[loc] ?? loc;
703
- this.locationPaths.set(loc, node_path.isAbsolute(subdir) ? subdir : node_path.join(this.rootPath, subdir));
704
- }
705
- }
706
- for (const [loc, override] of Object.entries(overrides ?? {})) if (typeof override === "string" && !this.locationPaths.has(loc)) this.locationPaths.set(loc, node_path.resolve(override));
826
+ /**
827
+ * The producer stopped. Every subscriber drains what it holds; its final
828
+ * packet carries `isLast`, and its generator then completes.
829
+ */
830
+ end(reason = "producer ended") {
831
+ if (this.ended) return;
832
+ this.ended = true;
833
+ this.logger?.info("fmp4 plane: ended", { meta: {
834
+ reason,
835
+ subscribers: this.subscriptions.size
836
+ } });
837
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
707
838
  }
708
- async resolve({ location, relativePath }) {
709
- const base = this.locationPaths.get(location) ?? node_path.join(this.rootPath, location);
710
- return node_path.join(base, relativePath);
839
+ listSubscribers() {
840
+ return [...this.subscriptions.values()].map((s) => ({
841
+ tag: s.tag,
842
+ subscribedAt: s.subscribedAt,
843
+ delivered: s.delivered,
844
+ closedReason: s.closedReason
845
+ }));
711
846
  }
712
- async write({ location, relativePath, data }) {
713
- const filePath = await this.resolve({
714
- location,
715
- relativePath
847
+ /** End and forget everything. Idempotent. */
848
+ dispose() {
849
+ this.end("disposed");
850
+ this.subscriptions.clear();
851
+ this.retainedInit = null;
852
+ this.ring.length = 0;
853
+ this.ringBytes = 0;
854
+ }
855
+ /**
856
+ * Add one fragment to the ring and evict from the front until BOTH bounds
857
+ * hold. Eviction is oldest-first, which is the one place in this file where
858
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
859
+ * on it, and a subscriber only ever receives a contiguous tail of it.
860
+ */
861
+ retain(unit) {
862
+ const prebuffer = this.prebuffer;
863
+ if (prebuffer === void 0) return;
864
+ const arrivedAt = this.now();
865
+ this.ring.push({
866
+ unit,
867
+ arrivedAt
716
868
  });
717
- await node_fs.promises.mkdir(node_path.dirname(filePath), { recursive: true });
718
- if (Buffer.isBuffer(data)) await node_fs.promises.writeFile(filePath, data);
719
- else {
720
- const writeStream = node_fs.createWriteStream(filePath);
721
- await new Promise((resolve, reject) => {
722
- data.pipe(writeStream);
723
- writeStream.on("finish", resolve);
724
- writeStream.on("error", reject);
725
- });
869
+ this.ringBytes += unit.data.length;
870
+ const cutoff = arrivedAt - prebuffer.windowMs;
871
+ while (this.ring.length > 0) {
872
+ const oldest = this.ring[0];
873
+ if (oldest === void 0) break;
874
+ const tooOld = oldest.arrivedAt < cutoff;
875
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
876
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
877
+ this.ring.shift();
878
+ this.ringBytes -= oldest.unit.data.length;
726
879
  }
727
880
  }
728
- async read({ location, relativePath }) {
729
- return node_fs.promises.readFile(await this.resolve({
730
- location,
731
- relativePath
732
- }));
881
+ /**
882
+ * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
883
+ * time, not only at publish time. A camera that went quiet keeps its last
884
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
885
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
886
+ * at the head of a clip iOS presents as the moment of the event.
887
+ */
888
+ trimmedRing() {
889
+ const prebuffer = this.prebuffer;
890
+ if (prebuffer === void 0) return [];
891
+ const cutoff = this.now() - prebuffer.windowMs;
892
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
733
893
  }
734
- async exists({ location, relativePath }) {
735
- try {
736
- await node_fs.promises.access(await this.resolve({
737
- location,
738
- relativePath
739
- }));
740
- return true;
741
- } catch {
742
- return false;
894
+ enqueue(sub, unit) {
895
+ if (sub.queue.length >= sub.capacity) {
896
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
897
+ subscriptionId: sub.id,
898
+ tag: sub.tag,
899
+ capacity: sub.capacity,
900
+ delivered: sub.delivered
901
+ } });
902
+ this.closeSubscription(sub, "slow-consumer");
903
+ return;
743
904
  }
905
+ sub.queue.push({
906
+ kind: unit.kind,
907
+ data: unit.data,
908
+ sequence: unit.sequence,
909
+ isLast: false
910
+ });
911
+ this.wake(sub);
744
912
  }
745
- async list({ location, prefix }) {
746
- const base = this.locationPaths.get(location);
747
- if (!base) return [];
748
- const dir = prefix ? node_path.join(base, prefix) : base;
749
- try {
750
- return (await node_fs.promises.readdir(dir, { withFileTypes: true })).map((e) => prefix ? `${prefix}/${e.name}` : e.name);
751
- } catch {
752
- return [];
753
- }
913
+ closeSubscription(sub, reason) {
914
+ if (sub.closedReason !== null) return;
915
+ sub.closedReason = reason;
916
+ if (reason === "slow-consumer") sub.queue.length = 0;
917
+ this.wake(sub);
754
918
  }
755
- async delete({ location, relativePath }) {
756
- const filePath = await this.resolve({
757
- location,
758
- relativePath
759
- });
760
- await node_fs.promises.rm(filePath, { force: true });
919
+ wake(sub) {
920
+ const resume = sub.wake;
921
+ sub.wake = null;
922
+ resume?.();
761
923
  }
762
- async getAvailableSpace({ location }) {
763
- const base = this.locationPaths.get(location);
764
- if (!base) return null;
765
- try {
766
- let target = base;
767
- while (!node_fs.existsSync(target)) {
768
- const parent = node_path.dirname(target);
769
- if (!parent || parent === target) return null;
770
- target = parent;
924
+ facade(sub) {
925
+ const plane = this;
926
+ return {
927
+ id: sub.id,
928
+ tag: sub.tag,
929
+ get delivered() {
930
+ return sub.delivered;
931
+ },
932
+ get closedReason() {
933
+ return sub.closedReason;
934
+ },
935
+ packets: () => plane.iterate(sub),
936
+ release: () => {
937
+ plane.closeSubscription(sub, "released");
938
+ plane.subscriptions.delete(sub.id);
771
939
  }
772
- const stats = await node_fs.promises.statfs(target);
773
- return stats.bavail * stats.bsize;
774
- } catch {
775
- return null;
776
- }
777
- }
778
- /** Get the resolved path for a location type (addon-declared ids fall back
779
- * to `<rootPath>/<id>`). */
780
- getLocationPath(location) {
781
- return this.locationPaths.get(location) ?? node_path.join(this.rootPath, location);
940
+ };
782
941
  }
783
- /** Get the root path */
784
- getRootPath() {
785
- return this.rootPath;
942
+ async *iterate(sub) {
943
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
944
+ sub.iterating = true;
945
+ for (;;) {
946
+ const next = sub.queue.shift();
947
+ if (next === void 0) {
948
+ if (sub.closedReason !== null) return;
949
+ await new Promise((resolve) => {
950
+ sub.wake = resolve;
951
+ });
952
+ continue;
953
+ }
954
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
955
+ sub.delivered += 1;
956
+ yield {
957
+ ...next,
958
+ isLast
959
+ };
960
+ if (isLast) return;
961
+ }
786
962
  }
787
963
  };
788
964
  //#endregion
789
- //#region src/utils/expiring-url-signature.ts
965
+ //#region src/ffmpeg/process.ts
790
966
  /**
791
- * The ONE derivation of "a signed, expiring URL".
792
- *
793
- * This repo mints unguessable, self-expiring links in three places now — the
794
- * notification artifact plane, the Home Assistant media plane, and the snapshot
795
- * link plane. The first two grew independently and are byte-identical logic
796
- * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
797
- * each restating the crypto locally because **addons never import each other**.
798
- *
799
- * That reason is real, and the conclusion drawn from it was wrong. Two copies of
800
- * a signing scheme is how one of them quietly ends up with a different TTL, a
801
- * different compare, or a missing expiry check, and nothing fails until a link
802
- * that should have died keeps working. The fix is the same one D52 applies to
803
- * crop geometry: one derivation, in a place every addon may depend on. A
804
- * framework package is exactly that place — `@camstack/types/node`, off the root
805
- * entry because `node:crypto` must never be traversed by a browser bundler.
806
- *
807
- * What this module deliberately does NOT decide: the TTL, the base URL, the
808
- * shape of `id`, and the access level of the route. Those are per-plane policy
809
- * and each caller states them where a reader can see them.
810
- */
811
- /**
812
- * The signature over `(id, expMs)`.
813
- *
814
- * `id` is whatever the plane uses to name the thing being served — an artifact
815
- * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
816
- * must not put a `:` inside a field whose boundary matters; where a plane has
817
- * more than one field, it composes them itself and owns that ambiguity.
818
- */
819
- function signExpiringUrl(secret, id, expMs) {
820
- return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
821
- }
822
- /**
823
- * Verify a request's `(id, exp, sig)`.
824
- *
825
- * **Expiry is checked BEFORE the compare**, so an expired link is refused
826
- * whether or not its signature is valid — a leaked URL stops working on its own
827
- * and cannot be kept alive by holding a correct signature. The compare itself is
828
- * constant-time so a public route cannot be probed for the signature byte by
829
- * byte.
830
- */
831
- function verifyExpiringUrl(input) {
832
- const { secret, id, exp, sig, nowMs } = input;
833
- if (exp === void 0 || sig === void 0) return false;
834
- const expMs = typeof exp === "number" ? exp : Number(exp);
835
- if (!Number.isFinite(expMs)) return false;
836
- if (expMs <= nowMs) return false;
837
- const expected = signExpiringUrl(secret, id, expMs);
838
- const a = Buffer.from(expected, "utf8");
839
- const b = Buffer.from(sig, "utf8");
840
- if (a.length !== b.length) return false;
841
- return (0, node_crypto.timingSafeEqual)(a, b);
842
- }
843
- //#endregion
844
- //#region src/utils/export-reconciler.ts
845
- /**
846
- * Compute the stable 64-char lowercase-hex fingerprint of a device's
847
- * export-relevant shape. Two structurally-equal shapes (any feature order,
848
- * any duplicates, any deviceId) hash identically.
849
- */
850
- function canonicalDeviceFingerprint(shape) {
851
- const features = [...new Set(shape.features)].toSorted();
852
- return require_canonical_hash.canonicalHash({
853
- deviceType: shape.deviceType,
854
- features
855
- });
856
- }
857
- /**
858
- * Pure 3-way diff of desired export state against the persisted
859
- * last-advertised state, keyed by `deviceId`.
860
- *
861
- * - `desired` — one entry per device to advertise this cycle, each carrying
862
- * the freshly-computed `fingerprint`.
863
- * - `lastAdvertised` — the exporter's persisted per-target sync state.
864
- *
865
- * Neither input is mutated.
866
- */
867
- function diffExportTargets(desired, lastAdvertised) {
868
- const toAdd = [];
869
- const toUpdate = [];
870
- const desiredIds = /* @__PURE__ */ new Set();
871
- for (const entry of desired) {
872
- desiredIds.add(entry.deviceId);
873
- const previous = lastAdvertised.get(entry.deviceId);
874
- if (previous === void 0) toAdd.push(entry);
875
- else if (previous.fingerprint !== entry.fingerprint) toUpdate.push(entry);
876
- }
877
- const toRemove = [];
878
- for (const deviceId of lastAdvertised.keys()) if (!desiredIds.has(deviceId)) toRemove.push(deviceId);
879
- return {
880
- toAdd,
881
- toUpdate,
882
- toRemove
883
- };
884
- }
885
- /**
886
- * Resolve the fingerprint an exporter should feed into `diffExportTargets`
887
- * for one device this cycle.
888
- *
889
- * When a device is NOT yet probe-ready, carry forward its last-advertised
890
- * (persisted) fingerprint so it nets to NO CHANGE in `diffExportTargets`
891
- * (neither update nor remove) — never advertise a partial mid-probe shape. A
892
- * brand-new un-probed device with no persisted entry falls back to `fresh` so
893
- * it can advertise once.
894
- */
895
- function resolveExportFingerprint(input) {
896
- if (input.ready) return input.fresh;
897
- return input.persisted ?? input.fresh;
898
- }
899
- //#endregion
900
- //#region src/ffmpeg/process.ts
901
- /**
902
- * `FfmpegProcess` — the ONE spawn/lifecycle wrapper for a live-media ffmpeg.
967
+ * `FfmpegProcess` — the ONE spawn/lifecycle wrapper for a live-media ffmpeg.
903
968
  *
904
969
  * Extracted from `TranscodeEgress.spawnAttempt`, which was already the most
905
970
  * complete of the repo's hand-rolled lifecycles: first-data deadline, hardware
@@ -1162,510 +1227,502 @@ var FfmpegProcess = class {
1162
1227
  }
1163
1228
  };
1164
1229
  //#endregion
1165
- //#region src/ffmpeg/fmp4-fragment-plane.ts
1230
+ //#region src/process/child-cost-registry.ts
1166
1231
  /**
1167
- * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
1168
- * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
1169
- *
1170
- * ## Why a plane and not a callback
1171
- *
1172
- * The operator's requirement for HKSV was explicit: the live fMP4 source built
1173
- * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
1174
- * CamStack as an additional videoclip source alongside the recorder and the NC
1175
- * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
1176
- * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
1177
- * a second ffmpeg child of the same camera. So this is the same shape the
1178
- * broker's other multi-consumer surfaces already have
1179
- * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
1180
- * one producer.
1181
- *
1182
- * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
1183
- * clip source; both are named here so the seam is not re-invented, and neither
1184
- * is built.
1232
+ * The registry an addon claims its OWN child processes in — the spawn-site
1233
+ * half of `load-contribution.cap.ts`.
1185
1234
  *
1186
- * ## The init segment is RETAINED
1235
+ * ## Where a claim is made, and where it is released
1187
1236
  *
1188
- * A subscriber that attaches mid-stream — the clip consumer joining an already
1189
- * running HKSV session, which is the whole dual-use case — receives the
1190
- * retained `ftyp`+`moov` as its first packet and then live fragments. Without
1191
- * retention its fragments are undecodable and the failure looks like a codec
1192
- * problem.
1237
+ * At the spawn site, and nowhere else. It is the only place that knows both
1238
+ * halves: the recorder's controller knows it is starting ffmpeg for camera 615
1239
+ * profile `high`; the decode coordinator knows the session it is forking a
1240
+ * worker for. Nothing has to deduce it afterwards, and no entity needs a
1241
+ * global list of which pid belongs to which camera.
1193
1242
  *
1194
- * ## A slow subscriber is CLOSED, never silently gapped
1243
+ * {@link ChildCostRegistry.claim} returns a HANDLE, and the handle is the only
1244
+ * way to release. That is not decoration: the alternative — `release(pid)` —
1245
+ * lets a late release from a dead generation delete the live claim of a
1246
+ * process that inherited the same pid, which is precisely how a per-camera
1247
+ * chart charges one camera for another camera's work. A handle can only ever
1248
+ * remove the entry it created.
1195
1249
  *
1196
- * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
1197
- * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
1198
- * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
1199
- * shows a clip that fails to play with nothing anywhere saying why. So a
1200
- * subscription whose queue overflows is ENDED with a reason, loudly, and the
1201
- * other subscriptions are untouched.
1250
+ * ## What the registry does NOT do
1202
1251
  *
1203
- * ## The PREBUFFER (phase 3)
1252
+ * It keeps no history, no counters and no timers. It holds live claims and
1253
+ * answers questions about them; when the process holding it dies, every claim
1254
+ * in it dies with it, which is correct — those children were its children.
1204
1255
  *
1205
- * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
1206
- * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
1207
- * motion edge has none. So the plane optionally retains the last few fragments
1208
- * and replays them to a subscriber that asks for them.
1256
+ * ## The numbers
1209
1257
  *
1210
- * Three things this ring gets right, each of which is a measured fact rather
1211
- * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
1258
+ * {@link ChildCostRegistry.contributions} reads each claimed child's
1259
+ * CUMULATIVE CPU seconds and resident bytes out of `/proc/<pid>` at the moment
1260
+ * it is asked. On demand, never on a timer: a new periodic per-node sampler is
1261
+ * the defect half of `docs/architecture/load-ledger.md` documents. A counter
1262
+ * can be differenced by whoever already keeps a history; a rate cannot be
1263
+ * un-averaged.
1212
1264
  *
1213
- * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
1214
- * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
1215
- * window. A time-only bound is a per-camera RAM figure nobody can predict.
1216
- * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
1217
- * splitter deliberately never computes a fragment's duration (a second
1218
- * opinion about a fact the muxer owns), and a prebuffer cares about how long
1219
- * ago the bytes turned up, which is exactly what arrival time answers.
1220
- * - **A replay is not backlog.** A subscriber taking N retained fragments gets
1221
- * its queue capacity raised by N for them, because closing a subscriber as a
1222
- * slow consumer for the prebuffer it explicitly asked for would be the
1223
- * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
1224
- * ring of 4, the guaranteed one.
1265
+ * Where `/proc` does not exist (macOS, Windows) or the read fails, the numbers
1266
+ * are ABSENT — never zero. Zero would say the camera cost nothing.
1267
+ */
1268
+ /**
1269
+ * Kernel jiffies per second (`USER_HZ`). Same constant, same reasoning, as
1270
+ * `packages/system/src/builtins/native-metrics/thread-cpu-sampler.ts`:
1271
+ * `sysconf(_SC_CLK_TCK)` is not exposed to Node and this has been 100 on every
1272
+ * kernel configuration we ship to. A wrong value would scale every CPU number
1273
+ * by a constant — visible immediately, not a silent skew.
1274
+ */
1275
+ var CLOCK_TICKS_PER_SEC = 100;
1276
+ /** Bytes per page, for `/proc/<pid>/statm`'s page counts. */
1277
+ var PAGE_BYTES = 4096;
1278
+ /**
1279
+ * The handle a spawn site gets when nobody is collecting — a test harness, or
1280
+ * a runner built before its addon registered a registry. Reporting nothing is
1281
+ * the correct behaviour: the process still appears in the node's process
1282
+ * snapshot, unclaimed, which is exactly what "nobody reported this" should
1283
+ * look like.
1284
+ */
1285
+ var NO_COST_CLAIM = { release: () => void 0 };
1286
+ var nodeProcStatReader = {
1287
+ readStat: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/stat`, "utf8"),
1288
+ readStatm: (pid) => (0, node_fs_promises.readFile)(`/proc/${pid}/statm`, "utf8")
1289
+ };
1290
+ /**
1291
+ * Cumulative CPU seconds from a `/proc/<pid>/stat` line.
1225
1292
  *
1226
- * ## `isLast`
1293
+ * The `comm` field is field 2, wrapped in parentheses, and can itself contain
1294
+ * spaces and parentheses — so the only correct parse cuts at the LAST `)` and
1295
+ * indexes from there. After the cut, field 3 (`state`) is index 0, so `utime`
1296
+ * (field 14) is index 11 and `stime` (field 15) is index 12.
1227
1297
  *
1228
- * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
1229
- * `isLast` — a generator that finishes without it produces the twelve-second
1230
- * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
1231
- * plane therefore computes it at DELIVERY time: a packet is last when the plane
1232
- * has ended and nothing remains queued behind it. A subscription that ends
1233
- * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
1234
- * the future delegate must not open an HDS stream it cannot feed.
1298
+ * `null` for a line that does not parse: a process that exited between the
1299
+ * claim and the read leaves a truncated or empty file, and that is normal.
1235
1300
  */
1236
- var DEFAULT_QUEUE_CAPACITY = 4;
1237
- var Fmp4FragmentPlane = class {
1238
- logger;
1239
- prebuffer;
1240
- now;
1241
- subscriptions = /* @__PURE__ */ new Map();
1242
- /** The last init unit seen, handed to every later subscriber. */
1243
- retainedInit = null;
1244
- ended = false;
1245
- /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
1246
- ring = [];
1247
- ringBytes = 0;
1248
- constructor(logger, prebuffer, now = Date.now) {
1249
- this.logger = logger;
1250
- this.prebuffer = prebuffer;
1251
- this.now = now;
1252
- }
1253
- get subscriberCount() {
1254
- return this.subscriptions.size;
1255
- }
1256
- /** True once {@link end} has been called — no further units are accepted. */
1257
- get isEnded() {
1258
- return this.ended;
1259
- }
1260
- /** What the prebuffer ring holds right now. All zeroes when disabled. */
1261
- prebufferStats() {
1262
- const oldest = this.ring[0];
1263
- return {
1264
- fragments: this.ring.length,
1265
- bytes: this.ringBytes,
1266
- spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
1267
- };
1301
+ function parseProcCpuSeconds(line) {
1302
+ const close = line.lastIndexOf(")");
1303
+ if (close < 0) return null;
1304
+ const rest = line.slice(close + 1).trim().split(/\s+/);
1305
+ const utime = Number(rest[11]);
1306
+ const stime = Number(rest[12]);
1307
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
1308
+ return (utime + stime) / CLOCK_TICKS_PER_SEC;
1309
+ }
1310
+ /**
1311
+ * Resident bytes from a `/proc/<pid>/statm` line — field 2 is the resident set
1312
+ * in pages. `null` when the line does not parse.
1313
+ */
1314
+ function parseProcRssBytes(line) {
1315
+ const fields = line.trim().split(/\s+/);
1316
+ const residentPages = Number(fields[1]);
1317
+ if (!Number.isFinite(residentPages)) return null;
1318
+ return residentPages * PAGE_BYTES;
1319
+ }
1320
+ /**
1321
+ * What the OS will say about one process right now: cumulative CPU seconds and
1322
+ * resident bytes, each ABSENT when it cannot be read.
1323
+ *
1324
+ * Exported because not every cost has a spawn site inside a registry. The
1325
+ * shared inference pool is the case that forced it: its process is created
1326
+ * deep inside the engine factory and belongs to no camera, so it is reported
1327
+ * as an `unattributable` contribution built from the live pool's pids rather
1328
+ * than from a claim — but its numbers must be read the same way, by the same
1329
+ * parsers, or two "CPU seconds" in one payload would mean two things.
1330
+ */
1331
+ async function readProcessCost(pid, reader = nodeProcStatReader) {
1332
+ let cpuSeconds = null;
1333
+ let rssBytes = null;
1334
+ try {
1335
+ cpuSeconds = parseProcCpuSeconds(await reader.readStat(pid));
1336
+ } catch {
1337
+ cpuSeconds = null;
1268
1338
  }
1269
- subscribe(input) {
1270
- const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
1271
- const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
1272
- const sub = {
1273
- id: `fmp4-${(0, node_crypto.randomUUID)()}`,
1274
- tag: input.tag,
1275
- subscribedAt: this.now(),
1276
- capacity: requested + replay.length,
1277
- queue: [],
1278
- delivered: 0,
1279
- closedReason: null,
1280
- wake: null,
1281
- iterating: false
1282
- };
1283
- this.subscriptions.set(sub.id, sub);
1284
- if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
1285
- for (const retained of replay) this.enqueue(sub, retained.unit);
1286
- if (this.ended) this.closeSubscription(sub, "ended");
1287
- this.logger?.info("fmp4 plane: subscribed", { meta: {
1288
- subscriptionId: sub.id,
1289
- tag: sub.tag,
1290
- hasRetainedInit: this.retainedInit !== null,
1291
- prebufferFragments: replay.length,
1292
- prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
1293
- } });
1294
- return this.facade(sub);
1339
+ try {
1340
+ rssBytes = parseProcRssBytes(await reader.readStatm(pid));
1341
+ } catch {
1342
+ rssBytes = null;
1295
1343
  }
1296
- /**
1297
- * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
1298
- * emits exactly one per child, and a second means the child was respawned, in
1299
- * which case the old one describes a stream that no longer exists.
1300
- */
1301
- publish(unit) {
1302
- if (this.ended) return;
1303
- if (unit.kind === "init") {
1304
- this.retainedInit = unit;
1305
- this.ring.length = 0;
1306
- this.ringBytes = 0;
1307
- } else this.retain(unit);
1308
- for (const sub of this.subscriptions.values()) {
1309
- if (sub.closedReason !== null) continue;
1310
- this.enqueue(sub, unit);
1311
- }
1344
+ return {
1345
+ ...cpuSeconds === null ? {} : { cpuSeconds },
1346
+ ...rssBytes === null ? {} : { rssBytes }
1347
+ };
1348
+ }
1349
+ var ChildCostRegistry = class {
1350
+ reader;
1351
+ now;
1352
+ claims = /* @__PURE__ */ new Map();
1353
+ constructor(reader = nodeProcStatReader, now = Date.now) {
1354
+ this.reader = reader;
1355
+ this.now = now;
1312
1356
  }
1313
1357
  /**
1314
- * The producer stopped. Every subscriber drains what it holds; its final
1315
- * packet carries `isLast`, and its generator then completes.
1358
+ * Record one child. The returned handle is the only way to remove it.
1359
+ *
1360
+ * A claim with no pid is dropped rather than stored: it could carry no
1361
+ * measurement, and an entry with a camera and no numbers reads on a chart as
1362
+ * a camera that cost nothing.
1316
1363
  */
1317
- end(reason = "producer ended") {
1318
- if (this.ended) return;
1319
- this.ended = true;
1320
- this.logger?.info("fmp4 plane: ended", { meta: {
1321
- reason,
1322
- subscribers: this.subscriptions.size
1323
- } });
1324
- for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
1364
+ claim(input) {
1365
+ const pid = input.pid;
1366
+ if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return NO_COST_CLAIM;
1367
+ const key = Symbol("child-cost-claim");
1368
+ this.claims.set(key, {
1369
+ role: input.role,
1370
+ deviceId: input.deviceId,
1371
+ unit: input.unit,
1372
+ attribution: input.attribution ?? "measured",
1373
+ pid,
1374
+ startedAtMs: this.now()
1375
+ });
1376
+ return { release: () => {
1377
+ this.claims.delete(key);
1378
+ } };
1325
1379
  }
1326
- listSubscribers() {
1327
- return [...this.subscriptions.values()].map((s) => ({
1328
- tag: s.tag,
1329
- subscribedAt: s.subscribedAt,
1330
- delivered: s.delivered,
1331
- closedReason: s.closedReason
1380
+ /** Live claims, in claim order. Diagnostics and tests; not a contribution. */
1381
+ list() {
1382
+ return [...this.claims.values()].map((c) => ({
1383
+ role: c.role,
1384
+ deviceId: c.deviceId,
1385
+ unit: c.unit,
1386
+ attribution: c.attribution,
1387
+ pid: c.pid
1332
1388
  }));
1333
1389
  }
1334
- /** End and forget everything. Idempotent. */
1335
- dispose() {
1336
- this.end("disposed");
1337
- this.subscriptions.clear();
1338
- this.retainedInit = null;
1339
- this.ring.length = 0;
1340
- this.ringBytes = 0;
1341
- }
1342
- /**
1343
- * Add one fragment to the ring and evict from the front until BOTH bounds
1344
- * hold. Eviction is oldest-first, which is the one place in this file where
1345
- * dropping is correct: the ring is context, not stream — nobody is mid-decode
1346
- * on it, and a subscriber only ever receives a contiguous tail of it.
1347
- */
1348
- retain(unit) {
1349
- const prebuffer = this.prebuffer;
1350
- if (prebuffer === void 0) return;
1351
- const arrivedAt = this.now();
1352
- this.ring.push({
1353
- unit,
1354
- arrivedAt
1355
- });
1356
- this.ringBytes += unit.data.length;
1357
- const cutoff = arrivedAt - prebuffer.windowMs;
1358
- while (this.ring.length > 0) {
1359
- const oldest = this.ring[0];
1360
- if (oldest === void 0) break;
1361
- const tooOld = oldest.arrivedAt < cutoff;
1362
- const tooBig = this.ringBytes > prebuffer.maxBytes;
1363
- if (!tooOld && !tooBig || this.ring.length === 1) break;
1364
- this.ring.shift();
1365
- this.ringBytes -= oldest.unit.data.length;
1366
- }
1367
- }
1368
1390
  /**
1369
- * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
1370
- * time, not only at publish time. A camera that went quiet keeps its last
1371
- * fragment in the ring indefinitely (see the never-evict-the-newest rule),
1372
- * and replaying a 40-second-old fragment as "prebuffer" would put stale video
1373
- * at the head of a clip iOS presents as the moment of the event.
1391
+ * This addon's contribution: one entry per live claim, with whatever the OS
1392
+ * will tell us about that child right now.
1393
+ *
1394
+ * A child that has exited between the claim and this read contributes an
1395
+ * entry with no numbers rather than no entry — the unit exists, the addon
1396
+ * believes it is running, and hiding it would make a dying writer look like
1397
+ * a writer that was never started.
1374
1398
  */
1375
- trimmedRing() {
1376
- const prebuffer = this.prebuffer;
1377
- if (prebuffer === void 0) return [];
1378
- const cutoff = this.now() - prebuffer.windowMs;
1379
- return this.ring.filter((r) => r.arrivedAt >= cutoff);
1380
- }
1381
- enqueue(sub, unit) {
1382
- if (sub.queue.length >= sub.capacity) {
1383
- this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
1384
- subscriptionId: sub.id,
1385
- tag: sub.tag,
1386
- capacity: sub.capacity,
1387
- delivered: sub.delivered
1388
- } });
1389
- this.closeSubscription(sub, "slow-consumer");
1390
- return;
1391
- }
1392
- sub.queue.push({
1393
- kind: unit.kind,
1394
- data: unit.data,
1395
- sequence: unit.sequence,
1396
- isLast: false
1399
+ async contributions() {
1400
+ const out = [];
1401
+ for (const claim of this.claims.values()) out.push({
1402
+ role: claim.role,
1403
+ deviceId: claim.deviceId,
1404
+ attribution: claim.attribution,
1405
+ unit: claim.unit,
1406
+ pid: claim.pid,
1407
+ startedAtMs: claim.startedAtMs,
1408
+ ...await readProcessCost(claim.pid, this.reader)
1397
1409
  });
1398
- this.wake(sub);
1399
- }
1400
- closeSubscription(sub, reason) {
1401
- if (sub.closedReason !== null) return;
1402
- sub.closedReason = reason;
1403
- if (reason === "slow-consumer") sub.queue.length = 0;
1404
- this.wake(sub);
1405
- }
1406
- wake(sub) {
1407
- const resume = sub.wake;
1408
- sub.wake = null;
1409
- resume?.();
1410
- }
1411
- facade(sub) {
1412
- const plane = this;
1413
- return {
1414
- id: sub.id,
1415
- tag: sub.tag,
1416
- get delivered() {
1417
- return sub.delivered;
1418
- },
1419
- get closedReason() {
1420
- return sub.closedReason;
1421
- },
1422
- packets: () => plane.iterate(sub),
1423
- release: () => {
1424
- plane.closeSubscription(sub, "released");
1425
- plane.subscriptions.delete(sub.id);
1426
- }
1427
- };
1428
- }
1429
- async *iterate(sub) {
1430
- if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
1431
- sub.iterating = true;
1432
- for (;;) {
1433
- const next = sub.queue.shift();
1434
- if (next === void 0) {
1435
- if (sub.closedReason !== null) return;
1436
- await new Promise((resolve) => {
1437
- sub.wake = resolve;
1438
- });
1439
- continue;
1440
- }
1441
- const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
1442
- sub.delivered += 1;
1443
- yield {
1444
- ...next,
1445
- isLast
1446
- };
1447
- if (isLast) return;
1448
- }
1410
+ return out;
1449
1411
  }
1450
1412
  };
1451
1413
  //#endregion
1452
- //#region src/ffmpeg/fmp4-fragment-child.ts
1453
- var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
1454
- /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
1455
- var FRAGMENT_LOG_EVERY = 30;
1456
- var Fmp4FragmentChild = class {
1457
- deps;
1458
- args;
1459
- child = null;
1460
- splitter = new require_canonical_hash.Fmp4BoxSplitter();
1461
- stopped = false;
1462
- unitsOut = 0;
1463
- activeHwAccel = null;
1464
- constructor(deps, args) {
1465
- this.deps = deps;
1466
- this.args = args;
1467
- }
1468
- /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
1469
- async start() {
1470
- const requested = this.args.invocation.decodeHwAccel;
1471
- this.activeHwAccel = requested;
1472
- try {
1473
- await this.spawnAttempt(requested);
1474
- return;
1475
- } catch (err) {
1476
- if (this.stopped) throw err;
1477
- if (requested === null || require_canonical_hash.isSoftwareDecode(requested)) throw err;
1478
- this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
1479
- tags: { deviceId: this.args.deviceId },
1480
- meta: {
1481
- sourceId: this.args.sourceId,
1482
- decodeHwAccel: requested,
1483
- error: require_err_msg.errMsg(err)
1484
- }
1485
- });
1486
- this.killChild();
1487
- this.splitter = new require_canonical_hash.Fmp4BoxSplitter();
1488
- this.activeHwAccel = null;
1489
- await this.spawnAttempt(null);
1490
- }
1491
- }
1492
- /** The backend the child ACTUALLY ran with — `null` for software. */
1493
- activeDecodeHwAccel() {
1494
- const value = this.activeHwAccel;
1495
- return value === null || value === "none" || value === "copy" ? null : value;
1496
- }
1497
- /** Kill ffmpeg and end the plane. Idempotent. */
1498
- async stop() {
1499
- if (this.stopped) return;
1500
- this.stopped = true;
1501
- this.killChild();
1502
- this.args.plane.end("the fragment child stopped");
1503
- }
1504
- spawnAttempt(decodeHwAccel) {
1505
- const args = require_canonical_hash.buildFfmpegArgs({
1506
- ...this.args.invocation,
1507
- decodeHwAccel,
1508
- sink: {
1509
- kind: "stdout",
1510
- container: "mp4",
1511
- fragmentMs: this.args.fragmentMs
1512
- }
1513
- });
1514
- this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
1515
- tags: { deviceId: this.args.deviceId },
1516
- meta: {
1517
- sourceId: this.args.sourceId,
1518
- fragmentMs: this.args.fragmentMs,
1519
- decodeHwAccel: decodeHwAccel ?? "software",
1520
- argv: args.join(" ")
1521
- }
1522
- });
1523
- return new Promise((resolve, reject) => {
1524
- const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
1525
- "ignore",
1526
- "pipe",
1527
- "pipe"
1528
- ] });
1529
- this.child = child;
1530
- let settled = false;
1531
- /**
1532
- * This attempt FAILED. Set before the kill, because SIGTERM makes the
1533
- * child exit and that exit must not be reported as a death: the retry —
1534
- * or the caller's rejection — already owns what happens next. Without it
1535
- * the timeout path ends the plane the software retry is about to fill,
1536
- * and the consumer sees a stream that stopped for no reason. A "which
1537
- * spawn is current" counter does NOT cover this: the retry has not been
1538
- * spawned when the kill's exit arrives.
1539
- */
1540
- let failed = false;
1541
- /**
1542
- * This attempt is still the live producer: it has not failed (a failure
1543
- * hands ownership to the retry, or to the caller's rejection) and nothing
1544
- * has stopped the child. Those two cover every way an attempt stops being
1545
- * current — `start` only respawns after a rejection.
1546
- */
1547
- const isCurrent = () => !this.stopped && !failed;
1548
- const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
1549
- const settle = (fail) => {
1550
- if (settled) return;
1551
- settled = true;
1552
- clearTimeout(timer);
1553
- if (fail) {
1554
- failed = true;
1555
- reject(fail);
1556
- } else resolve();
1557
- };
1558
- const timer = setTimeout(() => {
1559
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
1560
- this.killChild();
1561
- }, timeoutMs);
1562
- timer.unref?.();
1563
- child.stdout?.on("data", (chunk) => {
1564
- for (const unit of this.splitter.push(chunk)) {
1565
- this.unitsOut += 1;
1566
- this.args.plane.publish(unit);
1567
- if (unit.kind === "init") {
1568
- this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
1569
- tags: { deviceId: this.args.deviceId },
1570
- meta: {
1571
- sourceId: this.args.sourceId,
1572
- bytes: unit.data.length
1573
- }
1574
- });
1575
- settle();
1576
- } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
1577
- tags: { deviceId: this.args.deviceId },
1578
- meta: {
1579
- sourceId: this.args.sourceId,
1580
- unitsOut: this.unitsOut,
1581
- bytes: unit.data.length,
1582
- subscribers: this.args.plane.subscriberCount
1583
- }
1584
- });
1585
- }
1586
- const fault = this.splitter.fault;
1587
- if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
1588
- });
1589
- child.stderr?.setEncoding("utf8");
1590
- child.stderr?.on("data", (line) => {
1591
- this.deps.logger.debug("fmp4 fragment child ffmpeg", {
1592
- tags: { deviceId: this.args.deviceId },
1593
- meta: {
1594
- sourceId: this.args.sourceId,
1595
- line: line.trim()
1596
- }
1597
- });
1598
- });
1599
- child.once("error", (err) => {
1600
- if (!settled) {
1601
- settle(err);
1602
- return;
1603
- }
1604
- if (!isCurrent()) return;
1605
- this.args.plane.end("the fragment child errored");
1606
- this.deps.onChildExit?.(err);
1607
- });
1608
- child.once("exit", (code, signal) => {
1609
- if (!settled) {
1610
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
1611
- return;
1612
- }
1613
- if (!isCurrent()) return;
1614
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
1615
- this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
1616
- tags: { deviceId: this.args.deviceId },
1617
- meta: {
1618
- sourceId: this.args.sourceId,
1619
- code,
1620
- signal,
1621
- unitsOut: this.unitsOut
1622
- }
1623
- });
1624
- this.args.plane.end("the fragment child exited");
1625
- this.deps.onChildExit?.(error);
1414
+ //#region src/storage/filesystem-storage-provider.ts
1415
+ var STORAGE_LOCATION_TYPES = [
1416
+ "data",
1417
+ "media",
1418
+ "recordings",
1419
+ "recordings-high",
1420
+ "recordings-low",
1421
+ "recordings-clips",
1422
+ "event-images",
1423
+ "models",
1424
+ "addons-data",
1425
+ "cache",
1426
+ "logs",
1427
+ "backups"
1428
+ ];
1429
+ var DEFAULT_LOCATION_SUBDIRS = {
1430
+ data: "db",
1431
+ media: "media",
1432
+ recordings: "recordings",
1433
+ "recordings-high": "recordings-high",
1434
+ "recordings-low": "recordings-low",
1435
+ "recordings-clips": "recordings-clips",
1436
+ "event-images": "event-images",
1437
+ models: "models",
1438
+ "addons-data": "addons-data",
1439
+ cache: "/tmp/camstack-cache",
1440
+ logs: "logs",
1441
+ backups: "backups"
1442
+ };
1443
+ /**
1444
+ * Filesystem storage provider — serves all location types from a local directory tree.
1445
+ *
1446
+ * Default layout:
1447
+ * {rootPath}/recordings-high/
1448
+ * {rootPath}/recordings-low/
1449
+ * {rootPath}/recordings-clips/
1450
+ * {rootPath}/event-images/
1451
+ * {rootPath}/models/
1452
+ * {rootPath}/addons-data/
1453
+ * {rootPath}/logs/
1454
+ * /tmp/camstack-cache/ (cache is always local)
1455
+ *
1456
+ * Individual location paths can be overridden.
1457
+ */
1458
+ var FilesystemStorageProvider = class {
1459
+ id = "local";
1460
+ name = "Local Filesystem";
1461
+ supportedLocations = [...STORAGE_LOCATION_TYPES];
1462
+ rootPath;
1463
+ locationPaths;
1464
+ constructor(rootPath, overrides) {
1465
+ this.rootPath = node_path.resolve(rootPath);
1466
+ this.locationPaths = /* @__PURE__ */ new Map();
1467
+ for (const loc of STORAGE_LOCATION_TYPES) {
1468
+ const override = overrides?.[loc];
1469
+ if (override) this.locationPaths.set(loc, node_path.resolve(override));
1470
+ else {
1471
+ const subdir = DEFAULT_LOCATION_SUBDIRS[loc] ?? loc;
1472
+ this.locationPaths.set(loc, node_path.isAbsolute(subdir) ? subdir : node_path.join(this.rootPath, subdir));
1473
+ }
1474
+ }
1475
+ for (const [loc, override] of Object.entries(overrides ?? {})) if (typeof override === "string" && !this.locationPaths.has(loc)) this.locationPaths.set(loc, node_path.resolve(override));
1476
+ }
1477
+ async resolve({ location, relativePath }) {
1478
+ const base = this.locationPaths.get(location) ?? node_path.join(this.rootPath, location);
1479
+ return node_path.join(base, relativePath);
1480
+ }
1481
+ async write({ location, relativePath, data }) {
1482
+ const filePath = await this.resolve({
1483
+ location,
1484
+ relativePath
1485
+ });
1486
+ await node_fs.promises.mkdir(node_path.dirname(filePath), { recursive: true });
1487
+ if (Buffer.isBuffer(data)) await node_fs.promises.writeFile(filePath, data);
1488
+ else {
1489
+ const writeStream = node_fs.createWriteStream(filePath);
1490
+ await new Promise((resolve, reject) => {
1491
+ data.pipe(writeStream);
1492
+ writeStream.on("finish", resolve);
1493
+ writeStream.on("error", reject);
1626
1494
  });
1495
+ }
1496
+ }
1497
+ async read({ location, relativePath }) {
1498
+ return node_fs.promises.readFile(await this.resolve({
1499
+ location,
1500
+ relativePath
1501
+ }));
1502
+ }
1503
+ async exists({ location, relativePath }) {
1504
+ try {
1505
+ await node_fs.promises.access(await this.resolve({
1506
+ location,
1507
+ relativePath
1508
+ }));
1509
+ return true;
1510
+ } catch {
1511
+ return false;
1512
+ }
1513
+ }
1514
+ async list({ location, prefix }) {
1515
+ const base = this.locationPaths.get(location);
1516
+ if (!base) return [];
1517
+ const dir = prefix ? node_path.join(base, prefix) : base;
1518
+ try {
1519
+ return (await node_fs.promises.readdir(dir, { withFileTypes: true })).map((e) => prefix ? `${prefix}/${e.name}` : e.name);
1520
+ } catch {
1521
+ return [];
1522
+ }
1523
+ }
1524
+ async delete({ location, relativePath }) {
1525
+ const filePath = await this.resolve({
1526
+ location,
1527
+ relativePath
1627
1528
  });
1529
+ await node_fs.promises.rm(filePath, { force: true });
1628
1530
  }
1629
- /**
1630
- * The byte stream stopped being splittable. Not recoverable — the splitter
1631
- * cannot resynchronise mid-box — so the child is a corpse and every consumer
1632
- * has to be told, loudly, with the reason.
1633
- */
1634
- onFault(reason, wasLive, current, settle) {
1635
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
1636
- this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
1637
- tags: { deviceId: this.args.deviceId },
1638
- meta: {
1639
- sourceId: this.args.sourceId,
1640
- unitsOut: this.unitsOut,
1641
- interstitial: this.splitter.discardedInterstitialTypes,
1642
- reason
1531
+ async getAvailableSpace({ location }) {
1532
+ const base = this.locationPaths.get(location);
1533
+ if (!base) return null;
1534
+ try {
1535
+ let target = base;
1536
+ while (!node_fs.existsSync(target)) {
1537
+ const parent = node_path.dirname(target);
1538
+ if (!parent || parent === target) return null;
1539
+ target = parent;
1643
1540
  }
1644
- });
1645
- this.killChild();
1646
- settle(error);
1647
- if (wasLive && current) {
1648
- this.args.plane.end("the fragment child produced unsplittable output");
1649
- this.deps.onChildExit?.(error);
1541
+ const stats = await node_fs.promises.statfs(target);
1542
+ return stats.bavail * stats.bsize;
1543
+ } catch {
1544
+ return null;
1650
1545
  }
1651
1546
  }
1652
- killChild() {
1653
- const child = this.child;
1654
- this.child = null;
1655
- if (child && !child.killed) try {
1656
- child.kill("SIGTERM");
1657
- } catch (err) {
1658
- this.deps.logger.warn("fmp4 fragment child: kill error", {
1659
- tags: { deviceId: this.args.deviceId },
1660
- meta: {
1661
- sourceId: this.args.sourceId,
1662
- error: require_err_msg.errMsg(err)
1663
- }
1664
- });
1665
- }
1547
+ /** Get the resolved path for a location type (addon-declared ids fall back
1548
+ * to `<rootPath>/<id>`). */
1549
+ getLocationPath(location) {
1550
+ return this.locationPaths.get(location) ?? node_path.join(this.rootPath, location);
1551
+ }
1552
+ /** Get the root path */
1553
+ getRootPath() {
1554
+ return this.rootPath;
1666
1555
  }
1667
1556
  };
1668
1557
  //#endregion
1558
+ //#region src/storage/physical-root.ts
1559
+ /**
1560
+ * The PHYSICAL root behind a storage-location path — ONE derivation, shared.
1561
+ *
1562
+ * A symlink or a bind mount reaches one disk under two names, and a string
1563
+ * comparison misses exactly the case that matters: two locations that look
1564
+ * distinct in the config and are the same directory. Every consumer that has to
1565
+ * answer "are these the same disk" resolves it here.
1566
+ *
1567
+ * Two consumers today, and they need different things from the SAME derivation,
1568
+ * which is why the resolution failure is reported rather than swallowed:
1569
+ *
1570
+ * - the recorder's eviction domain merges same-root aliases into one
1571
+ * oldest-first pool. It wants a key either way — an unresolvable root falls
1572
+ * back to the lexically-resolved path, which can only ever OVER-partition
1573
+ * (treat one disk as two) and never merge two different disks. That is the
1574
+ * safe failure mode for a data-loss guard.
1575
+ * - the orchestrator's collision refusal (D389) must NOT refuse on a root it
1576
+ * could not resolve: a location on another node, or behind a remote
1577
+ * provider, is unresolvable from here, and refusing on an unanswerable read
1578
+ * blocks the operator on a doubt (D49's spirit). It needs to know.
1579
+ *
1580
+ * Node-only (`node:fs`), so it lives off the root entry and is re-exported from
1581
+ * `@camstack/types/node`.
1582
+ */
1583
+ /** Resolve a location root to its physical key. Never throws. */
1584
+ function physicalRootOf(root, realpath = node_fs.realpathSync) {
1585
+ const resolved = node_path.default.resolve(root);
1586
+ try {
1587
+ return {
1588
+ key: realpath(resolved),
1589
+ resolved: true
1590
+ };
1591
+ } catch {
1592
+ return {
1593
+ key: resolved,
1594
+ resolved: false
1595
+ };
1596
+ }
1597
+ }
1598
+ /**
1599
+ * Is `inner` the same directory as `outer`, or nested inside it?
1600
+ *
1601
+ * Containment matters as much as equality: a location inside another's tree is
1602
+ * WORSE than two equal roots, because the occupancy accounting silently
1603
+ * overlaps and a deletion under the outer one can take the inner one's footage
1604
+ * with it.
1605
+ *
1606
+ * Compared on path SEGMENTS, never as a string prefix — `/mnt/disk1` is not
1607
+ * inside `/mnt/disk` even though the string says so.
1608
+ */
1609
+ function containsOrEquals(outer, inner) {
1610
+ if (outer === inner) return true;
1611
+ const relative = node_path.default.relative(outer, inner);
1612
+ return relative !== "" && !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
1613
+ }
1614
+ //#endregion
1615
+ //#region src/utils/expiring-url-signature.ts
1616
+ /**
1617
+ * The ONE derivation of "a signed, expiring URL".
1618
+ *
1619
+ * This repo mints unguessable, self-expiring links in three places now — the
1620
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
1621
+ * link plane. The first two grew independently and are byte-identical logic
1622
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
1623
+ * each restating the crypto locally because **addons never import each other**.
1624
+ *
1625
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
1626
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
1627
+ * different compare, or a missing expiry check, and nothing fails until a link
1628
+ * that should have died keeps working. The fix is the same one D52 applies to
1629
+ * crop geometry: one derivation, in a place every addon may depend on. A
1630
+ * framework package is exactly that place — `@camstack/types/node`, off the root
1631
+ * entry because `node:crypto` must never be traversed by a browser bundler.
1632
+ *
1633
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
1634
+ * shape of `id`, and the access level of the route. Those are per-plane policy
1635
+ * and each caller states them where a reader can see them.
1636
+ */
1637
+ /**
1638
+ * The signature over `(id, expMs)`.
1639
+ *
1640
+ * `id` is whatever the plane uses to name the thing being served — an artifact
1641
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
1642
+ * must not put a `:` inside a field whose boundary matters; where a plane has
1643
+ * more than one field, it composes them itself and owns that ambiguity.
1644
+ */
1645
+ function signExpiringUrl(secret, id, expMs) {
1646
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
1647
+ }
1648
+ /**
1649
+ * Verify a request's `(id, exp, sig)`.
1650
+ *
1651
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
1652
+ * whether or not its signature is valid — a leaked URL stops working on its own
1653
+ * and cannot be kept alive by holding a correct signature. The compare itself is
1654
+ * constant-time so a public route cannot be probed for the signature byte by
1655
+ * byte.
1656
+ */
1657
+ function verifyExpiringUrl(input) {
1658
+ const { secret, id, exp, sig, nowMs } = input;
1659
+ if (exp === void 0 || sig === void 0) return false;
1660
+ const expMs = typeof exp === "number" ? exp : Number(exp);
1661
+ if (!Number.isFinite(expMs)) return false;
1662
+ if (expMs <= nowMs) return false;
1663
+ const expected = signExpiringUrl(secret, id, expMs);
1664
+ const a = Buffer.from(expected, "utf8");
1665
+ const b = Buffer.from(sig, "utf8");
1666
+ if (a.length !== b.length) return false;
1667
+ return (0, node_crypto.timingSafeEqual)(a, b);
1668
+ }
1669
+ //#endregion
1670
+ //#region src/utils/export-reconciler.ts
1671
+ /**
1672
+ * Compute the stable 64-char lowercase-hex fingerprint of a device's
1673
+ * export-relevant shape. Two structurally-equal shapes (any feature order,
1674
+ * any duplicates, any deviceId) hash identically.
1675
+ */
1676
+ function canonicalDeviceFingerprint(shape) {
1677
+ const features = [...new Set(shape.features)].toSorted();
1678
+ return require_canonical_hash.canonicalHash({
1679
+ deviceType: shape.deviceType,
1680
+ features
1681
+ });
1682
+ }
1683
+ /**
1684
+ * Pure 3-way diff of desired export state against the persisted
1685
+ * last-advertised state, keyed by `deviceId`.
1686
+ *
1687
+ * - `desired` — one entry per device to advertise this cycle, each carrying
1688
+ * the freshly-computed `fingerprint`.
1689
+ * - `lastAdvertised` — the exporter's persisted per-target sync state.
1690
+ *
1691
+ * Neither input is mutated.
1692
+ */
1693
+ function diffExportTargets(desired, lastAdvertised) {
1694
+ const toAdd = [];
1695
+ const toUpdate = [];
1696
+ const desiredIds = /* @__PURE__ */ new Set();
1697
+ for (const entry of desired) {
1698
+ desiredIds.add(entry.deviceId);
1699
+ const previous = lastAdvertised.get(entry.deviceId);
1700
+ if (previous === void 0) toAdd.push(entry);
1701
+ else if (previous.fingerprint !== entry.fingerprint) toUpdate.push(entry);
1702
+ }
1703
+ const toRemove = [];
1704
+ for (const deviceId of lastAdvertised.keys()) if (!desiredIds.has(deviceId)) toRemove.push(deviceId);
1705
+ return {
1706
+ toAdd,
1707
+ toUpdate,
1708
+ toRemove
1709
+ };
1710
+ }
1711
+ /**
1712
+ * Resolve the fingerprint an exporter should feed into `diffExportTargets`
1713
+ * for one device this cycle.
1714
+ *
1715
+ * When a device is NOT yet probe-ready, carry forward its last-advertised
1716
+ * (persisted) fingerprint so it nets to NO CHANGE in `diffExportTargets`
1717
+ * (neither update nor remove) — never advertise a partial mid-probe shape. A
1718
+ * brand-new un-probed device with no persisted entry falls back to `fresh` so
1719
+ * it can advertise once.
1720
+ */
1721
+ function resolveExportFingerprint(input) {
1722
+ if (input.ready) return input.fresh;
1723
+ return input.persisted ?? input.fresh;
1724
+ }
1725
+ //#endregion
1669
1726
  exports.ChildCostRegistry = ChildCostRegistry;
1670
1727
  exports.FfmpegProcess = FfmpegProcess;
1671
1728
  exports.FilesystemStorageProvider = FilesystemStorageProvider;
@@ -1676,6 +1733,7 @@ exports.PYTHON_VERSION = PYTHON_VERSION;
1676
1733
  exports.buildBinaryPath = buildBinaryPath;
1677
1734
  exports.canonicalDeviceFingerprint = canonicalDeviceFingerprint;
1678
1735
  exports.canonicalHash = require_canonical_hash.canonicalHash;
1736
+ exports.containsOrEquals = containsOrEquals;
1679
1737
  exports.diffExportTargets = diffExportTargets;
1680
1738
  exports.downloadBinary = downloadBinary;
1681
1739
  exports.ensureBinary = ensureBinary;
@@ -1690,6 +1748,7 @@ exports.installPythonRequirements = installPythonRequirements;
1690
1748
  exports.nodeProcStatReader = nodeProcStatReader;
1691
1749
  exports.parseProcCpuSeconds = parseProcCpuSeconds;
1692
1750
  exports.parseProcRssBytes = parseProcRssBytes;
1751
+ exports.physicalRootOf = physicalRootOf;
1693
1752
  exports.readProcessCost = readProcessCost;
1694
1753
  exports.resolveExportFingerprint = resolveExportFingerprint;
1695
1754
  exports.signExpiringUrl = signExpiringUrl;