@camstack/types 1.2.161 → 1.2.163

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