@camstack/addon-post-analysis 1.2.63 → 1.2.64

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.
@@ -1,4 +1,4 @@
1
- import { $ as array, B as subKindsOf, C as addonWidgetsSourceCapability, D as cosineSimilarity, E as buildEventKindDescriptor, F as kebabToCamel, G as BaseAddon, H as videoclipsCapability, I as notificationRulesCapability, J as hydrateSchema, K as DeviceType, L as pipelineAnalyticsCapability, M as faceGalleryCapability, O as customAction, P as isScheduleActive, Q as _enum, R as plateGalleryCapability, S as TrackSourceSchema, T as audioMetricsCapability, U as zoneAnalyticsCapability, V as vectorDimFromBase64, W as errMsg, X as nodePin, Y as isDeviceScopedCap, Z as sleep, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as string, b as TimelapseRuleInputSchema, c as NC_CONDITION_CATALOG, d as NcRuleInputSchema, et as boolean, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as record, j as encodeVectorBase64, k as defineCustomActions, l as NC_TAXONOMY, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as number, o as LabelAttributionSchema, ot as unknown, p as NcRuleSchema, q as createEvent, r as DeclaredDevices, rt as object, s as MACRO_LABELS, st as EventCategory, t as BaseDevice, tt as literal, u as NcConditionDescriptorSchema, v as OpsLogEntrySchema, w as alarmPanelCapability, x as TimelapseRuleSchema, y as RetrainStatusSchema, z as readDeviceStateFrom } from "../dist-CdqESw05.mjs";
1
+ import { $ as array, B as subKindsOf, C as addonWidgetsSourceCapability, D as cosineSimilarity, E as buildEventKindDescriptor, F as kebabToCamel, G as BaseAddon, H as videoclipsCapability, I as notificationRulesCapability, J as hydrateSchema, K as DeviceType, L as pipelineAnalyticsCapability, M as faceGalleryCapability, O as customAction, P as isScheduleActive, Q as _enum, R as plateGalleryCapability, S as TrackSourceSchema, T as audioMetricsCapability, U as zoneAnalyticsCapability, V as vectorDimFromBase64, W as errMsg, X as nodePin, Y as isDeviceScopedCap, Z as sleep, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as string, b as TimelapseRuleInputSchema, c as NC_CONDITION_CATALOG, d as NcRuleInputSchema, et as boolean, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as record, j as encodeVectorBase64, k as defineCustomActions, l as NC_TAXONOMY, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as number, o as LabelAttributionSchema, ot as unknown, p as NcRuleSchema, q as createEvent, r as DeclaredDevices, rt as object, s as MACRO_LABELS, st as EventCategory, t as BaseDevice, tt as literal, u as NcConditionDescriptorSchema, v as OpsLogEntrySchema, w as alarmPanelCapability, x as TimelapseRuleSchema, y as RetrainStatusSchema, z as readDeviceStateFrom } from "../dist-CSTtfEOC.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -15771,6 +15771,7 @@ function resolveFaceSettings(raw) {
15771
15771
  */
15772
15772
  function createLocationAwareMediaStorage(deps) {
15773
15773
  const roots = /* @__PURE__ */ new Map();
15774
+ let defaultBypassInvalidated = false;
15774
15775
  const rootOf = async (locationId) => {
15775
15776
  const cached = roots.get(locationId);
15776
15777
  if (cached !== void 0) return cached;
@@ -15781,21 +15782,55 @@ function createLocationAwareMediaStorage(deps) {
15781
15782
  const absOf = async (locationId, relativePath) => path.join(await rootOf(locationId), relativePath);
15782
15783
  return {
15783
15784
  write: async (input) => {
15784
- if (input.location === deps.defaultLocation) return deps.base.write(input);
15785
+ if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.write(input);
15785
15786
  const abs = await absOf(input.location, input.relativePath);
15786
15787
  await promises.mkdir(path.dirname(abs), { recursive: true });
15787
15788
  await promises.writeFile(abs, input.data);
15788
15789
  },
15789
15790
  read: async (input) => {
15790
- if (input.location === deps.defaultLocation) return deps.base.read(input);
15791
+ if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.read(input);
15791
15792
  return promises.readFile(await absOf(input.location, input.relativePath));
15792
15793
  },
15793
15794
  delete: async (input) => {
15794
- if (input.location === deps.defaultLocation) return deps.base.delete(input);
15795
+ if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.delete(input);
15795
15796
  await promises.rm(await absOf(input.location, input.relativePath), { force: true });
15797
+ },
15798
+ invalidateRoots: () => {
15799
+ roots.clear();
15800
+ defaultBypassInvalidated = true;
15796
15801
  }
15797
15802
  };
15798
15803
  }
15804
+ //#endregion
15805
+ //#region src/pipeline-analytics/media-write-gate.ts
15806
+ /**
15807
+ * Reversible writer gate for storage maintenance. `pause()` closes admission
15808
+ * first, then waits for writes already admitted to settle; callers never
15809
+ * mutate device bindings or analytics configuration to quiesce media.
15810
+ */
15811
+ var MediaWriteGate = class {
15812
+ paused = false;
15813
+ active = 0;
15814
+ drainedWaiters = [];
15815
+ async run(write) {
15816
+ if (this.paused) throw new Error("event-media writes are paused for storage migration");
15817
+ this.active++;
15818
+ try {
15819
+ return await write();
15820
+ } finally {
15821
+ this.active--;
15822
+ if (this.active === 0) for (const resolve of this.drainedWaiters.splice(0)) resolve();
15823
+ }
15824
+ }
15825
+ pause() {
15826
+ this.paused = true;
15827
+ if (this.active === 0) return Promise.resolve();
15828
+ return new Promise((resolve) => this.drainedWaiters.push(resolve));
15829
+ }
15830
+ resume() {
15831
+ this.paused = false;
15832
+ }
15833
+ };
15799
15834
  var MACRO_SET = new Set([
15800
15835
  "person",
15801
15836
  "vehicle",
@@ -16666,7 +16701,14 @@ var RETRAIN_FRAME_COLUMNS = [
16666
16701
  name: "copiedAt",
16667
16702
  type: "INTEGER",
16668
16703
  notNull: true
16669
- }
16704
+ },
16705
+ (
16706
+ /** NULL = current eventMedia default; stamped when migration moves a copy
16707
+ * before the logical default is repointed. */
16708
+ {
16709
+ name: "locationId",
16710
+ type: "TEXT"
16711
+ })
16670
16712
  ];
16671
16713
  var RETRAIN_FRAME_INDEXES = [{
16672
16714
  name: "idx_retrain_frames_track",
@@ -16695,12 +16737,14 @@ var RetrainFrameStore = class {
16695
16737
  logger;
16696
16738
  readSource;
16697
16739
  now;
16740
+ writeGate;
16698
16741
  constructor(deps) {
16699
16742
  this.store = deps.store;
16700
16743
  this.storage = deps.storage;
16701
16744
  this.logger = deps.logger;
16702
16745
  this.readSource = deps.readSource;
16703
16746
  this.now = deps.now;
16747
+ this.writeGate = deps.writeGate;
16704
16748
  }
16705
16749
  static async declare(store) {
16706
16750
  await store.declareCollection.mutate({
@@ -16719,7 +16763,8 @@ var RetrainFrameStore = class {
16719
16763
  sizeBytes: Number(data["sizeBytes"] ?? 0),
16720
16764
  width: Number(data["width"] ?? 0),
16721
16765
  height: Number(data["height"] ?? 0),
16722
- copiedAt: Number(data["copiedAt"] ?? 0)
16766
+ copiedAt: Number(data["copiedAt"] ?? 0),
16767
+ locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
16723
16768
  };
16724
16769
  }
16725
16770
  /** The copy with this id, or `null`. Index read only — no blob is touched. */
@@ -16787,7 +16832,7 @@ var RetrainFrameStore = class {
16787
16832
  async readFrame(frame) {
16788
16833
  try {
16789
16834
  return await this.storage.read({
16790
- location: RETRAIN_MEDIA_LOCATION,
16835
+ location: frame.locationId ?? "eventMedia",
16791
16836
  relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
16792
16837
  });
16793
16838
  } catch (err) {
@@ -16867,9 +16912,10 @@ var RetrainFrameStore = class {
16867
16912
  sizeBytes: bytes.length,
16868
16913
  width: size.width,
16869
16914
  height: size.height,
16870
- copiedAt: this.now()
16915
+ copiedAt: this.now(),
16916
+ locationId: null
16871
16917
  };
16872
- try {
16918
+ const persist = async () => {
16873
16919
  await this.storage.write({
16874
16920
  location: RETRAIN_MEDIA_LOCATION,
16875
16921
  relativePath: path,
@@ -16887,9 +16933,14 @@ var RetrainFrameStore = class {
16887
16933
  sizeBytes: frame.sizeBytes,
16888
16934
  width: frame.width,
16889
16935
  height: frame.height,
16890
- copiedAt: frame.copiedAt
16936
+ copiedAt: frame.copiedAt,
16937
+ locationId: null
16891
16938
  }
16892
16939
  });
16940
+ };
16941
+ try {
16942
+ if (this.writeGate) await this.writeGate.run(persist);
16943
+ else await persist();
16893
16944
  } catch (err) {
16894
16945
  this.logger.error("retrain copy failed to persist", {
16895
16946
  tags: { deviceId: input.deviceId },
@@ -16930,26 +16981,30 @@ var RetrainFrameStore = class {
16930
16981
  * best-effort write is how a half-deleted annotation set happens.
16931
16982
  */
16932
16983
  async deleteFrame(frame) {
16933
- try {
16934
- await this.storage.delete({
16935
- location: RETRAIN_MEDIA_LOCATION,
16936
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
16937
- });
16938
- } catch {}
16939
- try {
16940
- await this.store.delete.mutate({
16941
- collection: RETRAIN_FRAMES_COLLECTION,
16942
- key: frame.frameId
16943
- });
16944
- } catch (err) {
16945
- this.logger.warn("RetrainFrameStore.deleteFrame row delete failed", {
16946
- tags: { deviceId: frame.deviceId },
16947
- meta: {
16948
- frameId: frame.frameId,
16949
- error: String(err)
16950
- }
16951
- });
16952
- }
16984
+ const remove = async () => {
16985
+ try {
16986
+ await this.storage.delete({
16987
+ location: frame.locationId ?? "eventMedia",
16988
+ relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
16989
+ });
16990
+ } catch {}
16991
+ try {
16992
+ await this.store.delete.mutate({
16993
+ collection: RETRAIN_FRAMES_COLLECTION,
16994
+ key: frame.frameId
16995
+ });
16996
+ } catch (err) {
16997
+ this.logger.warn("RetrainFrameStore.deleteFrame row delete failed", {
16998
+ tags: { deviceId: frame.deviceId },
16999
+ meta: {
17000
+ frameId: frame.frameId,
17001
+ error: String(err)
17002
+ }
17003
+ });
17004
+ }
17005
+ };
17006
+ if (this.writeGate) await this.writeGate.run(remove);
17007
+ else await remove();
16953
17008
  }
16954
17009
  };
16955
17010
  //#endregion
@@ -17988,10 +18043,15 @@ var MediaStore = class {
17988
18043
  storage;
17989
18044
  store;
17990
18045
  logger;
18046
+ writeGate;
17991
18047
  constructor(deps) {
17992
18048
  this.storage = deps.storage;
17993
18049
  this.store = deps.store;
17994
18050
  this.logger = deps.logger;
18051
+ this.writeGate = deps.writeGate;
18052
+ }
18053
+ gated(mutation) {
18054
+ return this.writeGate ? this.writeGate.run(mutation) : mutation();
17995
18055
  }
17996
18056
  static async declare(store) {
17997
18057
  await store.declareCollection.mutate({
@@ -18003,6 +18063,10 @@ var MediaStore = class {
18003
18063
  /** Persist a media blob and register its metadata row. Returns the
18004
18064
  * collection key (MediaFile.key) that callers store as reference. */
18005
18065
  async put(params) {
18066
+ if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
18067
+ return this.putUnchecked(params);
18068
+ }
18069
+ async putUnchecked(params) {
18006
18070
  const key = buildKey(params);
18007
18071
  const path = buildPath(params);
18008
18072
  const record = {
@@ -18073,6 +18137,9 @@ var MediaStore = class {
18073
18137
  * scheme (transition back-compat); once migrated it is a no-op.
18074
18138
  */
18075
18139
  async putReplacing(params) {
18140
+ return this.gated(() => this.putReplacingUnchecked(params));
18141
+ }
18142
+ async putReplacingUnchecked(params) {
18076
18143
  const existing = await this.store.query.query({
18077
18144
  collection: MEDIA_COLLECTION,
18078
18145
  filter: { where: {
@@ -18081,7 +18148,7 @@ var MediaStore = class {
18081
18148
  kind: params.kind
18082
18149
  } }
18083
18150
  });
18084
- const newKey = await this.put(params);
18151
+ const newKey = await this.putUnchecked(params);
18085
18152
  for (const row of existing) {
18086
18153
  if (row.id === newKey) continue;
18087
18154
  const rowData = row.data;
@@ -18124,8 +18191,11 @@ var MediaStore = class {
18124
18191
  * own kind.
18125
18192
  */
18126
18193
  async promoteToLastFrame(input) {
18194
+ return this.gated(() => this.promoteToLastFrameUnchecked(input));
18195
+ }
18196
+ async promoteToLastFrameUnchecked(input) {
18127
18197
  const data = Buffer.from(input.snapshot.base64, "base64");
18128
- const newKey = await this.putReplacing({
18198
+ const newKey = await this.putReplacingUnchecked({
18129
18199
  deviceId: input.deviceId,
18130
18200
  ownerKind: "track",
18131
18201
  ownerId: input.trackId,
@@ -18133,7 +18203,7 @@ var MediaStore = class {
18133
18203
  timestamp: input.snapshot.timestamp,
18134
18204
  data
18135
18205
  });
18136
- if (input.keepSource !== true) await this.deleteByKey(input.snapshot.key);
18206
+ if (input.keepSource !== true) await this.deleteByKeyUnchecked(input.snapshot.key);
18137
18207
  return newKey;
18138
18208
  }
18139
18209
  /**
@@ -18171,6 +18241,9 @@ var MediaStore = class {
18171
18241
  * Deletes the blob from storage (best-effort) and removes the index row.
18172
18242
  */
18173
18243
  async deleteByKey(key) {
18244
+ await this.gated(() => this.deleteByKeyUnchecked(key));
18245
+ }
18246
+ async deleteByKeyUnchecked(key) {
18174
18247
  const row = await this.store.get.query({
18175
18248
  collection: MEDIA_COLLECTION,
18176
18249
  key
@@ -18281,6 +18354,9 @@ var MediaStore = class {
18281
18354
  * Best-effort per row; returns the number of index rows removed.
18282
18355
  */
18283
18356
  async deleteForOwner(ownerKind, ownerIds) {
18357
+ return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
18358
+ }
18359
+ async deleteForOwnerUnchecked(ownerKind, ownerIds) {
18284
18360
  let removed = 0;
18285
18361
  for (const ownerId of ownerIds) {
18286
18362
  const rows = await this.store.query.query({
@@ -18362,6 +18438,9 @@ var MediaStore = class {
18362
18438
  * The old-entry deletion is best-effort: on failure a warning is logged
18363
18439
  * and the method still returns the new key. */
18364
18440
  async reown(input) {
18441
+ return this.gated(() => this.reownUnchecked(input));
18442
+ }
18443
+ async reownUnchecked(input) {
18365
18444
  const { fromOwnerKind, fromOwnerId, toOwnerKind, toOwnerId, key } = input;
18366
18445
  const row = (await this.store.query.query({
18367
18446
  collection: MEDIA_COLLECTION,
@@ -18395,7 +18474,7 @@ var MediaStore = class {
18395
18474
  relativePath: oldPath
18396
18475
  })
18397
18476
  };
18398
- const newKey = await this.put(newParams);
18477
+ const newKey = await this.putUnchecked(newParams);
18399
18478
  try {
18400
18479
  await this.storage.delete({
18401
18480
  location: mediaRowLocation(meta),
@@ -18449,8 +18528,10 @@ var MediaStore = class {
18449
18528
  * batch.
18450
18529
  */
18451
18530
  async deleteByTracks(trackIds) {
18452
- await this.deleteForOwner("track", trackIds);
18453
- await this.deleteForOwner("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
18531
+ await this.gated(async () => {
18532
+ await this.deleteForOwnerUnchecked("track", trackIds);
18533
+ await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
18534
+ });
18454
18535
  await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
18455
18536
  }
18456
18537
  /**
@@ -18492,7 +18573,7 @@ function snapshot(j) {
18492
18573
  fromLocationId: j.fromLocationId,
18493
18574
  toLocationId: j.toLocationId,
18494
18575
  deviceId: j.deviceId,
18495
- entities: ["media"],
18576
+ entities: ["media", "retrainFrames"],
18496
18577
  filesMoved: j.filesMoved,
18497
18578
  bytesMoved: j.bytesMoved,
18498
18579
  filesTotal: null,
@@ -18523,7 +18604,7 @@ var MediaRelocateEngine = class {
18523
18604
  state: "running",
18524
18605
  fromLocationId: "*",
18525
18606
  toLocationId: input.toLocationId,
18526
- deviceId: input.deviceId ?? null,
18607
+ deviceId: null,
18527
18608
  filesMoved: 0,
18528
18609
  bytesMoved: 0,
18529
18610
  startedAt: this.deps.now(),
@@ -18540,24 +18621,25 @@ var MediaRelocateEngine = class {
18540
18621
  const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
18541
18622
  try {
18542
18623
  await this.deps.resolveTargetRoot(job.toLocationId);
18624
+ let failedRows = 0;
18543
18625
  let cursor = 0;
18544
- let seenAtCursor = /* @__PURE__ */ new Set();
18626
+ let offsetAtCursor = 0;
18545
18627
  for (;;) {
18546
18628
  if (job.cancelRequested) break;
18547
- const fresh = (await this.deps.store.query.query({
18629
+ const rows = await this.deps.store.query.query({
18548
18630
  collection: MEDIA_COLLECTION,
18549
18631
  filter: {
18550
- ...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
18551
18632
  whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
18552
18633
  orderBy: {
18553
18634
  field: "timestamp",
18554
18635
  direction: "asc"
18555
18636
  },
18556
- limit: PAGE_SIZE
18637
+ limit: PAGE_SIZE,
18638
+ offset: offsetAtCursor
18557
18639
  }
18558
- })).filter((r) => !seenAtCursor.has(r.id));
18559
- if (fresh.length === 0) break;
18560
- for (const row of fresh) {
18640
+ });
18641
+ if (rows.length === 0) break;
18642
+ for (const row of rows) {
18561
18643
  if (job.cancelRequested) break;
18562
18644
  const data = row.data;
18563
18645
  const from = mediaRowLocation(data);
@@ -18565,6 +18647,17 @@ var MediaRelocateEngine = class {
18565
18647
  const relativePath = String(data["path"] ?? "");
18566
18648
  if (relativePath.length === 0) continue;
18567
18649
  try {
18650
+ if (await this.samePhysicalLocation(from, job.toLocationId)) {
18651
+ await this.deps.store.set.mutate({
18652
+ collection: MEDIA_COLLECTION,
18653
+ key: row.id,
18654
+ value: {
18655
+ ...data,
18656
+ locationId: job.toLocationId
18657
+ }
18658
+ });
18659
+ continue;
18660
+ }
18568
18661
  const bytes = await this.deps.storage.read({
18569
18662
  location: from,
18570
18663
  relativePath
@@ -18574,6 +18667,10 @@ var MediaRelocateEngine = class {
18574
18667
  relativePath,
18575
18668
  data: bytes
18576
18669
  });
18670
+ if ((await this.deps.storage.read({
18671
+ location: job.toLocationId,
18672
+ relativePath
18673
+ })).length !== bytes.length) throw new Error(`relocated media size mismatch for ${relativePath}`);
18577
18674
  await this.deps.store.set.mutate({
18578
18675
  collection: MEDIA_COLLECTION,
18579
18676
  key: row.id,
@@ -18590,20 +18687,99 @@ var MediaRelocateEngine = class {
18590
18687
  job.bytesMoved += bytes.length;
18591
18688
  await sleep(bytes.length / bytesPerMs);
18592
18689
  } catch (err) {
18690
+ failedRows++;
18593
18691
  this.deps.logger.debug("media relocate row failed", { meta: {
18594
18692
  key: row.id,
18595
18693
  error: String(err)
18596
18694
  } });
18597
18695
  }
18598
18696
  }
18599
- const last = fresh[fresh.length - 1];
18697
+ const last = rows[rows.length - 1];
18600
18698
  const lastTs = Number(last.data["timestamp"] ?? cursor);
18601
- if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
18699
+ if (lastTs === cursor) offsetAtCursor += rows.length;
18602
18700
  else {
18603
18701
  cursor = lastTs;
18604
- seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
18702
+ offsetAtCursor = rows.filter((row) => Number(row.data["timestamp"]) === lastTs).length;
18605
18703
  }
18704
+ if (rows.length < PAGE_SIZE) break;
18606
18705
  }
18706
+ let retrainOffset = 0;
18707
+ for (;;) {
18708
+ if (job.cancelRequested) break;
18709
+ const rows = await this.deps.store.query.query({
18710
+ collection: RETRAIN_FRAMES_COLLECTION,
18711
+ filter: {
18712
+ orderBy: {
18713
+ field: "copiedAt",
18714
+ direction: "asc"
18715
+ },
18716
+ limit: PAGE_SIZE,
18717
+ offset: retrainOffset
18718
+ }
18719
+ });
18720
+ if (rows.length === 0) break;
18721
+ for (const row of rows) {
18722
+ if (job.cancelRequested) break;
18723
+ const data = row.data;
18724
+ const from = mediaRowLocation(data);
18725
+ if (from === job.toLocationId) continue;
18726
+ const relativePath = String(data["path"] ?? "");
18727
+ if (relativePath.length === 0) {
18728
+ failedRows++;
18729
+ continue;
18730
+ }
18731
+ try {
18732
+ if (await this.samePhysicalLocation(from, job.toLocationId)) {
18733
+ await this.deps.store.set.mutate({
18734
+ collection: RETRAIN_FRAMES_COLLECTION,
18735
+ key: row.id,
18736
+ value: {
18737
+ ...data,
18738
+ locationId: job.toLocationId
18739
+ }
18740
+ });
18741
+ continue;
18742
+ }
18743
+ const bytes = await this.deps.storage.read({
18744
+ location: from,
18745
+ relativePath
18746
+ });
18747
+ await this.deps.storage.write({
18748
+ location: job.toLocationId,
18749
+ relativePath,
18750
+ data: bytes
18751
+ });
18752
+ if ((await this.deps.storage.read({
18753
+ location: job.toLocationId,
18754
+ relativePath
18755
+ })).length !== bytes.length) throw new Error(`relocated retrain frame size mismatch for ${relativePath}`);
18756
+ await this.deps.store.set.mutate({
18757
+ collection: RETRAIN_FRAMES_COLLECTION,
18758
+ key: row.id,
18759
+ value: {
18760
+ ...data,
18761
+ locationId: job.toLocationId
18762
+ }
18763
+ });
18764
+ await this.deps.storage.delete({
18765
+ location: from,
18766
+ relativePath
18767
+ });
18768
+ job.filesMoved++;
18769
+ job.bytesMoved += bytes.length;
18770
+ await sleep(bytes.length / bytesPerMs);
18771
+ } catch (err) {
18772
+ failedRows++;
18773
+ this.deps.logger.debug("retrain frame relocate row failed", { meta: {
18774
+ key: row.id,
18775
+ error: String(err)
18776
+ } });
18777
+ }
18778
+ }
18779
+ retrainOffset += rows.length;
18780
+ if (rows.length < PAGE_SIZE) break;
18781
+ }
18782
+ if (failedRows > 0) throw new Error(`event-media relocation left ${failedRows} row(s) unmoved`);
18607
18783
  job.state = job.cancelRequested ? "cancelled" : "done";
18608
18784
  } catch (err) {
18609
18785
  job.state = "failed";
@@ -18617,6 +18793,18 @@ var MediaRelocateEngine = class {
18617
18793
  this.deps.onFinished?.(snapshot(job));
18618
18794
  }
18619
18795
  }
18796
+ async samePhysicalLocation(from, to) {
18797
+ if (from === to) return true;
18798
+ try {
18799
+ const [fromRoot, toRoot] = await Promise.all([this.deps.resolveTargetRoot(from), this.deps.resolveTargetRoot(to)]);
18800
+ const [fromStat, toStat] = await Promise.all([promises.stat(fromRoot), promises.stat(toRoot)]);
18801
+ if (fromStat.dev === toStat.dev && fromStat.ino === toStat.ino) return true;
18802
+ const [fromReal, toReal] = await Promise.all([promises.realpath(path.resolve(fromRoot)), promises.realpath(path.resolve(toRoot))]);
18803
+ return fromReal === toReal;
18804
+ } catch {
18805
+ return false;
18806
+ }
18807
+ }
18620
18808
  };
18621
18809
  //#endregion
18622
18810
  //#region src/pipeline-analytics/media-settings.ts
@@ -31169,6 +31357,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31169
31357
  eventsOpsLog = null;
31170
31358
  /** Event-media relocation engine (entity-routing Phase 4). */
31171
31359
  mediaRelocate = null;
31360
+ mediaLocationStorage = null;
31361
+ mediaWriteGate = new MediaWriteGate();
31362
+ storageMigrationLeaseId = null;
31172
31363
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
31173
31364
  sensorEventStore = null;
31174
31365
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
@@ -31969,7 +32160,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31969
32160
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
31970
32161
  }
31971
32162
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
31972
- return createLocationAwareMediaStorage({
32163
+ const routed = createLocationAwareMediaStorage({
31973
32164
  base: storage,
31974
32165
  defaultLocation: "eventMedia",
31975
32166
  resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
@@ -31977,6 +32168,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31977
32168
  relativePath: ""
31978
32169
  })
31979
32170
  });
32171
+ this.mediaLocationStorage = routed;
32172
+ return routed;
31980
32173
  }
31981
32174
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
31982
32175
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
@@ -32021,7 +32214,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
32021
32214
  const mediaStore = new MediaStore({
32022
32215
  storage,
32023
32216
  store: api.settingsStore,
32024
- logger: logger.child("MediaStore")
32217
+ logger: logger.child("MediaStore"),
32218
+ writeGate: this.mediaWriteGate
32025
32219
  });
32026
32220
  this.mediaStore = mediaStore;
32027
32221
  const retrainFrames = new RetrainFrameStore({
@@ -32029,6 +32223,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
32029
32223
  storage,
32030
32224
  logger: logger.child("RetrainFrames"),
32031
32225
  now: () => Date.now(),
32226
+ writeGate: this.mediaWriteGate,
32032
32227
  readSource: async (mediaKey) => {
32033
32228
  const file = await mediaStore.getByKey(mediaKey);
32034
32229
  return file === null ? null : Buffer.from(file.base64, "base64");
@@ -36270,14 +36465,38 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
36270
36465
  return this.queryFacade.deleteDeviceEvents(input);
36271
36466
  }
36272
36467
  /** The events ops-log rows (newest-first), optionally scoped to one camera. */
36273
- async relocateMedia(input) {
36468
+ async pauseForStorageMigration(input) {
36469
+ if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media writes are already paused by another storage migration");
36470
+ this.storageMigrationLeaseId = input.leaseId;
36471
+ try {
36472
+ await this.mediaWriteGate.pause();
36473
+ return { paused: true };
36474
+ } catch (err) {
36475
+ this.storageMigrationLeaseId = null;
36476
+ throw err;
36477
+ }
36478
+ }
36479
+ async resumeForStorageMigration(input) {
36480
+ if (this.storageMigrationLeaseId === null) return { resumed: true };
36481
+ if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
36482
+ this.mediaWriteGate.resume();
36483
+ this.storageMigrationLeaseId = null;
36484
+ return { resumed: true };
36485
+ }
36486
+ async refreshStorageLocationsForMigration(input) {
36487
+ if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
36488
+ this.mediaLocationStorage?.invalidateRoots();
36489
+ return { refreshed: true };
36490
+ }
36491
+ async startStorageMigrationMove(input) {
36274
36492
  if (!this.mediaRelocate) throw new Error("media relocation unavailable");
36493
+ if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
36275
36494
  return { jobId: this.mediaRelocate.start(input) };
36276
36495
  }
36277
- async getMediaRelocateStatus() {
36278
- return this.mediaRelocate?.list() ?? [];
36496
+ async getStorageMigrationMoveStatus(input) {
36497
+ return this.mediaRelocate?.list().find((job) => job.jobId === input.jobId) ?? null;
36279
36498
  }
36280
- async cancelMediaRelocate(input) {
36499
+ async cancelStorageMigrationMove(input) {
36281
36500
  return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
36282
36501
  }
36283
36502
  async listOpsLog(input) {
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DrgptocJ.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-lSMqbbY-.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.63",
3
+ "version": "1.2.64",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -99,7 +99,7 @@
99
99
  {
100
100
  "id": "eventMedia",
101
101
  "displayName": "Event Media",
102
- "cardinality": "single",
102
+ "cardinality": "multi",
103
103
  "description": "Detection snapshots, thumbnails and crops.",
104
104
  "defaultsTo": "recordings"
105
105
  }