@camstack/addon-post-analysis 1.2.54 → 1.2.56

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.
@@ -2,11 +2,14 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BJ65Xxa1.js");
5
+ const require_dist = require("../dist-CcS9NwDl.js");
6
6
  let node_fs = require("node:fs");
7
+ node_fs = require_dist.__toESM(node_fs, 1);
7
8
  let node_path = require("node:path");
9
+ let node_path$1 = require_dist.__toESM(node_path, 1);
8
10
  node_path = require_dist.__toESM(node_path);
9
11
  let node_crypto = require("node:crypto");
12
+ require("node:child_process");
10
13
  let sharp = require("sharp");
11
14
  sharp = require_dist.__toESM(sharp);
12
15
  //#region src/notification-center/action-token.ts
@@ -727,6 +730,227 @@ var NC_ALARM_DECLARATION = {
727
730
  DeviceClass: NcAlarmPanelDevice
728
731
  };
729
732
  //#endregion
733
+ //#region ../types/dist/node.mjs
734
+ var node_exports = /* @__PURE__ */ require_dist.__exportAll({
735
+ FilesystemStorageProvider: () => FilesystemStorageProvider,
736
+ PYTHON_VERSION: () => PYTHON_VERSION,
737
+ signExpiringUrl: () => signExpiringUrl,
738
+ verifyExpiringUrl: () => verifyExpiringUrl
739
+ });
740
+ /**
741
+ * Download portable Python (headless) from bjia56/portable-python.
742
+ *
743
+ * Source: https://github.com/bjia56/portable-python
744
+ *
745
+ * Advantages over python-build-standalone:
746
+ * - macOS universal2 binary (arm64+x86_64 in one)
747
+ * - Lighter (~24-28MB headless zip)
748
+ * - glibc 2.17 minimum (better compat)
749
+ * - Headless variant excludes tkinter/UI (ideal for ML inference)
750
+ *
751
+ * The zip's top-level dir is platform-specific (e.g.
752
+ * `python-headless-3.12.12-darwin-universal2/`); after extraction
753
+ * we rename it to a stable `python/` so consumers can rely on
754
+ * `<dataDir>/deps/python/bin/python3`.
755
+ */
756
+ var PYTHON_VERSION = "3.12.12";
757
+ `${PYTHON_VERSION}`;
758
+ var STORAGE_LOCATION_TYPES = [
759
+ "data",
760
+ "media",
761
+ "recordings",
762
+ "recordings-high",
763
+ "recordings-low",
764
+ "recordings-clips",
765
+ "event-images",
766
+ "models",
767
+ "addons-data",
768
+ "cache",
769
+ "logs",
770
+ "backups"
771
+ ];
772
+ var DEFAULT_LOCATION_SUBDIRS = {
773
+ data: "db",
774
+ media: "media",
775
+ recordings: "recordings",
776
+ "recordings-high": "recordings-high",
777
+ "recordings-low": "recordings-low",
778
+ "recordings-clips": "recordings-clips",
779
+ "event-images": "event-images",
780
+ models: "models",
781
+ "addons-data": "addons-data",
782
+ cache: "/tmp/camstack-cache",
783
+ logs: "logs",
784
+ backups: "backups"
785
+ };
786
+ /**
787
+ * Filesystem storage provider — serves all location types from a local directory tree.
788
+ *
789
+ * Default layout:
790
+ * {rootPath}/recordings-high/
791
+ * {rootPath}/recordings-low/
792
+ * {rootPath}/recordings-clips/
793
+ * {rootPath}/event-images/
794
+ * {rootPath}/models/
795
+ * {rootPath}/addons-data/
796
+ * {rootPath}/logs/
797
+ * /tmp/camstack-cache/ (cache is always local)
798
+ *
799
+ * Individual location paths can be overridden.
800
+ */
801
+ var FilesystemStorageProvider = class {
802
+ id = "local";
803
+ name = "Local Filesystem";
804
+ supportedLocations = [...STORAGE_LOCATION_TYPES];
805
+ rootPath;
806
+ locationPaths;
807
+ constructor(rootPath, overrides) {
808
+ this.rootPath = node_path$1.resolve(rootPath);
809
+ this.locationPaths = /* @__PURE__ */ new Map();
810
+ for (const loc of STORAGE_LOCATION_TYPES) {
811
+ const override = overrides?.[loc];
812
+ if (override) this.locationPaths.set(loc, node_path$1.resolve(override));
813
+ else {
814
+ const subdir = DEFAULT_LOCATION_SUBDIRS[loc] ?? loc;
815
+ this.locationPaths.set(loc, node_path$1.isAbsolute(subdir) ? subdir : node_path$1.join(this.rootPath, subdir));
816
+ }
817
+ }
818
+ for (const [loc, override] of Object.entries(overrides ?? {})) if (typeof override === "string" && !this.locationPaths.has(loc)) this.locationPaths.set(loc, node_path$1.resolve(override));
819
+ }
820
+ async resolve({ location, relativePath }) {
821
+ const base = this.locationPaths.get(location) ?? node_path$1.join(this.rootPath, location);
822
+ return node_path$1.join(base, relativePath);
823
+ }
824
+ async write({ location, relativePath, data }) {
825
+ const filePath = await this.resolve({
826
+ location,
827
+ relativePath
828
+ });
829
+ await node_fs.promises.mkdir(node_path$1.dirname(filePath), { recursive: true });
830
+ if (Buffer.isBuffer(data)) await node_fs.promises.writeFile(filePath, data);
831
+ else {
832
+ const writeStream = node_fs.createWriteStream(filePath);
833
+ await new Promise((resolve, reject) => {
834
+ data.pipe(writeStream);
835
+ writeStream.on("finish", resolve);
836
+ writeStream.on("error", reject);
837
+ });
838
+ }
839
+ }
840
+ async read({ location, relativePath }) {
841
+ return node_fs.promises.readFile(await this.resolve({
842
+ location,
843
+ relativePath
844
+ }));
845
+ }
846
+ async exists({ location, relativePath }) {
847
+ try {
848
+ await node_fs.promises.access(await this.resolve({
849
+ location,
850
+ relativePath
851
+ }));
852
+ return true;
853
+ } catch {
854
+ return false;
855
+ }
856
+ }
857
+ async list({ location, prefix }) {
858
+ const base = this.locationPaths.get(location);
859
+ if (!base) return [];
860
+ const dir = prefix ? node_path$1.join(base, prefix) : base;
861
+ try {
862
+ return (await node_fs.promises.readdir(dir, { withFileTypes: true })).map((e) => prefix ? `${prefix}/${e.name}` : e.name);
863
+ } catch {
864
+ return [];
865
+ }
866
+ }
867
+ async delete({ location, relativePath }) {
868
+ const filePath = await this.resolve({
869
+ location,
870
+ relativePath
871
+ });
872
+ await node_fs.promises.rm(filePath, { force: true });
873
+ }
874
+ async getAvailableSpace({ location }) {
875
+ const base = this.locationPaths.get(location);
876
+ if (!base) return null;
877
+ try {
878
+ let target = base;
879
+ while (!node_fs.existsSync(target)) {
880
+ const parent = node_path$1.dirname(target);
881
+ if (!parent || parent === target) return null;
882
+ target = parent;
883
+ }
884
+ const stats = await node_fs.promises.statfs(target);
885
+ return stats.bavail * stats.bsize;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+ /** Get the resolved path for a location type (addon-declared ids fall back
891
+ * to `<rootPath>/<id>`). */
892
+ getLocationPath(location) {
893
+ return this.locationPaths.get(location) ?? node_path$1.join(this.rootPath, location);
894
+ }
895
+ /** Get the root path */
896
+ getRootPath() {
897
+ return this.rootPath;
898
+ }
899
+ };
900
+ /**
901
+ * The ONE derivation of "a signed, expiring URL".
902
+ *
903
+ * This repo mints unguessable, self-expiring links in three places now — the
904
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
905
+ * link plane. The first two grew independently and are byte-identical logic
906
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
907
+ * each restating the crypto locally because **addons never import each other**.
908
+ *
909
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
910
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
911
+ * different compare, or a missing expiry check, and nothing fails until a link
912
+ * that should have died keeps working. The fix is the same one D52 applies to
913
+ * crop geometry: one derivation, in a place every addon may depend on. A
914
+ * framework package is exactly that place — `@camstack/types/node`, off the root
915
+ * entry because `node:crypto` must never be traversed by a browser bundler.
916
+ *
917
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
918
+ * shape of `id`, and the access level of the route. Those are per-plane policy
919
+ * and each caller states them where a reader can see them.
920
+ */
921
+ /**
922
+ * The signature over `(id, expMs)`.
923
+ *
924
+ * `id` is whatever the plane uses to name the thing being served — an artifact
925
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
926
+ * must not put a `:` inside a field whose boundary matters; where a plane has
927
+ * more than one field, it composes them itself and owns that ambiguity.
928
+ */
929
+ function signExpiringUrl(secret, id, expMs) {
930
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
931
+ }
932
+ /**
933
+ * Verify a request's `(id, exp, sig)`.
934
+ *
935
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
936
+ * whether or not its signature is valid — a leaked URL stops working on its own
937
+ * and cannot be kept alive by holding a correct signature. The compare itself is
938
+ * constant-time so a public route cannot be probed for the signature byte by
939
+ * byte.
940
+ */
941
+ function verifyExpiringUrl(input) {
942
+ const { secret, id, exp, sig, nowMs } = input;
943
+ if (exp === void 0 || sig === void 0) return false;
944
+ const expMs = typeof exp === "number" ? exp : Number(exp);
945
+ if (!Number.isFinite(expMs)) return false;
946
+ if (expMs <= nowMs) return false;
947
+ const expected = signExpiringUrl(secret, id, expMs);
948
+ const a = Buffer.from(expected, "utf8");
949
+ const b = Buffer.from(sig, "utf8");
950
+ if (a.length !== b.length) return false;
951
+ return (0, node_crypto.timingSafeEqual)(a, b);
952
+ }
953
+ //#endregion
730
954
  //#region src/notification-center/artifact-url.ts
731
955
  /**
732
956
  * Signed, externally-reachable URLs for notification artifacts.
@@ -743,7 +967,10 @@ var NC_ALARM_DECLARATION = {
743
967
  * recipient's phone) — it holds no session and no admin token. The route is
744
968
  * therefore `access:'public'` and the authority is the signature: an
745
969
  * unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
746
- * its own. Nothing else about the install is reachable through it.
970
+ * its own. Nothing else about the install is reachable through it. The HMAC
971
+ * is the shared one in `@camstack/types/node` — this module used to restate
972
+ * it, as did the Home Assistant media plane, and two copies of a signing
973
+ * scheme is how one of them quietly loses its expiry check.
747
974
  * - **The base URL is CHOSEN, never assumed.** A notification carrying
748
975
  * `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
749
976
  * ranked endpoint list the `local-network` cap already computes — public
@@ -760,9 +987,17 @@ var NC_ALARM_DECLARATION = {
760
987
  function pickArtifactBaseUrl(endpoints) {
761
988
  return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
762
989
  }
763
- /** Sign `(id, exp)` — the exact string the verifier recomputes. */
990
+ /**
991
+ * Sign `(id, exp)` — the exact string the verifier recomputes.
992
+ *
993
+ * Delegates to the one derivation in `@camstack/types/node`. This used to
994
+ * restate the HMAC locally, and so did the Home Assistant media plane, on the
995
+ * correct reasoning that addons never import each other — but two copies of a
996
+ * signing scheme is how one of them ends up with a different expiry check that
997
+ * nothing catches.
998
+ */
764
999
  function signArtifact(secret, id, expMs) {
765
- return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${expMs}`).digest("hex");
1000
+ return signExpiringUrl(secret, id, expMs);
766
1001
  }
767
1002
  /**
768
1003
  * Build the fully-qualified, signed URL for an artifact.
@@ -779,12 +1014,7 @@ function buildArtifactUrl(input) {
779
1014
  * the compare so an expired link is rejected even with a valid signature.
780
1015
  */
781
1016
  function verifyArtifactSignature(input) {
782
- if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
783
- const expected = signArtifact(input.secret, input.id, input.exp);
784
- const a = Buffer.from(expected, "utf8");
785
- const b = Buffer.from(input.sig, "utf8");
786
- if (a.length !== b.length) return false;
787
- return (0, node_crypto.timingSafeEqual)(a, b);
1017
+ return verifyExpiringUrl(input);
788
1018
  }
789
1019
  //#endregion
790
1020
  //#region src/notification-center/artifact-plane.ts
@@ -12452,23 +12682,73 @@ async function makeSquareThumb(bytes, size) {
12452
12682
  }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
12453
12683
  }
12454
12684
  //#endregion
12685
+ //#region src/pipeline-analytics/zone-name-stamp.ts
12686
+ /**
12687
+ * Copy each track with `zoneNames` stamped on.
12688
+ *
12689
+ * Three silences, all deliberate, all of them "say nothing" rather than "say
12690
+ * something wrong":
12691
+ *
12692
+ * - a track that visited no zone gets NO field (an empty array on every row of
12693
+ * a 1000-row page is pure wire cost for no information);
12694
+ * - a zone id with no entry in the catalogue — a zone deleted since the track
12695
+ * was written — is DROPPED. `zoneNames` is therefore not positionally
12696
+ * aligned with `zonesVisited` and can be shorter;
12697
+ * - a device whose catalogue could not be read at all leaves the track exactly
12698
+ * as it arrived. A zones cap that is momentarily down must not look like a
12699
+ * track that was never in a zone.
12700
+ */
12701
+ function stampZoneNames(tracks, lookup) {
12702
+ return tracks.map((track) => {
12703
+ if (track.zonesVisited.length === 0) return track;
12704
+ const catalogue = lookup(track.deviceId);
12705
+ if (catalogue === null) return track;
12706
+ const names = [];
12707
+ for (const id of track.zonesVisited) {
12708
+ const name = catalogue.get(id);
12709
+ if (name !== void 0 && name.length > 0 && !names.includes(name)) names.push(name);
12710
+ }
12711
+ if (names.length === 0) return track;
12712
+ return {
12713
+ ...track,
12714
+ zoneNames: names
12715
+ };
12716
+ });
12717
+ }
12718
+ //#endregion
12455
12719
  //#region src/pipeline-analytics/analytics-query-facade.ts
12456
12720
  var AnalyticsQueryFacade = class {
12457
12721
  deps;
12458
12722
  constructor(deps) {
12459
12723
  this.deps = deps;
12460
12724
  }
12725
+ /**
12726
+ * Stamp the human `zoneNames` onto a batch of tracks.
12727
+ *
12728
+ * ONE place, on the read path, for every track surface — `zonesVisited` is
12729
+ * ids and a client cannot resolve them without a per-camera fan-out. A hub
12730
+ * with no catalogue reader configured returns the tracks untouched.
12731
+ */
12732
+ async withZoneNames(tracks) {
12733
+ const cache = this.deps.zoneCatalogue?.() ?? null;
12734
+ if (cache === null || tracks.length === 0) return tracks;
12735
+ const zoned = tracks.filter((t) => t.zonesVisited.length > 0);
12736
+ if (zoned.length === 0) return tracks;
12737
+ await cache.ensure(zoned.map((t) => t.deviceId));
12738
+ return stampZoneNames(tracks, (deviceId) => cache.lookup(deviceId));
12739
+ }
12461
12740
  async getActiveTracks(input) {
12462
- return this.deps.trackStore()?.getActive(input.deviceId) ?? [];
12741
+ return this.withZoneNames(this.deps.trackStore()?.getActive(input.deviceId) ?? []);
12463
12742
  }
12464
12743
  async getTrack(input) {
12465
12744
  const store = this.deps.trackStore();
12466
- const active = store?.getActiveByTrack(input.trackId);
12467
- if (active) return active;
12468
- return await store?.getPersistedByTrackId(input.trackId) ?? null;
12745
+ const track = store?.getActiveByTrack(input.trackId) ?? await store?.getPersistedByTrackId(input.trackId) ?? null;
12746
+ if (track === null) return null;
12747
+ const [named] = await this.withZoneNames([track]);
12748
+ return named ?? track;
12469
12749
  }
12470
12750
  async listTracks(input) {
12471
- return this.deps.trackStore()?.queryHistorical(input) ?? [];
12751
+ return this.withZoneNames(await this.deps.trackStore()?.queryHistorical(input) ?? []);
12472
12752
  }
12473
12753
  /**
12474
12754
  * Batched cluster-wide track listing — one merged (`lastSeen` DESC,
@@ -12478,10 +12758,14 @@ var AnalyticsQueryFacade = class {
12478
12758
  * indexed page + k-way merge and the cursor encoding.
12479
12759
  */
12480
12760
  async listRecentTracks(input) {
12481
- return this.deps.trackStore()?.queryRecent(input) ?? {
12761
+ const page = await this.deps.trackStore()?.queryRecent(input) ?? {
12482
12762
  tracks: [],
12483
12763
  nextCursor: null
12484
12764
  };
12765
+ return {
12766
+ ...page,
12767
+ tracks: await this.withZoneNames(page.tracks)
12768
+ };
12485
12769
  }
12486
12770
  /** Per-camera sensor-event history (state changes of linked devices). */
12487
12771
  async getSensorEvents(input) {
@@ -17153,6 +17437,72 @@ var RetrainService = class {
17153
17437
  }
17154
17438
  };
17155
17439
  //#endregion
17440
+ //#region src/pipeline-analytics/zone-catalogue-cache.ts
17441
+ /**
17442
+ * zone-catalogue-cache — the per-device zone id→name dictionary the track read
17443
+ * path stamps `zoneNames` from.
17444
+ *
17445
+ * The `zones` capability is per-device, so resolving names on a merged
17446
+ * `listRecentTracks` page would otherwise be one cap call per camera per page —
17447
+ * the exact fan-out that surface exists to remove. One read per camera per TTL
17448
+ * is the whole cost, against a catalogue that changes only when an operator
17449
+ * draws a polygon.
17450
+ *
17451
+ * The failure rule is the important part: a refresh that throws KEEPS the last
17452
+ * known catalogue. Dropping it would make every zoned track look unzoned for
17453
+ * the next minute, which reads as "zones stopped working" rather than "the
17454
+ * zones cap blipped".
17455
+ */
17456
+ /** How long a device's zone catalogue is trusted before a re-read. A zone
17457
+ * rename should reach the feed without a restart; a minute is invisible to an
17458
+ * operator and reduces the read to ~16 cheap calls/min on this fleet. */
17459
+ var ZONE_CATALOGUE_TTL_MS = 6e4;
17460
+ var ZoneCatalogueCache = class {
17461
+ deps;
17462
+ entries = /* @__PURE__ */ new Map();
17463
+ /** In-flight reads, so a burst of pages for the same camera makes one call. */
17464
+ inFlight = /* @__PURE__ */ new Map();
17465
+ constructor(deps) {
17466
+ this.deps = deps;
17467
+ }
17468
+ /** Make sure every device has a catalogue no older than the TTL. */
17469
+ async ensure(deviceIds) {
17470
+ const stale = [...new Set(deviceIds)].filter((id) => this.isStale(id));
17471
+ await Promise.all(stale.map((id) => this.refresh(id)));
17472
+ }
17473
+ /** The device's zone id→name map, or null when it was never readable. */
17474
+ lookup(deviceId) {
17475
+ return this.entries.get(deviceId)?.names ?? null;
17476
+ }
17477
+ isStale(deviceId) {
17478
+ const held = this.entries.get(deviceId);
17479
+ if (held === void 0) return true;
17480
+ return this.deps.now() - held.readAtMs >= ZONE_CATALOGUE_TTL_MS;
17481
+ }
17482
+ async refresh(deviceId) {
17483
+ const running = this.inFlight.get(deviceId);
17484
+ if (running !== void 0) return running;
17485
+ const task = this.read(deviceId).finally(() => {
17486
+ this.inFlight.delete(deviceId);
17487
+ });
17488
+ this.inFlight.set(deviceId, task);
17489
+ return task;
17490
+ }
17491
+ async read(deviceId) {
17492
+ try {
17493
+ const zones = await this.deps.fetchZones(deviceId);
17494
+ const names = /* @__PURE__ */ new Map();
17495
+ for (const zone of zones) if (zone.id.length > 0 && zone.name.length > 0) names.set(zone.id, zone.name);
17496
+ this.entries.set(deviceId, {
17497
+ names,
17498
+ readAtMs: this.deps.now()
17499
+ });
17500
+ } catch (error) {
17501
+ this.deps.onError(deviceId, error);
17502
+ }
17503
+ }
17504
+ };
17505
+ //#endregion
17156
17506
  //#region src/pipeline-analytics/media-capture-log.ts
17157
17507
  var DEFAULT_FLUSH_INTERVAL_MS$1 = 6e4;
17158
17508
  var MediaCaptureLogAggregator = class {
@@ -31006,6 +31356,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31006
31356
  * bodies live in `analytics-query-facade.ts`; the class keeps one-line
31007
31357
  * delegates because the pipeline-analytics cap registers `this` as its
31008
31358
  * provider. Deps are lazy getters — the stores are late-bound in boot. */
31359
+ /**
31360
+ * Zone id→name dictionary for the track read path.
31361
+ *
31362
+ * `zonesVisited` persists ids; every operator-facing surface needs the name.
31363
+ * Cached per device (see `ZoneCatalogueCache`) so a merged 1000-row page
31364
+ * costs at most one `listZones` per camera per minute rather than one per
31365
+ * page.
31366
+ */
31367
+ zoneCatalogue = new ZoneCatalogueCache({
31368
+ fetchZones: (deviceId) => this.ctx.api.zones.listZones.query({ deviceId }),
31369
+ now: () => Date.now(),
31370
+ onError: (deviceId, error) => {
31371
+ this.ctx.logger.warn("zone catalogue read failed — zone names unresolved for this device", {
31372
+ tags: { deviceId },
31373
+ meta: {
31374
+ deviceId,
31375
+ error: require_dist.errMsg(error)
31376
+ }
31377
+ });
31378
+ }
31379
+ });
31009
31380
  queryFacade = new AnalyticsQueryFacade({
31010
31381
  trackStore: () => this.trackStore,
31011
31382
  eventStore: () => this.eventStore,
@@ -31013,7 +31384,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31013
31384
  sensorEventStore: () => this.sensorEventStore,
31014
31385
  opsLog: () => this.eventsOpsLog,
31015
31386
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
31016
- logger: () => this.ctx.logger
31387
+ logger: () => this.ctx.logger,
31388
+ zoneCatalogue: () => this.zoneCatalogue
31017
31389
  });
31018
31390
  /** The shared crop extractor (native-res first, detection-frame fallback),
31019
31391
  * captured in the constructor so `processFrame` can crop object thumbnails in
@@ -31367,7 +31739,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
31367
31739
  let storage = this.ctx.kernel.storage;
31368
31740
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
31369
31741
  if (mediaRoot) {
31370
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DTrkngzv.js"));
31742
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => node_exports);
31371
31743
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
31372
31744
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
31373
31745
  }