@camstack/addon-post-analysis 1.2.206 → 1.2.208

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,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-sREZj0iq.js");
5
+ const require_dist = require("../dist-CoLA3NHh.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -27594,10 +27594,63 @@ function squareSubjectCropRegion(bbox, frame) {
27594
27594
  h: Math.round(side)
27595
27595
  };
27596
27596
  }
27597
- function wideCentralSquareLayout(bbox, frame, options = {}) {
27598
- const central = squareSubjectCropRegion(bbox, frame);
27597
+ /**
27598
+ * How much of the composed picture may be synthesized fill before the canvas
27599
+ * NARROWS instead (operator directive, 2026-09-08, track a46599d5 device 3829).
27600
+ *
27601
+ * A subject flush against a frame edge for its whole life has no containable
27602
+ * frame, so `containSubject` keeps the middle square on it and pays the
27603
+ * overhang in lateral fill. On that car the bill was 225 px — 21.9% of the
27604
+ * picture, and the operator read the blurred stretch as a mirrored duplicate of
27605
+ * the scene.
27606
+ *
27607
+ * Rather than choose between an uncontained subject and a quarter of ambience,
27608
+ * the canvas gives up WIDTH: it narrows towards its own central square until
27609
+ * the fill is at most this fraction. Aspect then lands between 1:1 and 16:9 —
27610
+ * the square is never crossed, because the square is the region that is
27611
+ * guaranteed to hold the subject.
27612
+ *
27613
+ * The cost is real and it is lateral CONTEXT: a wide surface cover-fitting a
27614
+ * near-square picture crops top and bottom. That is why this is a cap and not a
27615
+ * rule — everything that already composes with a small fill keeps its 16:9.
27616
+ */
27617
+ var MAX_LETTERBOX_FRACTION = .1;
27618
+ /**
27619
+ * The 16:9 width, narrowed just enough to hold the fill under
27620
+ * {@link MAX_LETTERBOX_FRACTION}.
27621
+ *
27622
+ * The canvas is CENTRED on the square, so each side wants `(canvasW - c) / 2`
27623
+ * and gets whatever real frame lies there; the rest is fill. Solving
27624
+ * `(w - c)/2 - available <= cap * w` for `w` gives the widest canvas that
27625
+ * respects the cap on that side, and the binding side is the narrower answer.
27626
+ *
27627
+ * It is computed from the space available BEFORE the slide, which can only
27628
+ * over-estimate the fill: the slide moves the window towards real scene and
27629
+ * never away from it. So a layout that the slide would have rescued anyway may
27630
+ * come out a few percent narrower than strictly necessary — a smaller error
27631
+ * than the band it removes, and in the other direction.
27632
+ *
27633
+ * Never narrower than the square itself: below that the picture would cut the
27634
+ * one region the layout guarantees contains the subject.
27635
+ */
27636
+ function cappedCanvasWidth(c, centralX, frameW) {
27637
+ const full = Math.round(c * 16 / 9);
27638
+ const denominator = .5 - MAX_LETTERBOX_FRACTION;
27639
+ const widest = (available) => (c / 2 + Math.max(0, available)) / denominator;
27640
+ const bound = Math.min(widest(centralX), widest(frameW - (centralX + c)));
27641
+ if (!Number.isFinite(bound) || bound >= full) return full;
27642
+ return Math.max(c, Math.round(bound));
27643
+ }
27644
+ /**
27645
+ * The layout at a GIVEN canvas width. `wideCentralSquareLayout` runs it twice:
27646
+ * once at the full 16:9 width, and — only when that leaves more fill than
27647
+ * {@link MAX_LETTERBOX_FRACTION} — once at the narrowed width. Two passes
27648
+ * rather than one predictive formula because the slide's result is what
27649
+ * decides, and computing the fill after the slide is exact where predicting it
27650
+ * is not.
27651
+ */
27652
+ function layoutAtWidth(bbox, frame, options, central, canvasW) {
27599
27653
  const c = central.w;
27600
- const canvasW = Math.round(c * 16 / 9);
27601
27654
  const centralX0 = Math.round((canvasW - c) / 2);
27602
27655
  const anchorX = central.x - (canvasW - c) / 2;
27603
27656
  let frameOriginX = anchorX;
@@ -27635,6 +27688,21 @@ function wideCentralSquareLayout(bbox, frame, options = {}) {
27635
27688
  frameOriginX
27636
27689
  };
27637
27690
  }
27691
+ /** Synthesized fill as a fraction of the composed picture. */
27692
+ function letterboxFraction(layout, frame) {
27693
+ const left = Math.max(0, -layout.frameOriginX);
27694
+ const right = Math.max(0, layout.frameOriginX + layout.canvasW - frame.W);
27695
+ return layout.canvasW <= 0 ? 0 : Math.max(left, right) / layout.canvasW;
27696
+ }
27697
+ function wideCentralSquareLayout(bbox, frame, options = {}) {
27698
+ const central = squareSubjectCropRegion(bbox, frame);
27699
+ const c = central.w;
27700
+ const full = Math.round(c * 16 / 9);
27701
+ const wide = layoutAtWidth(bbox, frame, options, central, full);
27702
+ if (full > frame.W || letterboxFraction(wide, frame) <= .1) return wide;
27703
+ const narrowed = cappedCanvasWidth(c, central.x, frame.W);
27704
+ return narrowed >= full ? wide : layoutAtWidth(bbox, frame, options, central, narrowed);
27705
+ }
27638
27706
  /**
27639
27707
  * Fraction of the subject bbox's area that lands inside the CENTRAL SQUARE a
27640
27708
  * square consumer center-crops from the composed 16:9 canvas — i.e. how much
@@ -28578,11 +28646,37 @@ var MediaStore = class {
28578
28646
  timestamp,
28579
28647
  deviceId: Number(data["deviceId"])
28580
28648
  };
28581
- } catch {
28649
+ } catch (err) {
28650
+ this.logger.warn("media read failed — row kept, blob not served", {
28651
+ tags: { deviceId: Number(data["deviceId"]) },
28652
+ meta: {
28653
+ key,
28654
+ path,
28655
+ location: mediaRowLocation(data),
28656
+ error: String(err)
28657
+ }
28658
+ });
28582
28659
  return null;
28583
28660
  }
28584
28661
  }
28585
28662
  /**
28663
+ * Can this row's blob be SERVED? The question `getByKey` answers in bytes,
28664
+ * asked without fetching any.
28665
+ *
28666
+ * False covers both permanent causes — no row, and a row whose blob cannot be
28667
+ * reached (deleted, or on a storage location the row names and the cluster no
28668
+ * longer has). They are one answer here because they are one answer to the
28669
+ * viewer: the tile is blank. Which of the two it is belongs in the log line
28670
+ * above, not in a boolean.
28671
+ */
28672
+ async blobServable(key) {
28673
+ try {
28674
+ return await this.referencedBlobState(key) === "exists";
28675
+ } catch {
28676
+ return false;
28677
+ }
28678
+ }
28679
+ /**
28586
28680
  * Delete a single media entry by its key (id).
28587
28681
  * Deletes the blob from storage (best-effort) and removes the index row.
28588
28682
  */
@@ -28780,11 +28874,15 @@ var MediaStore = class {
28780
28874
  ...provenance !== void 0 ? { provenance } : {}
28781
28875
  });
28782
28876
  } catch (err) {
28783
- this.logger.debug("media read failed — row kept but blob missing", { meta: {
28784
- key: row.id,
28785
- path,
28786
- error: String(err)
28787
- } });
28877
+ this.logger.warn("media read failed — row kept, blob not served", {
28878
+ tags: { deviceId: Number(data["deviceId"]) },
28879
+ meta: {
28880
+ key: row.id,
28881
+ path,
28882
+ location: mediaRowLocation(data),
28883
+ error: String(err)
28884
+ }
28885
+ });
28788
28886
  }
28789
28887
  }
28790
28888
  return files;
@@ -30417,6 +30515,8 @@ function analyseDebugTrack(inventory) {
30417
30515
  const durationMs = trackDurationMs(facts);
30418
30516
  const closed = !facts.active;
30419
30517
  const out = [];
30518
+ const unreadable = inventory.unreadableMedia ?? [];
30519
+ if (unreadable.length > 0) out.push(finding("MEDIA_ROW_WITHOUT_BLOB", "error", `${unreadable.length} of ${media.length} media rows exist in the index but their bytes could not be read — the tile renders blank and every count taken from the index overstates what is there. The blob is missing, or its row names a storage location that no longer resolves.`));
30420
30520
  if (media.length === 0) out.push(finding("MEDIA_EVICTED_OR_NEVER", "error", "No media at all — either nothing was captured, or it was evicted (a debug mark does not pin retention)."));
30421
30521
  else if (closed) {
30422
30522
  const keyFrameMissing = !hasKind(media, "keyFrame");
@@ -30596,12 +30696,15 @@ async function gatherTrack(deps, track) {
30596
30696
  for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
30597
30697
  const events = await deps.listEvents(track.trackId);
30598
30698
  const thumbnailProvenance = await deps.trackThumbnailProvenance(track.trackId);
30699
+ const unreadableMedia = [];
30700
+ for (const row of media) if (!await deps.probeMediaBlob(row.key)) unreadableMedia.push(row.key);
30599
30701
  return {
30600
30702
  inventory: {
30601
30703
  facts: toFacts(track),
30602
30704
  media,
30603
30705
  eventCount: events.length,
30604
- thumbnailProvenance
30706
+ thumbnailProvenance,
30707
+ unreadableMedia
30605
30708
  },
30606
30709
  events
30607
30710
  };
@@ -32637,6 +32740,130 @@ var FaceStore = class {
32637
32740
  }
32638
32741
  };
32639
32742
  //#endregion
32743
+ //#region src/pipeline-analytics/identity-audit.ts
32744
+ /**
32745
+ * identity-audit — which of a person's ENROLLED samples do not look like the
32746
+ * rest of that person.
32747
+ *
32748
+ * ## Why this exists
32749
+ *
32750
+ * The gallery is the one input to recognition that nobody re-reads. A sample
32751
+ * enrolled from the wrong face keeps voting forever: it pulls the wrong name
32752
+ * onto every future match near it, and because auto-assignment then looks
32753
+ * confident, nobody goes back to check. The 2026-09-08 sweep found exactly one
32754
+ * such pair on this cluster — a sample under one person scoring 0.61 against
32755
+ * three samples of another, enrolled from the same camera in the same minute,
32756
+ * where every other cross-identity pair on the cluster sits under 0.50.
32757
+ *
32758
+ * Finding that took a script and a day. This module makes it a question the
32759
+ * operator can ask per person, from the Review tab, and act on.
32760
+ *
32761
+ * ## What it measures
32762
+ *
32763
+ * Two numbers per sample, and the second is the one that convicts:
32764
+ *
32765
+ * - `selfMean` — mean cosine against the person's OTHER samples. Low means
32766
+ * "this does not look like the rest of you", which is suspicious but can
32767
+ * also be a legitimate hard angle, bad light, a mask.
32768
+ * - `bestForeign` — the highest cosine against any OTHER person's samples,
32769
+ * with that person named. High means "this looks like someone we can name",
32770
+ * which is what a mis-enrolment actually looks like.
32771
+ *
32772
+ * `suspicion` combines them so a sample that looks like somebody else outranks
32773
+ * one that merely looks like nobody: the first is a fixable error, the second
32774
+ * is often just a difficult photograph.
32775
+ *
32776
+ * ## What it refuses to do
32777
+ *
32778
+ * A cosine between vectors from two different face models is a well-formed
32779
+ * float with no meaning — `face-matcher` refuses the comparison and so does
32780
+ * this (`comparable: false`). It must read as "cannot say", never as an
32781
+ * accusation and never as a clean bill: this list ends in a DELETE button.
32782
+ *
32783
+ * A person with one sample is not judged either. One sample has nothing to
32784
+ * disagree with, and the only thing an outlier score could do there is delete
32785
+ * the only sample a new person has.
32786
+ *
32787
+ * Pure — the caller loads the vectors and performs the removal.
32788
+ */
32789
+ /** Above this a row is worth the operator's attention. */
32790
+ var SUSPICION_FLAG = .5;
32791
+ /**
32792
+ * How far a foreign match has to stand OUT to count as looking like someone
32793
+ * else: `bestForeign - selfMean`. A sample that scores 0.6 against a stranger
32794
+ * and 0.7 against its own person is not a mis-enrolment.
32795
+ */
32796
+ function foreignExcess(selfMean, foreign) {
32797
+ if (foreign === void 0) return 0;
32798
+ if (selfMean === null) return 0;
32799
+ return Math.max(0, foreign.score - selfMean);
32800
+ }
32801
+ function meanCosine(target, pool) {
32802
+ let sum = 0;
32803
+ let n = 0;
32804
+ for (const other of pool) {
32805
+ if (other.sampleId === target.sampleId) continue;
32806
+ sum += cosineSimilarity(target.embedding, other.embedding);
32807
+ n += 1;
32808
+ }
32809
+ return n === 0 ? null : sum / n;
32810
+ }
32811
+ function bestForeignMatch(target, others, modelId) {
32812
+ let best;
32813
+ for (const identity of others) for (const sample of identity.samples) {
32814
+ if (sample.modelId !== modelId) continue;
32815
+ const score = cosineSimilarity(target.embedding, sample.embedding);
32816
+ if (best === void 0 || score > best.score) best = {
32817
+ identityId: identity.identityId,
32818
+ name: identity.name,
32819
+ score
32820
+ };
32821
+ }
32822
+ return best;
32823
+ }
32824
+ /**
32825
+ * Rank one person's samples, worst first.
32826
+ *
32827
+ * `suspicion` is `(1 - selfMean) / 2` — how far the sample sits from its own
32828
+ * person, on [0,1] — plus the foreign EXCESS, which dominates when a sample
32829
+ * genuinely resembles somebody the gallery can name. A sample that agrees with
32830
+ * its person scores near zero on both terms.
32831
+ */
32832
+ function auditIdentitySamples(input) {
32833
+ const comparable = input.samples.filter((s) => s.modelId === input.modelId);
32834
+ const rows = input.samples.map((sample) => {
32835
+ if (sample.modelId !== input.modelId) return {
32836
+ sampleId: sample.sampleId,
32837
+ enrolledAt: sample.enrolledAt,
32838
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId },
32839
+ comparable: false,
32840
+ selfMean: null,
32841
+ suspicion: 0
32842
+ };
32843
+ const selfMean = meanCosine(sample, comparable);
32844
+ const foreign = bestForeignMatch(sample, input.others, input.modelId);
32845
+ const excess = foreignExcess(selfMean, foreign);
32846
+ const suspicion = selfMean === null ? 0 : (1 - selfMean) / 2 + excess;
32847
+ return {
32848
+ sampleId: sample.sampleId,
32849
+ enrolledAt: sample.enrolledAt,
32850
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId },
32851
+ comparable: true,
32852
+ selfMean,
32853
+ ...foreign === void 0 ? {} : { bestForeign: foreign },
32854
+ suspicion
32855
+ };
32856
+ });
32857
+ rows.sort((a, b) => b.suspicion - a.suspicion);
32858
+ return {
32859
+ identityId: input.identityId,
32860
+ totalSamples: input.samples.length,
32861
+ comparableSamples: comparable.length,
32862
+ flagged: rows.filter((r) => r.suspicion >= SUSPICION_FLAG).length,
32863
+ rows
32864
+ };
32865
+ }
32866
+ //#endregion
32640
32867
  //#region src/pipeline-analytics/face-gallery-provider.ts
32641
32868
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
32642
32869
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -32688,6 +32915,7 @@ var FaceGalleryProvider = class {
32688
32915
  eventStore;
32689
32916
  logger;
32690
32917
  refreshGallery;
32918
+ resolveClusterModelId;
32691
32919
  eventMediaBaseUrl;
32692
32920
  eventMediaPathPrefix;
32693
32921
  emitFaceGalleryChanged;
@@ -32699,6 +32927,7 @@ var FaceGalleryProvider = class {
32699
32927
  this.eventStore = deps.eventStore;
32700
32928
  this.logger = deps.logger;
32701
32929
  this.refreshGallery = deps.refreshGallery;
32930
+ this.resolveClusterModelId = deps.resolveClusterModelId;
32702
32931
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
32703
32932
  this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
32704
32933
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
@@ -32775,6 +33004,67 @@ var FaceGalleryProvider = class {
32775
33004
  }
32776
33005
  return result;
32777
33006
  }
33007
+ /**
33008
+ * Re-analyse one person's enrolled samples against the whole gallery.
33009
+ *
33010
+ * Reads every identity's samples and their vectors — 327 on this cluster —
33011
+ * and ranks the target's own by how little they look like the rest of that
33012
+ * person and how much they look like somebody else (`identity-audit.ts`).
33013
+ * Dot products, no inference, no write.
33014
+ *
33015
+ * The whole gallery is loaded on purpose: the accusation that matters is
33016
+ * "this looks like SOMEONE ELSE", and that cannot be computed from one
33017
+ * person's samples.
33018
+ */
33019
+ async auditIdentitySamples(input) {
33020
+ const modelId = await this.resolveClusterModelId();
33021
+ if (modelId === null) throw new Error("auditIdentitySamples: the cluster face-embedding model could not be resolved");
33022
+ const identities = await this.identityStore.listIdentities();
33023
+ const target = identities.find((i) => i.id === input.identityId);
33024
+ if (target === void 0) throw new Error(`auditIdentitySamples: no identity ${input.identityId}`);
33025
+ const samplesByIdentity = /* @__PURE__ */ new Map();
33026
+ for (const identity of identities) samplesByIdentity.set(identity.id, await this.identityStore.listSamples(identity.id));
33027
+ const vectors = await this.identityStore.loadSampleVectors([...samplesByIdentity.values()].flat().map((sample) => sample.id));
33028
+ const inputsFor = (identityId) => (samplesByIdentity.get(identityId) ?? []).flatMap((sample) => {
33029
+ const embedding = vectors.get(sample.id);
33030
+ if (embedding === void 0) return [];
33031
+ return [{
33032
+ sampleId: sample.id,
33033
+ embedding,
33034
+ modelId: sample.modelId,
33035
+ enrolledAt: sample.addedAt,
33036
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId }
33037
+ }];
33038
+ });
33039
+ const report = auditIdentitySamples({
33040
+ identityId: input.identityId,
33041
+ samples: inputsFor(input.identityId),
33042
+ others: identities.filter((identity) => identity.id !== input.identityId).map((identity) => ({
33043
+ identityId: identity.id,
33044
+ name: identity.name,
33045
+ samples: inputsFor(identity.id)
33046
+ })),
33047
+ modelId
33048
+ });
33049
+ const meta = new Map((samplesByIdentity.get(input.identityId) ?? []).map((sample) => [sample.id, sample]));
33050
+ return {
33051
+ identityId: report.identityId,
33052
+ name: target.name,
33053
+ modelId,
33054
+ totalSamples: report.totalSamples,
33055
+ comparableSamples: report.comparableSamples,
33056
+ flagged: report.flagged,
33057
+ flagThreshold: SUSPICION_FLAG,
33058
+ rows: report.rows.map((row) => {
33059
+ const sample = meta.get(row.sampleId);
33060
+ return {
33061
+ ...row,
33062
+ source: sample?.source ?? "detected",
33063
+ ...sample?.mediaKey === void 0 ? {} : { mediaKey: sample.mediaKey }
33064
+ };
33065
+ })
33066
+ };
33067
+ }
32778
33068
  async removeSample(input) {
32779
33069
  const sample = (await this.identityStore.listSamples(input.identityId)).find((s) => s.id === input.sampleId);
32780
33070
  if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
@@ -37403,7 +37693,7 @@ var TrackCloser = class {
37403
37693
  async maybePromoteLastFrame(t, ownedMedia) {
37404
37694
  const promotion = decideLastFramePromotion(ownedMedia);
37405
37695
  if (!promotion.promote) {
37406
- if (promotion.declinedBecause !== "already-last") this.deps.logger.warn("no lastFrame at close", {
37696
+ if (promotion.declinedBecause === "no-key-frame") this.deps.logger.warn("no lastFrame at close", {
37407
37697
  tags: { deviceId: t.deviceId },
37408
37698
  meta: {
37409
37699
  trackId: t.trackId,
@@ -59023,12 +59313,24 @@ var IdentityStore = class {
59023
59313
  id: r.id,
59024
59314
  source: d.source ?? "detected",
59025
59315
  addedAt: Number(d.addedAt ?? 0),
59316
+ modelId: String(d.modelId ?? ""),
59026
59317
  ...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
59027
59318
  ...d.sourceMediaKey != null ? { sourceMediaKey: d.sourceMediaKey } : {},
59028
59319
  ...d.deviceId != null ? { deviceId: d.deviceId } : {}
59029
59320
  };
59030
59321
  });
59031
59322
  }
59323
+ /**
59324
+ * The enrolled vectors for these sample ids, straight off the index.
59325
+ *
59326
+ * Exposed because the gallery audit compares samples with each other, and
59327
+ * `loadGallery` — the only other vector read — drops the sample id in favour
59328
+ * of the identity id. An audit that cannot name the sample it accuses has
59329
+ * nothing to offer a Remove button.
59330
+ */
59331
+ async loadSampleVectors(sampleIds) {
59332
+ return this.vectors.load(sampleIds);
59333
+ }
59032
59334
  async removeSample(identityId, sampleId) {
59033
59335
  await this.store.delete.mutate({
59034
59336
  collection: IDENTITY_SAMPLES_COLLECTION,
@@ -70940,9 +71242,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70940
71242
  /**
70941
71243
  * The store seam the `debug.*` actions and the debug bundle both read through.
70942
71244
  *
70943
- * INDEX READS ONLY three media-inventory queries and an event query per
70944
- * track, no blob. The bytes are fetched one at a time, by key, by the export
70945
- * handler, so a bundle of a hundred tracks never holds more than one file.
71245
+ * Index reads, plus ONE existence probe per media row never a blob. The
71246
+ * probe is the only thing here that touches the filesystem, and it earns the
71247
+ * trip: every index read in this file counts rows the two serving reads
71248
+ * (`listByOwner`, `getByKey`) silently drop when the bytes are unreachable,
71249
+ * so without it the tab reports tiles the viewer draws blank. The bytes
71250
+ * themselves are still fetched one at a time, by key, by the export handler,
71251
+ * so a bundle of a hundred tracks never holds more than one file.
70946
71252
  */
70947
71253
  buildDebugServiceDeps() {
70948
71254
  const trackStore = this.trackStore;
@@ -70956,6 +71262,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70956
71262
  listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
70957
71263
  listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
70958
71264
  trackThumbnailProvenance: (trackId) => mediaStore.trackThumbnailProvenance(trackId),
71265
+ probeMediaBlob: (key) => mediaStore.blobServable(key),
70959
71266
  logger: this.ctx.logger.child("debug"),
70960
71267
  now: () => Date.now()
70961
71268
  };
@@ -71657,6 +71964,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71657
71964
  eventStore: stores.eventStore,
71658
71965
  logger: this.ctx.logger,
71659
71966
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
71967
+ resolveClusterModelId: async () => {
71968
+ const api = this.ctx.api;
71969
+ if (!api) return null;
71970
+ return resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
71971
+ },
71660
71972
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
71661
71973
  eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
71662
71974
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)