@camstack/addon-post-analysis 1.2.207 → 1.2.209

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-BttKNVmP.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");
@@ -7520,7 +7520,8 @@ function cooldownKey(rule, subject) {
7520
7520
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
7521
7521
  const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
7522
7522
  const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
7523
- return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
7523
+ const phaseKey = subject.packagePhase !== void 0 ? `:p:${subject.packagePhase}` : "";
7524
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${phaseKey}${classKey}` : `r:${rule.id}:${scopeRef}${phaseKey}${classKey}`;
7524
7525
  }
7525
7526
  /** True when the rule fired within its cooldown window before `now`. */
7526
7527
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -9957,6 +9958,10 @@ var en_default = {
9957
9958
  "one": "{{count}} other {{classNoun}}",
9958
9959
  "other": "{{count}} other {{classNoun}}"
9959
9960
  },
9961
+ "package.delivered.title": "Parcel delivered",
9962
+ "package.delivered.body": "Delivered at {{camera}}{{inZones}}",
9963
+ "package.picked-up.title": "Parcel collected",
9964
+ "package.picked-up.body": "Collected from {{camera}}{{inZones}}",
9960
9965
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
9961
9966
  "occupancy.op.occupied": "occupied",
9962
9967
  "occupancy.op.free": "free",
@@ -10208,6 +10213,10 @@ var it_default = {
10208
10213
  "one": "{{count}} altro {{classNoun}}",
10209
10214
  "other": "{{count}} altri {{classNoun}}"
10210
10215
  },
10216
+ "package.delivered.title": "Pacco consegnato",
10217
+ "package.delivered.body": "Consegnato su {{camera}}{{inZones}}",
10218
+ "package.picked-up.title": "Pacco ritirato",
10219
+ "package.picked-up.body": "Ritirato da {{camera}}{{inZones}}",
10211
10220
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
10212
10221
  "occupancy.op.occupied": "occupata",
10213
10222
  "occupancy.op.free": "libera",
@@ -12050,6 +12059,7 @@ function bodyKeyFor(entry) {
12050
12059
  const subject = entry.payload.subject;
12051
12060
  if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
12052
12061
  if (subject.occupancy !== void 0) return "occupancy.body";
12062
+ if (subject.packagePhase !== void 0) return `package.${subject.packagePhase}.body`;
12053
12063
  if (entry.payload.enhancement === true) return "detection.body.enhanced";
12054
12064
  return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
12055
12065
  }
@@ -12057,6 +12067,8 @@ function bodyKeyFor(entry) {
12057
12067
  function titleKeyFor(entry) {
12058
12068
  const systemEvent = entry.payload.subject.systemEvent;
12059
12069
  if (systemEvent !== void 0) return systemEvent.titleKey ?? "";
12070
+ const packagePhase = entry.payload.subject.packagePhase;
12071
+ if (packagePhase !== void 0) return `package.${packagePhase}.title`;
12060
12072
  return "detection.title";
12061
12073
  }
12062
12074
  /**
@@ -24856,6 +24868,7 @@ var NotificationCenter = class NotificationCenter {
24856
24868
  zones: renderedZones,
24857
24869
  timestamp: subject.timestamp,
24858
24870
  ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
24871
+ ...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
24859
24872
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
24860
24873
  }
24861
24874
  };
@@ -27594,10 +27607,63 @@ function squareSubjectCropRegion(bbox, frame) {
27594
27607
  h: Math.round(side)
27595
27608
  };
27596
27609
  }
27597
- function wideCentralSquareLayout(bbox, frame, options = {}) {
27598
- const central = squareSubjectCropRegion(bbox, frame);
27610
+ /**
27611
+ * How much of the composed picture may be synthesized fill before the canvas
27612
+ * NARROWS instead (operator directive, 2026-09-08, track a46599d5 device 3829).
27613
+ *
27614
+ * A subject flush against a frame edge for its whole life has no containable
27615
+ * frame, so `containSubject` keeps the middle square on it and pays the
27616
+ * overhang in lateral fill. On that car the bill was 225 px — 21.9% of the
27617
+ * picture, and the operator read the blurred stretch as a mirrored duplicate of
27618
+ * the scene.
27619
+ *
27620
+ * Rather than choose between an uncontained subject and a quarter of ambience,
27621
+ * the canvas gives up WIDTH: it narrows towards its own central square until
27622
+ * the fill is at most this fraction. Aspect then lands between 1:1 and 16:9 —
27623
+ * the square is never crossed, because the square is the region that is
27624
+ * guaranteed to hold the subject.
27625
+ *
27626
+ * The cost is real and it is lateral CONTEXT: a wide surface cover-fitting a
27627
+ * near-square picture crops top and bottom. That is why this is a cap and not a
27628
+ * rule — everything that already composes with a small fill keeps its 16:9.
27629
+ */
27630
+ var MAX_LETTERBOX_FRACTION = .1;
27631
+ /**
27632
+ * The 16:9 width, narrowed just enough to hold the fill under
27633
+ * {@link MAX_LETTERBOX_FRACTION}.
27634
+ *
27635
+ * The canvas is CENTRED on the square, so each side wants `(canvasW - c) / 2`
27636
+ * and gets whatever real frame lies there; the rest is fill. Solving
27637
+ * `(w - c)/2 - available <= cap * w` for `w` gives the widest canvas that
27638
+ * respects the cap on that side, and the binding side is the narrower answer.
27639
+ *
27640
+ * It is computed from the space available BEFORE the slide, which can only
27641
+ * over-estimate the fill: the slide moves the window towards real scene and
27642
+ * never away from it. So a layout that the slide would have rescued anyway may
27643
+ * come out a few percent narrower than strictly necessary — a smaller error
27644
+ * than the band it removes, and in the other direction.
27645
+ *
27646
+ * Never narrower than the square itself: below that the picture would cut the
27647
+ * one region the layout guarantees contains the subject.
27648
+ */
27649
+ function cappedCanvasWidth(c, centralX, frameW) {
27650
+ const full = Math.round(c * 16 / 9);
27651
+ const denominator = .5 - MAX_LETTERBOX_FRACTION;
27652
+ const widest = (available) => (c / 2 + Math.max(0, available)) / denominator;
27653
+ const bound = Math.min(widest(centralX), widest(frameW - (centralX + c)));
27654
+ if (!Number.isFinite(bound) || bound >= full) return full;
27655
+ return Math.max(c, Math.round(bound));
27656
+ }
27657
+ /**
27658
+ * The layout at a GIVEN canvas width. `wideCentralSquareLayout` runs it twice:
27659
+ * once at the full 16:9 width, and — only when that leaves more fill than
27660
+ * {@link MAX_LETTERBOX_FRACTION} — once at the narrowed width. Two passes
27661
+ * rather than one predictive formula because the slide's result is what
27662
+ * decides, and computing the fill after the slide is exact where predicting it
27663
+ * is not.
27664
+ */
27665
+ function layoutAtWidth(bbox, frame, options, central, canvasW) {
27599
27666
  const c = central.w;
27600
- const canvasW = Math.round(c * 16 / 9);
27601
27667
  const centralX0 = Math.round((canvasW - c) / 2);
27602
27668
  const anchorX = central.x - (canvasW - c) / 2;
27603
27669
  let frameOriginX = anchorX;
@@ -27635,6 +27701,21 @@ function wideCentralSquareLayout(bbox, frame, options = {}) {
27635
27701
  frameOriginX
27636
27702
  };
27637
27703
  }
27704
+ /** Synthesized fill as a fraction of the composed picture. */
27705
+ function letterboxFraction(layout, frame) {
27706
+ const left = Math.max(0, -layout.frameOriginX);
27707
+ const right = Math.max(0, layout.frameOriginX + layout.canvasW - frame.W);
27708
+ return layout.canvasW <= 0 ? 0 : Math.max(left, right) / layout.canvasW;
27709
+ }
27710
+ function wideCentralSquareLayout(bbox, frame, options = {}) {
27711
+ const central = squareSubjectCropRegion(bbox, frame);
27712
+ const c = central.w;
27713
+ const full = Math.round(c * 16 / 9);
27714
+ const wide = layoutAtWidth(bbox, frame, options, central, full);
27715
+ if (full > frame.W || letterboxFraction(wide, frame) <= .1) return wide;
27716
+ const narrowed = cappedCanvasWidth(c, central.x, frame.W);
27717
+ return narrowed >= full ? wide : layoutAtWidth(bbox, frame, options, central, narrowed);
27718
+ }
27638
27719
  /**
27639
27720
  * Fraction of the subject bbox's area that lands inside the CENTRAL SQUARE a
27640
27721
  * square consumer center-crops from the composed 16:9 canvas — i.e. how much
@@ -28578,11 +28659,37 @@ var MediaStore = class {
28578
28659
  timestamp,
28579
28660
  deviceId: Number(data["deviceId"])
28580
28661
  };
28581
- } catch {
28662
+ } catch (err) {
28663
+ this.logger.warn("media read failed — row kept, blob not served", {
28664
+ tags: { deviceId: Number(data["deviceId"]) },
28665
+ meta: {
28666
+ key,
28667
+ path,
28668
+ location: mediaRowLocation(data),
28669
+ error: String(err)
28670
+ }
28671
+ });
28582
28672
  return null;
28583
28673
  }
28584
28674
  }
28585
28675
  /**
28676
+ * Can this row's blob be SERVED? The question `getByKey` answers in bytes,
28677
+ * asked without fetching any.
28678
+ *
28679
+ * False covers both permanent causes — no row, and a row whose blob cannot be
28680
+ * reached (deleted, or on a storage location the row names and the cluster no
28681
+ * longer has). They are one answer here because they are one answer to the
28682
+ * viewer: the tile is blank. Which of the two it is belongs in the log line
28683
+ * above, not in a boolean.
28684
+ */
28685
+ async blobServable(key) {
28686
+ try {
28687
+ return await this.referencedBlobState(key) === "exists";
28688
+ } catch {
28689
+ return false;
28690
+ }
28691
+ }
28692
+ /**
28586
28693
  * Delete a single media entry by its key (id).
28587
28694
  * Deletes the blob from storage (best-effort) and removes the index row.
28588
28695
  */
@@ -28780,11 +28887,15 @@ var MediaStore = class {
28780
28887
  ...provenance !== void 0 ? { provenance } : {}
28781
28888
  });
28782
28889
  } 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
- } });
28890
+ this.logger.warn("media read failed — row kept, blob not served", {
28891
+ tags: { deviceId: Number(data["deviceId"]) },
28892
+ meta: {
28893
+ key: row.id,
28894
+ path,
28895
+ location: mediaRowLocation(data),
28896
+ error: String(err)
28897
+ }
28898
+ });
28788
28899
  }
28789
28900
  }
28790
28901
  return files;
@@ -30417,6 +30528,8 @@ function analyseDebugTrack(inventory) {
30417
30528
  const durationMs = trackDurationMs(facts);
30418
30529
  const closed = !facts.active;
30419
30530
  const out = [];
30531
+ const unreadable = inventory.unreadableMedia ?? [];
30532
+ 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
30533
  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
30534
  else if (closed) {
30422
30535
  const keyFrameMissing = !hasKind(media, "keyFrame");
@@ -30596,12 +30709,15 @@ async function gatherTrack(deps, track) {
30596
30709
  for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
30597
30710
  const events = await deps.listEvents(track.trackId);
30598
30711
  const thumbnailProvenance = await deps.trackThumbnailProvenance(track.trackId);
30712
+ const unreadableMedia = [];
30713
+ for (const row of media) if (!await deps.probeMediaBlob(row.key)) unreadableMedia.push(row.key);
30599
30714
  return {
30600
30715
  inventory: {
30601
30716
  facts: toFacts(track),
30602
30717
  media,
30603
30718
  eventCount: events.length,
30604
- thumbnailProvenance
30719
+ thumbnailProvenance,
30720
+ unreadableMedia
30605
30721
  },
30606
30722
  events
30607
30723
  };
@@ -32637,6 +32753,149 @@ var FaceStore = class {
32637
32753
  }
32638
32754
  };
32639
32755
  //#endregion
32756
+ //#region src/pipeline-analytics/identity-audit.ts
32757
+ /**
32758
+ * identity-audit — which of a person's ENROLLED samples do not look like the
32759
+ * rest of that person.
32760
+ *
32761
+ * ## Why this exists
32762
+ *
32763
+ * The gallery is the one input to recognition that nobody re-reads. A sample
32764
+ * enrolled from the wrong face keeps voting forever: it pulls the wrong name
32765
+ * onto every future match near it, and because auto-assignment then looks
32766
+ * confident, nobody goes back to check. The 2026-09-08 sweep found exactly one
32767
+ * such pair on this cluster — a sample under one person scoring 0.61 against
32768
+ * three samples of another, enrolled from the same camera in the same minute,
32769
+ * where every other cross-identity pair on the cluster sits under 0.50.
32770
+ *
32771
+ * Finding that took a script and a day. This module makes it a question the
32772
+ * operator can ask per person, from the Review tab, and act on.
32773
+ *
32774
+ * ## What it measures
32775
+ *
32776
+ * Two numbers per sample, and the second is the one that convicts:
32777
+ *
32778
+ * - `selfMean` — mean cosine against the person's OTHER samples. Low means
32779
+ * "this does not look like the rest of you", which is suspicious but can
32780
+ * also be a legitimate hard angle, bad light, a mask.
32781
+ * - `bestForeign` — the highest cosine against any OTHER person's samples,
32782
+ * with that person named. High means "this looks like someone we can name",
32783
+ * which is what a mis-enrolment actually looks like.
32784
+ *
32785
+ * `suspicion` is the EXCESS — `bestForeign − selfMean` — because that is the
32786
+ * only one of the two that discriminates on real data. Measured on Roberta's
32787
+ * 106 samples, 2026-09-08: `selfMean` has a median of 0.188 and a p90 of 0.235,
32788
+ * so ANY absolute reading of "does not look like itself" fires on the whole
32789
+ * gallery. The first version of this module scored `(1 − selfMean) / 2` and
32790
+ * flagged 56 of 106 — a finding that fires on healthy samples means nothing.
32791
+ * `bestForeign` on the same gallery: median 0.283, p95 0.376, and one single
32792
+ * outlier at 0.612, which is the mis-enrolment.
32793
+ *
32794
+ * ## What it refuses to do
32795
+ *
32796
+ * A cosine between vectors from two different face models is a well-formed
32797
+ * float with no meaning — `face-matcher` refuses the comparison and so does
32798
+ * this (`comparable: false`). It must read as "cannot say", never as an
32799
+ * accusation and never as a clean bill: this list ends in a DELETE button.
32800
+ *
32801
+ * A person with one sample is not judged either. One sample has nothing to
32802
+ * disagree with, and the only thing an outlier score could do there is delete
32803
+ * the only sample a new person has.
32804
+ *
32805
+ * Pure — the caller loads the vectors and performs the removal.
32806
+ */
32807
+ /**
32808
+ * A row is FLAGGED when its best foreign match would auto-assign the face to
32809
+ * that other person — i.e. it reaches the recogniser's own
32810
+ * `similarityThreshold`.
32811
+ *
32812
+ * Tied to the live threshold rather than to a constant of its own, because
32813
+ * that is what the flag MEANS: this sample can steal a match. A second number
32814
+ * invented here would drift from the one the recogniser actually uses, and the
32815
+ * panel would accuse samples that cannot hurt anyone while staying quiet about
32816
+ * ones that can. On the live gallery (threshold 0.40) it flags 2 of 106.
32817
+ */
32818
+ function flaggedCount(rows, autoAssignThreshold) {
32819
+ return rows.filter((row) => (row.bestForeign?.score ?? 0) >= autoAssignThreshold).length;
32820
+ }
32821
+ /**
32822
+ * How far a foreign match has to stand OUT to count as looking like someone
32823
+ * else: `bestForeign - selfMean`. A sample that scores 0.6 against a stranger
32824
+ * and 0.7 against its own person is not a mis-enrolment.
32825
+ */
32826
+ function foreignExcess(selfMean, foreign) {
32827
+ if (foreign === void 0) return 0;
32828
+ if (selfMean === null) return 0;
32829
+ return Math.max(0, foreign.score - selfMean);
32830
+ }
32831
+ function meanCosine(target, pool) {
32832
+ let sum = 0;
32833
+ let n = 0;
32834
+ for (const other of pool) {
32835
+ if (other.sampleId === target.sampleId) continue;
32836
+ sum += cosineSimilarity(target.embedding, other.embedding);
32837
+ n += 1;
32838
+ }
32839
+ return n === 0 ? null : sum / n;
32840
+ }
32841
+ function bestForeignMatch(target, others, modelId) {
32842
+ let best;
32843
+ for (const identity of others) for (const sample of identity.samples) {
32844
+ if (sample.modelId !== modelId) continue;
32845
+ const score = cosineSimilarity(target.embedding, sample.embedding);
32846
+ if (best === void 0 || score > best.score) best = {
32847
+ identityId: identity.identityId,
32848
+ name: identity.name,
32849
+ score
32850
+ };
32851
+ }
32852
+ return best;
32853
+ }
32854
+ /**
32855
+ * Rank one person's samples, worst first.
32856
+ *
32857
+ * `suspicion` is `(1 - selfMean) / 2` — how far the sample sits from its own
32858
+ * person, on [0,1] — plus the foreign EXCESS, which dominates when a sample
32859
+ * genuinely resembles somebody the gallery can name. A sample that agrees with
32860
+ * its person scores near zero on both terms.
32861
+ */
32862
+ function auditIdentitySamples(input) {
32863
+ const comparable = input.samples.filter((s) => s.modelId === input.modelId);
32864
+ const rows = input.samples.map((sample) => {
32865
+ if (sample.modelId !== input.modelId) return {
32866
+ sampleId: sample.sampleId,
32867
+ enrolledAt: sample.enrolledAt,
32868
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId },
32869
+ comparable: false,
32870
+ selfMean: null,
32871
+ suspicion: 0
32872
+ };
32873
+ const selfMean = meanCosine(sample, comparable);
32874
+ const foreign = bestForeignMatch(sample, input.others, input.modelId);
32875
+ const suspicion = foreignExcess(selfMean, foreign);
32876
+ return {
32877
+ sampleId: sample.sampleId,
32878
+ enrolledAt: sample.enrolledAt,
32879
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId },
32880
+ comparable: true,
32881
+ selfMean,
32882
+ ...foreign === void 0 ? {} : { bestForeign: foreign },
32883
+ suspicion
32884
+ };
32885
+ });
32886
+ rows.sort((a, b) => {
32887
+ if (b.suspicion !== a.suspicion) return b.suspicion - a.suspicion;
32888
+ return (a.selfMean ?? Number.POSITIVE_INFINITY) - (b.selfMean ?? Number.POSITIVE_INFINITY);
32889
+ });
32890
+ return {
32891
+ identityId: input.identityId,
32892
+ totalSamples: input.samples.length,
32893
+ comparableSamples: comparable.length,
32894
+ flagged: flaggedCount(rows, input.autoAssignThreshold),
32895
+ rows
32896
+ };
32897
+ }
32898
+ //#endregion
32640
32899
  //#region src/pipeline-analytics/face-gallery-provider.ts
32641
32900
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
32642
32901
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -32688,6 +32947,8 @@ var FaceGalleryProvider = class {
32688
32947
  eventStore;
32689
32948
  logger;
32690
32949
  refreshGallery;
32950
+ resolveClusterModelId;
32951
+ resolveAutoAssignThreshold;
32691
32952
  eventMediaBaseUrl;
32692
32953
  eventMediaPathPrefix;
32693
32954
  emitFaceGalleryChanged;
@@ -32699,6 +32960,8 @@ var FaceGalleryProvider = class {
32699
32960
  this.eventStore = deps.eventStore;
32700
32961
  this.logger = deps.logger;
32701
32962
  this.refreshGallery = deps.refreshGallery;
32963
+ this.resolveClusterModelId = deps.resolveClusterModelId;
32964
+ this.resolveAutoAssignThreshold = deps.resolveAutoAssignThreshold;
32702
32965
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
32703
32966
  this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
32704
32967
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
@@ -32775,6 +33038,69 @@ var FaceGalleryProvider = class {
32775
33038
  }
32776
33039
  return result;
32777
33040
  }
33041
+ /**
33042
+ * Re-analyse one person's enrolled samples against the whole gallery.
33043
+ *
33044
+ * Reads every identity's samples and their vectors — 327 on this cluster —
33045
+ * and ranks the target's own by how little they look like the rest of that
33046
+ * person and how much they look like somebody else (`identity-audit.ts`).
33047
+ * Dot products, no inference, no write.
33048
+ *
33049
+ * The whole gallery is loaded on purpose: the accusation that matters is
33050
+ * "this looks like SOMEONE ELSE", and that cannot be computed from one
33051
+ * person's samples.
33052
+ */
33053
+ async auditIdentitySamples(input) {
33054
+ const modelId = await this.resolveClusterModelId();
33055
+ if (modelId === null) throw new Error("auditIdentitySamples: the cluster face-embedding model could not be resolved");
33056
+ const autoAssignThreshold = await this.resolveAutoAssignThreshold();
33057
+ const identities = await this.identityStore.listIdentities();
33058
+ const target = identities.find((i) => i.id === input.identityId);
33059
+ if (target === void 0) throw new Error(`auditIdentitySamples: no identity ${input.identityId}`);
33060
+ const samplesByIdentity = /* @__PURE__ */ new Map();
33061
+ for (const identity of identities) samplesByIdentity.set(identity.id, await this.identityStore.listSamples(identity.id));
33062
+ const vectors = await this.identityStore.loadSampleVectors([...samplesByIdentity.values()].flat().map((sample) => sample.id));
33063
+ const inputsFor = (identityId) => (samplesByIdentity.get(identityId) ?? []).flatMap((sample) => {
33064
+ const embedding = vectors.get(sample.id);
33065
+ if (embedding === void 0) return [];
33066
+ return [{
33067
+ sampleId: sample.id,
33068
+ embedding,
33069
+ modelId: sample.modelId,
33070
+ enrolledAt: sample.addedAt,
33071
+ ...sample.deviceId === void 0 ? {} : { deviceId: sample.deviceId }
33072
+ }];
33073
+ });
33074
+ const report = auditIdentitySamples({
33075
+ identityId: input.identityId,
33076
+ samples: inputsFor(input.identityId),
33077
+ others: identities.filter((identity) => identity.id !== input.identityId).map((identity) => ({
33078
+ identityId: identity.id,
33079
+ name: identity.name,
33080
+ samples: inputsFor(identity.id)
33081
+ })),
33082
+ modelId,
33083
+ autoAssignThreshold
33084
+ });
33085
+ const meta = new Map((samplesByIdentity.get(input.identityId) ?? []).map((sample) => [sample.id, sample]));
33086
+ return {
33087
+ identityId: report.identityId,
33088
+ name: target.name,
33089
+ modelId,
33090
+ totalSamples: report.totalSamples,
33091
+ comparableSamples: report.comparableSamples,
33092
+ flagged: report.flagged,
33093
+ flagThreshold: autoAssignThreshold,
33094
+ rows: report.rows.map((row) => {
33095
+ const sample = meta.get(row.sampleId);
33096
+ return {
33097
+ ...row,
33098
+ source: sample?.source ?? "detected",
33099
+ ...sample?.mediaKey === void 0 ? {} : { mediaKey: sample.mediaKey }
33100
+ };
33101
+ })
33102
+ };
33103
+ }
32778
33104
  async removeSample(input) {
32779
33105
  const sample = (await this.identityStore.listSamples(input.identityId)).find((s) => s.id === input.sampleId);
32780
33106
  if (sample?.mediaKey) await this.mediaStore.deleteByKey(sample.mediaKey);
@@ -37403,7 +37729,7 @@ var TrackCloser = class {
37403
37729
  async maybePromoteLastFrame(t, ownedMedia) {
37404
37730
  const promotion = decideLastFramePromotion(ownedMedia);
37405
37731
  if (!promotion.promote) {
37406
- if (promotion.declinedBecause !== "already-last") this.deps.logger.warn("no lastFrame at close", {
37732
+ if (promotion.declinedBecause === "no-key-frame") this.deps.logger.warn("no lastFrame at close", {
37407
37733
  tags: { deviceId: t.deviceId },
37408
37734
  meta: {
37409
37735
  trackId: t.trackId,
@@ -57642,6 +57968,9 @@ async function reconcileGallerySamples(deps, options) {
57642
57968
  let keyless = 0;
57643
57969
  let skippedGrace = 0;
57644
57970
  let stoppedByCeiling = false;
57971
+ let missed = 0;
57972
+ /** Verdicts held until there are enough of them to tell rot from a move. */
57973
+ const held = [];
57645
57974
  const identities = await deps.listIdentities();
57646
57975
  for (const identity of identities) {
57647
57976
  const samples = await deps.listSamples(identity.id);
@@ -57686,6 +58015,43 @@ async function reconcileGallerySamples(deps, options) {
57686
58015
  }
57687
58016
  checked += 1;
57688
58017
  if (state === "exists") continue;
58018
+ missed += 1;
58019
+ const minProbes = options.massMissMinProbes ?? 8;
58020
+ const bar = options.massMissFraction ?? .5;
58021
+ if (checked < minProbes) {
58022
+ held.push({
58023
+ identityId: identity.id,
58024
+ sampleId: sample.id,
58025
+ mediaKey: sample.mediaKey
58026
+ });
58027
+ continue;
58028
+ }
58029
+ if (missed / checked >= bar) {
58030
+ const reason = `mass miss: ${String(missed)} of ${String(checked)} probes found no blob`;
58031
+ deps.logger.warn("gallery reconcile: nearly every blob is missing — that is the volume, not the samples. Aborting and keeping every row; check where the gallery location points.", { meta: {
58032
+ checked,
58033
+ missed,
58034
+ held: held.length,
58035
+ identity: identity.name
58036
+ } });
58037
+ return {
58038
+ checked,
58039
+ reclaimed,
58040
+ keyless,
58041
+ skippedGrace,
58042
+ stoppedByCeiling,
58043
+ aborted: reason
58044
+ };
58045
+ }
58046
+ for (const pending of held.splice(0, held.length)) {
58047
+ await deps.removeSample(pending.identityId, pending.sampleId);
58048
+ reclaimed += 1;
58049
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
58050
+ sampleId: pending.sampleId,
58051
+ mediaKey: pending.mediaKey,
58052
+ state: "missing-blob"
58053
+ } });
58054
+ }
57689
58055
  await deps.removeSample(identity.id, sample.id);
57690
58056
  reclaimed += 1;
57691
58057
  deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
@@ -57696,6 +58062,14 @@ async function reconcileGallerySamples(deps, options) {
57696
58062
  } });
57697
58063
  }
57698
58064
  }
58065
+ for (const pending of held.splice(0, held.length)) {
58066
+ if (reclaimed >= options.maxReclaimPerRun) {
58067
+ stoppedByCeiling = true;
58068
+ break;
58069
+ }
58070
+ await deps.removeSample(pending.identityId, pending.sampleId);
58071
+ reclaimed += 1;
58072
+ }
57699
58073
  if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
57700
58074
  checked,
57701
58075
  reclaimed,
@@ -59023,12 +59397,24 @@ var IdentityStore = class {
59023
59397
  id: r.id,
59024
59398
  source: d.source ?? "detected",
59025
59399
  addedAt: Number(d.addedAt ?? 0),
59400
+ modelId: String(d.modelId ?? ""),
59026
59401
  ...d.mediaKey != null ? { mediaKey: d.mediaKey } : {},
59027
59402
  ...d.sourceMediaKey != null ? { sourceMediaKey: d.sourceMediaKey } : {},
59028
59403
  ...d.deviceId != null ? { deviceId: d.deviceId } : {}
59029
59404
  };
59030
59405
  });
59031
59406
  }
59407
+ /**
59408
+ * The enrolled vectors for these sample ids, straight off the index.
59409
+ *
59410
+ * Exposed because the gallery audit compares samples with each other, and
59411
+ * `loadGallery` — the only other vector read — drops the sample id in favour
59412
+ * of the identity id. An audit that cannot name the sample it accuses has
59413
+ * nothing to offer a Remove button.
59414
+ */
59415
+ async loadSampleVectors(sampleIds) {
59416
+ return this.vectors.load(sampleIds);
59417
+ }
59032
59418
  async removeSample(identityId, sampleId) {
59033
59419
  await this.store.delete.mutate({
59034
59420
  collection: IDENTITY_SAMPLES_COLLECTION,
@@ -70940,9 +71326,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70940
71326
  /**
70941
71327
  * The store seam the `debug.*` actions and the debug bundle both read through.
70942
71328
  *
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.
71329
+ * Index reads, plus ONE existence probe per media row never a blob. The
71330
+ * probe is the only thing here that touches the filesystem, and it earns the
71331
+ * trip: every index read in this file counts rows the two serving reads
71332
+ * (`listByOwner`, `getByKey`) silently drop when the bytes are unreachable,
71333
+ * so without it the tab reports tiles the viewer draws blank. The bytes
71334
+ * themselves are still fetched one at a time, by key, by the export handler,
71335
+ * so a bundle of a hundred tracks never holds more than one file.
70946
71336
  */
70947
71337
  buildDebugServiceDeps() {
70948
71338
  const trackStore = this.trackStore;
@@ -70956,6 +71346,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70956
71346
  listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
70957
71347
  listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
70958
71348
  trackThumbnailProvenance: (trackId) => mediaStore.trackThumbnailProvenance(trackId),
71349
+ probeMediaBlob: (key) => mediaStore.blobServable(key),
70959
71350
  logger: this.ctx.logger.child("debug"),
70960
71351
  now: () => Date.now()
70961
71352
  };
@@ -71657,6 +72048,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71657
72048
  eventStore: stores.eventStore,
71658
72049
  logger: this.ctx.logger,
71659
72050
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
72051
+ resolveAutoAssignThreshold: async () => (await this.readDeviceSettings(0, resolveFaceSettings)).similarityThreshold,
72052
+ resolveClusterModelId: async () => {
72053
+ const api = this.ctx.api;
72054
+ if (!api) return null;
72055
+ return resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
72056
+ },
71660
72057
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
71661
72058
  eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
71662
72059
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)