@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.
- package/dist/addon.js +4 -3
- package/dist/addon.mjs +4 -3
- package/dist/capabilities/alerts.cap.d.ts +5 -5
- package/dist/capabilities/index.d.ts +7 -5
- package/dist/capabilities/motion.cap.d.ts +2 -0
- package/dist/capabilities/notifier.cap.d.ts +3 -3
- package/dist/capabilities/pet-feeder.cap.d.ts +4 -4
- package/dist/capabilities/pipeline-runner.cap.d.ts +19 -0
- package/dist/capabilities/snapshot.cap.d.ts +2 -2
- package/dist/capabilities/storage-occupancy.cap.d.ts +72 -0
- package/dist/capabilities/storage-provider.cap.d.ts +110 -0
- package/dist/capabilities/storage.cap.d.ts +43 -0
- package/dist/capabilities/stream-broker.cap.d.ts +1 -1
- package/dist/device/device-profile.d.ts +1 -1
- package/dist/enums/event-category.d.ts +13 -0
- package/dist/enums.js +1 -1
- package/dist/enums.mjs +1 -1
- package/dist/{event-category-b8kBSOTT.js → event-category-BVo6ta6_.js} +13 -0
- package/dist/{event-category-zAv7pMUz.mjs → event-category-CnLqLOKs.mjs} +13 -0
- package/dist/generated/addon-api.d.ts +22 -0
- package/dist/generated/capability-router-map.d.ts +5 -2
- package/dist/generated/collection-array-methods.d.ts +1 -1
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +914 -561
- package/dist/index.mjs +895 -562
- package/dist/interfaces/event-bus.d.ts +23 -2
- package/dist/interfaces/storage-location-mode.d.ts +135 -0
- package/dist/interfaces/storage-location.d.ts +54 -6
- package/dist/interfaces/stream-broker.d.ts +15 -0
- package/dist/node.d.ts +13 -11
- package/dist/node.js +935 -876
- package/dist/node.mjs +936 -879
- package/dist/{sleep-DDIFZGbc.js → sleep-BZtO-eFY.js} +1 -1
- package/dist/{sleep-Cfij6Jj9.mjs → sleep-Bd-Y4RUt.mjs} +1 -1
- package/dist/storage/physical-root.d.ts +25 -0
- 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/
|
|
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
|
-
*
|
|
464
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
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
|
|
699
|
+
* ## The init segment is RETAINED
|
|
488
700
|
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
612
|
-
|
|
613
|
-
return
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
*
|
|
623
|
-
*
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
"
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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/
|
|
965
|
+
//#region src/ffmpeg/process.ts
|
|
790
966
|
/**
|
|
791
|
-
*
|
|
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/
|
|
1230
|
+
//#region src/process/child-cost-registry.ts
|
|
1166
1231
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
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
|
-
* ##
|
|
1235
|
+
* ## Where a claim is made, and where it is released
|
|
1187
1236
|
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1211
|
-
*
|
|
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
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1229
|
-
*
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
*
|
|
1315
|
-
*
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
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
|
-
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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/
|
|
1453
|
-
var
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
});
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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;
|