@camstack/addon-post-analysis 1.1.25 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-CCC79h7t.js → dist-BEx5ST1W.js} +143 -6
- package/dist/{dist-Csk_yJr_.mjs → dist-DytVmDZg.mjs} +138 -7
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/enrichment-engine/index.js +2 -2
- package/dist/enrichment-engine/index.mjs +1 -1
- package/dist/{node-CIEkEv1F.js → node-Cg_cGqs0.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cb6Z2H5K.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-p-r6oXq8.mjs} +3 -3
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtOrjE2U.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C6LbOCa9.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-BrqCuPYQ.mjs → hostInit-2UZIpU0W.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +763 -40
- package/dist/pipeline-analytics/index.mjs +761 -38
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/dist/{resolve-frame-sKYbstL-.js → resolve-frame-Cbm_NFuq.js} +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { C as number, S as boolean, T as string, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, w as object, y as hydrateSchema } from "../dist-DytVmDZg.mjs";
|
|
2
2
|
import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
|
|
3
3
|
import { FrameRingReaderCache } from "@camstack/shm-ring";
|
|
4
4
|
import sharp from "sharp";
|
|
@@ -119,6 +119,138 @@ function clusterByEmbedding(items, threshold = .5) {
|
|
|
119
119
|
})).toSorted((a, b) => b.size - a.size || b.cohesion - a.cohesion);
|
|
120
120
|
}
|
|
121
121
|
//#endregion
|
|
122
|
+
//#region src/pipeline-analytics/pipeline/key-event-scoring.ts
|
|
123
|
+
/**
|
|
124
|
+
* key-event-scoring — the SINGLE deterministic "importance" scorer for a
|
|
125
|
+
* completed track. A value in [0,1] the viewer sorts events by (events page)
|
|
126
|
+
* and a future reels feature pulls highlights from.
|
|
127
|
+
*
|
|
128
|
+
* PURE + DETERMINISTIC: no Date.now / no random. Time enters ONLY as the
|
|
129
|
+
* precomputed `durationMs` (the caller already knows a track's dwell). Given
|
|
130
|
+
* the same `ScoringInput`, `computeImportance` always returns the same
|
|
131
|
+
* `{ importance, reason }`.
|
|
132
|
+
*
|
|
133
|
+
* The score is a weighted sum of seven independent, individually-saturated
|
|
134
|
+
* signals. Every weight and knee is a NAMED EXPORT so the formula is auditable
|
|
135
|
+
* and the tests can assert exact wiring. The weights sum to exactly 1.0, so an
|
|
136
|
+
* all-signals-maxed track scores exactly 1.0 (the clamp is then a no-op) and an
|
|
137
|
+
* all-zero track scores 0.
|
|
138
|
+
*/
|
|
139
|
+
var WEIGHT_CONFIDENCE = .3;
|
|
140
|
+
var WEIGHT_CLASS = .2;
|
|
141
|
+
var WEIGHT_DWELL = .2;
|
|
142
|
+
var WEIGHT_PROXIMITY = .12;
|
|
143
|
+
var WEIGHT_TRAVEL = .08;
|
|
144
|
+
var WEIGHT_ZONE = .05;
|
|
145
|
+
var WEIGHT_IDENTITY = .05;
|
|
146
|
+
/** Dwell (ms) that saturates the dwell term. */
|
|
147
|
+
var DWELL_FULL_MS = 6e4;
|
|
148
|
+
/** Peak bbox area (as a fraction of frame area) that saturates the size term. */
|
|
149
|
+
var SIZE_FULL = .15;
|
|
150
|
+
var CLASS_RANK_VEHICLE = .8;
|
|
151
|
+
var CLASS_RANK_ANIMAL = .5;
|
|
152
|
+
var CLASS_RANK_DEFAULT = .25;
|
|
153
|
+
var PERSON_CLASSES = new Set(["person", "face"]);
|
|
154
|
+
var VEHICLE_CLASSES = new Set([
|
|
155
|
+
"vehicle",
|
|
156
|
+
"car",
|
|
157
|
+
"truck",
|
|
158
|
+
"bus"
|
|
159
|
+
]);
|
|
160
|
+
var ANIMAL_CLASSES = new Set([
|
|
161
|
+
"animal",
|
|
162
|
+
"dog",
|
|
163
|
+
"cat"
|
|
164
|
+
]);
|
|
165
|
+
/** Clamp to [0,1]. */
|
|
166
|
+
function clamp01(x) {
|
|
167
|
+
if (Number.isNaN(x)) return 0;
|
|
168
|
+
if (x < 0) return 0;
|
|
169
|
+
if (x > 1) return 1;
|
|
170
|
+
return x;
|
|
171
|
+
}
|
|
172
|
+
/** Rank a class name into [0,1] by how event-worthy it is. Case-insensitive. */
|
|
173
|
+
function classRank(className) {
|
|
174
|
+
const c = className.toLowerCase();
|
|
175
|
+
if (PERSON_CLASSES.has(c)) return 1;
|
|
176
|
+
if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
|
|
177
|
+
if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
|
|
178
|
+
return CLASS_RANK_DEFAULT;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Compute a deterministic importance score for a completed track.
|
|
182
|
+
*
|
|
183
|
+
* `reason` is the tag of the single largest weighted term. Ties resolve to the
|
|
184
|
+
* first term in a fixed, weight-descending order (confidence → class → dwell →
|
|
185
|
+
* proximity → travel → zone → identity), so the output stays deterministic.
|
|
186
|
+
*/
|
|
187
|
+
function computeImportance(input) {
|
|
188
|
+
const terms = [
|
|
189
|
+
{
|
|
190
|
+
reason: "confidence",
|
|
191
|
+
value: WEIGHT_CONFIDENCE * clamp01(input.peakConfidence)
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
reason: "class",
|
|
195
|
+
value: WEIGHT_CLASS * classRank(input.className)
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
reason: "dwell",
|
|
199
|
+
value: WEIGHT_DWELL * clamp01(input.durationMs / DWELL_FULL_MS)
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
reason: "proximity",
|
|
203
|
+
value: WEIGHT_PROXIMITY * clamp01(input.peakBboxAreaFrac / SIZE_FULL)
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
reason: "travel",
|
|
207
|
+
value: WEIGHT_TRAVEL * clamp01(input.totalDistance / 2)
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
reason: "zone",
|
|
211
|
+
value: WEIGHT_ZONE * (input.zonesVisited.length > 0 ? 1 : 0)
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
reason: "identity",
|
|
215
|
+
value: WEIGHT_IDENTITY * (input.label ? 1 : 0)
|
|
216
|
+
}
|
|
217
|
+
];
|
|
218
|
+
let sum = 0;
|
|
219
|
+
let best = terms[0];
|
|
220
|
+
for (const term of terms) {
|
|
221
|
+
sum += term.value;
|
|
222
|
+
if (term.value > best.value) best = term;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
importance: clamp01(sum),
|
|
226
|
+
reason: best.reason
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/pipeline-analytics/pipeline/key-event-recompute.ts
|
|
231
|
+
/**
|
|
232
|
+
* Recompute + persist a persisted track's importance from its current fields
|
|
233
|
+
* (including any newly-assigned label) and re-stamp its object events. No-op
|
|
234
|
+
* when the track is not (yet) persisted.
|
|
235
|
+
*/
|
|
236
|
+
async function recomputeTrackImportance(deps, trackId) {
|
|
237
|
+
const track = await deps.trackStore.getPersistedByTrackId(trackId);
|
|
238
|
+
if (track === null) return;
|
|
239
|
+
const peak = await deps.eventStore.peakForTrack(trackId);
|
|
240
|
+
const { importance, reason } = computeImportance({
|
|
241
|
+
peakConfidence: peak.peakConfidence,
|
|
242
|
+
className: track.className,
|
|
243
|
+
durationMs: track.lastSeen - track.firstSeen,
|
|
244
|
+
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
245
|
+
totalDistance: track.totalDistance,
|
|
246
|
+
zonesVisited: track.zonesVisited,
|
|
247
|
+
...track.label !== void 0 ? { label: track.label } : {}
|
|
248
|
+
});
|
|
249
|
+
const bestEventId = track.bestEventId ?? peak.bestEventId;
|
|
250
|
+
await deps.trackStore.setImportance(trackId, importance, reason, bestEventId);
|
|
251
|
+
await deps.eventStore.setImportanceForTrack(trackId, importance);
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
122
254
|
//#region src/pipeline-analytics/face-gallery-provider.ts
|
|
123
255
|
/** Embedding model id used when enrolling face crops as identity samples. */
|
|
124
256
|
var MODEL_ID = "arcface-r100";
|
|
@@ -239,7 +371,9 @@ var FaceGalleryProvider = class {
|
|
|
239
371
|
...face.recognizedIdentityId != null ? { recognizedIdentityId: face.recognizedIdentityId } : {},
|
|
240
372
|
...identityName !== void 0 ? { identityName } : {},
|
|
241
373
|
assigned: face.assigned,
|
|
242
|
-
...base64 !== void 0 ? { base64 } : {}
|
|
374
|
+
...base64 !== void 0 ? { base64 } : {},
|
|
375
|
+
...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
|
|
376
|
+
...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
|
|
243
377
|
});
|
|
244
378
|
}
|
|
245
379
|
return result;
|
|
@@ -267,7 +401,9 @@ var FaceGalleryProvider = class {
|
|
|
267
401
|
...face.recognizedIdentityId != null ? { recognizedIdentityId: face.recognizedIdentityId } : {},
|
|
268
402
|
...identityName !== void 0 ? { identityName } : {},
|
|
269
403
|
assigned: face.assigned,
|
|
270
|
-
...base64 !== void 0 ? { base64 } : {}
|
|
404
|
+
...base64 !== void 0 ? { base64 } : {},
|
|
405
|
+
...face.faceBbox !== void 0 ? { faceBbox: face.faceBbox } : {},
|
|
406
|
+
...face.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: face.keyFrameMediaKey } : {}
|
|
271
407
|
};
|
|
272
408
|
}
|
|
273
409
|
/**
|
|
@@ -344,6 +480,10 @@ var FaceGalleryProvider = class {
|
|
|
344
480
|
if (identityName !== void 0) {
|
|
345
481
|
await this.trackStore.setLabel(currentFace.trackId, identityName);
|
|
346
482
|
await this.eventStore.setLabelForTrack(currentFace.trackId, identityName);
|
|
483
|
+
await recomputeTrackImportance({
|
|
484
|
+
trackStore: this.trackStore,
|
|
485
|
+
eventStore: this.eventStore
|
|
486
|
+
}, currentFace.trackId);
|
|
347
487
|
}
|
|
348
488
|
this.refreshGallery();
|
|
349
489
|
}
|
|
@@ -1424,6 +1564,10 @@ var FrameProcessor = class {
|
|
|
1424
1564
|
w: det.bbox.width,
|
|
1425
1565
|
h: det.bbox.height
|
|
1426
1566
|
});
|
|
1567
|
+
if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
|
|
1568
|
+
embedding: det.embedding,
|
|
1569
|
+
...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
|
|
1570
|
+
});
|
|
1427
1571
|
}
|
|
1428
1572
|
for (const det of frame.detections) {
|
|
1429
1573
|
if (det.kind !== "detail" || det.macroClass !== "plate" || !det.parentId) continue;
|
|
@@ -1564,6 +1708,115 @@ var BestDetectionTracker = class {
|
|
|
1564
1708
|
}
|
|
1565
1709
|
};
|
|
1566
1710
|
//#endregion
|
|
1711
|
+
//#region src/pipeline-analytics/pipeline/track-best-detection.ts
|
|
1712
|
+
/**
|
|
1713
|
+
* `TrackBestSelector` — the ONE unified "best detection per track" primitive.
|
|
1714
|
+
*
|
|
1715
|
+
* Post-analysis derives several per-track "best" artefacts (best FRAME thumbnail,
|
|
1716
|
+
* best OBJECT/CLIP embedding + its crop, best FACE crop). They all rank the same
|
|
1717
|
+
* way: highest detector confidence per `trackId`. This selector composes the
|
|
1718
|
+
* canonical {@link BestDetectionTracker} ranking with the RESOLVED payload the
|
|
1719
|
+
* consumers need at the peak (bbox / className / embedding / faceBbox), so a
|
|
1720
|
+
* single "new best" DECISION can drive one capture whose output is shared:
|
|
1721
|
+
* - the boxed best-frame `thumbnail`,
|
|
1722
|
+
* - the tight object crop written onto the CLIP embedding row's `mediaKey`
|
|
1723
|
+
* (so a search hit's thumbnail IS the embedded crop).
|
|
1724
|
+
*
|
|
1725
|
+
* It is deliberately in-memory (the peak is per live track, dropped at track
|
|
1726
|
+
* end). The CLIP embedding's cross-restart persistence stays a SEPARATE store-
|
|
1727
|
+
* side gate (`ObjectEmbeddingStore.upsertIfBetter`): this selector unifies the
|
|
1728
|
+
* DECISION, not the durable store (see best-detection-tracker.ts docstring).
|
|
1729
|
+
*
|
|
1730
|
+
* The face path keeps its own hold buffer because a face crop can only come
|
|
1731
|
+
* from a face-bearing frame (the documented FRAME↔FACE seam) — but it shares
|
|
1732
|
+
* this ranking so best-frame and best-face agree on which frame is "best".
|
|
1733
|
+
*/
|
|
1734
|
+
var TrackBestSelector = class {
|
|
1735
|
+
tracker;
|
|
1736
|
+
payloads = /* @__PURE__ */ new Map();
|
|
1737
|
+
constructor(options = {}) {
|
|
1738
|
+
this.tracker = new BestDetectionTracker(options);
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Record an observation for its track. Returns true when it becomes the
|
|
1742
|
+
* track's new best (first sighting, or a confidence that beats the held peak
|
|
1743
|
+
* per the tracker's hysteresis / minGap rules). On acceptance the held payload
|
|
1744
|
+
* advances to this observation so `peak(trackId)` returns the winning frame's
|
|
1745
|
+
* bbox / embedding / faceBbox.
|
|
1746
|
+
*/
|
|
1747
|
+
observe(obs) {
|
|
1748
|
+
const isNewBest = this.tracker.observe(obs.trackId, obs.confidence, obs.atMs);
|
|
1749
|
+
if (isNewBest) {
|
|
1750
|
+
const { trackId, ...payload } = obs;
|
|
1751
|
+
this.payloads.set(trackId, payload);
|
|
1752
|
+
}
|
|
1753
|
+
return isNewBest;
|
|
1754
|
+
}
|
|
1755
|
+
/** The held best payload for a track (undefined if never observed). */
|
|
1756
|
+
peak(trackId) {
|
|
1757
|
+
return this.payloads.get(trackId);
|
|
1758
|
+
}
|
|
1759
|
+
/** The held peak confidence for a track (undefined if never observed). */
|
|
1760
|
+
peakConfidence(trackId) {
|
|
1761
|
+
return this.tracker.peak(trackId)?.confidence;
|
|
1762
|
+
}
|
|
1763
|
+
/** Drop a track's peak + payload (call at track end). */
|
|
1764
|
+
delete(trackId) {
|
|
1765
|
+
this.tracker.delete(trackId);
|
|
1766
|
+
this.payloads.delete(trackId);
|
|
1767
|
+
}
|
|
1768
|
+
clear() {
|
|
1769
|
+
this.tracker.clear();
|
|
1770
|
+
this.payloads.clear();
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1773
|
+
//#endregion
|
|
1774
|
+
//#region src/pipeline-analytics/pipeline/object-embedding-selection.ts
|
|
1775
|
+
function isClipObjectEmbedding(t) {
|
|
1776
|
+
return Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.embeddingModelId.startsWith("mobileclip-");
|
|
1777
|
+
}
|
|
1778
|
+
function resolveSearchThumbnailUrl(input) {
|
|
1779
|
+
if (input.baseUrl === null) return void 0;
|
|
1780
|
+
const id = input.embeddingMediaKey ?? input.eventId;
|
|
1781
|
+
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
1782
|
+
}
|
|
1783
|
+
//#endregion
|
|
1784
|
+
//#region src/pipeline-analytics/pipeline/key-event-query.ts
|
|
1785
|
+
async function rankKeyEvents(candidates, options, peakLookup) {
|
|
1786
|
+
const scored = [];
|
|
1787
|
+
for (const t of candidates) {
|
|
1788
|
+
if (options.classFilter !== void 0 && t.className !== options.classFilter) continue;
|
|
1789
|
+
let importance = t.importance;
|
|
1790
|
+
let bestEventId = t.bestEventId;
|
|
1791
|
+
if (importance === void 0) {
|
|
1792
|
+
const peak = await peakLookup(t.trackId);
|
|
1793
|
+
importance = computeImportance({
|
|
1794
|
+
peakConfidence: peak.peakConfidence,
|
|
1795
|
+
className: t.className,
|
|
1796
|
+
durationMs: t.lastSeen - t.firstSeen,
|
|
1797
|
+
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
1798
|
+
totalDistance: t.totalDistance,
|
|
1799
|
+
zonesVisited: t.zonesVisited,
|
|
1800
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
1801
|
+
}).importance;
|
|
1802
|
+
bestEventId = bestEventId ?? peak.bestEventId;
|
|
1803
|
+
}
|
|
1804
|
+
if (options.minImportance !== void 0 && importance < options.minImportance) continue;
|
|
1805
|
+
scored.push({
|
|
1806
|
+
id: bestEventId ?? t.trackId,
|
|
1807
|
+
trackId: t.trackId,
|
|
1808
|
+
timestamp: t.firstSeen,
|
|
1809
|
+
className: t.className,
|
|
1810
|
+
...t.label !== void 0 ? { label: t.label } : {},
|
|
1811
|
+
importance,
|
|
1812
|
+
bestEventId: bestEventId ?? "",
|
|
1813
|
+
windowMs: t.lastSeen - t.firstSeen
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
scored.sort((a, b) => b.importance - a.importance);
|
|
1817
|
+
return scored.slice(0, options.limit);
|
|
1818
|
+
}
|
|
1819
|
+
//#endregion
|
|
1567
1820
|
//#region src/pipeline-analytics/pipeline/native-detection.ts
|
|
1568
1821
|
/**
|
|
1569
1822
|
* Nominal frame size used to denormalize native `[0,1]` boxes when a
|
|
@@ -1733,6 +1986,18 @@ var TRACKS_COLUMNS = [
|
|
|
1733
1986
|
{
|
|
1734
1987
|
name: "state",
|
|
1735
1988
|
type: "TEXT"
|
|
1989
|
+
},
|
|
1990
|
+
{
|
|
1991
|
+
name: "importance",
|
|
1992
|
+
type: "REAL"
|
|
1993
|
+
},
|
|
1994
|
+
{
|
|
1995
|
+
name: "bestEventId",
|
|
1996
|
+
type: "TEXT"
|
|
1997
|
+
},
|
|
1998
|
+
{
|
|
1999
|
+
name: "importanceReason",
|
|
2000
|
+
type: "TEXT"
|
|
1736
2001
|
}
|
|
1737
2002
|
];
|
|
1738
2003
|
var TRACKS_INDEXES = [{
|
|
@@ -1764,7 +2029,10 @@ function cloneTrack(t) {
|
|
|
1764
2029
|
zonesVisited: [...t.zonesVisited],
|
|
1765
2030
|
totalDistance: t.totalDistance,
|
|
1766
2031
|
state: t.state,
|
|
1767
|
-
active: t.active
|
|
2032
|
+
active: t.active,
|
|
2033
|
+
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2034
|
+
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2035
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
1768
2036
|
};
|
|
1769
2037
|
}
|
|
1770
2038
|
var TrackStore = class {
|
|
@@ -1898,6 +2166,37 @@ var TrackStore = class {
|
|
|
1898
2166
|
}
|
|
1899
2167
|
}
|
|
1900
2168
|
/**
|
|
2169
|
+
* Stamp a track's importance score (+ dominant reason and best-event pointer).
|
|
2170
|
+
* Updates the in-memory active entry (so the value is carried into an eventual
|
|
2171
|
+
* (re)persist) AND patches the already-persisted row. Mirrors `setLabel`.
|
|
2172
|
+
* Forward-only — never rewrites history beyond these fields.
|
|
2173
|
+
*/
|
|
2174
|
+
async setImportance(trackId, importance, reason, bestEventId) {
|
|
2175
|
+
const active = this.active.get(trackId);
|
|
2176
|
+
if (active) {
|
|
2177
|
+
active.importance = importance;
|
|
2178
|
+
active.importanceReason = reason;
|
|
2179
|
+
if (bestEventId !== void 0) active.bestEventId = bestEventId;
|
|
2180
|
+
}
|
|
2181
|
+
const data = {
|
|
2182
|
+
importance,
|
|
2183
|
+
importanceReason: reason
|
|
2184
|
+
};
|
|
2185
|
+
if (bestEventId !== void 0) data["bestEventId"] = bestEventId;
|
|
2186
|
+
try {
|
|
2187
|
+
await this.store.update.mutate({
|
|
2188
|
+
collection: TRACKS_COLLECTION,
|
|
2189
|
+
id: trackId,
|
|
2190
|
+
data
|
|
2191
|
+
});
|
|
2192
|
+
} catch (err) {
|
|
2193
|
+
this.logger.warn("setImportance persist failed", { meta: {
|
|
2194
|
+
trackId,
|
|
2195
|
+
error: String(err)
|
|
2196
|
+
} });
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
1901
2200
|
* Clear a track's label. Sets label to null in the persisted row (so
|
|
1902
2201
|
* rowToTrack's `typeof label === 'string'` guard omits it on read → label
|
|
1903
2202
|
* is absent/undefined). Also clears the in-memory active entry if present.
|
|
@@ -1968,7 +2267,10 @@ var TrackStore = class {
|
|
|
1968
2267
|
snapshots: [...t.snapshots],
|
|
1969
2268
|
zonesVisited: [...t.zonesVisited],
|
|
1970
2269
|
totalDistance: t.totalDistance,
|
|
1971
|
-
state: t.state
|
|
2270
|
+
state: t.state,
|
|
2271
|
+
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
2272
|
+
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
2273
|
+
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {}
|
|
1972
2274
|
}
|
|
1973
2275
|
});
|
|
1974
2276
|
}
|
|
@@ -1977,6 +2279,9 @@ var TrackStore = class {
|
|
|
1977
2279
|
const snapshots = data["snapshots"] ?? [];
|
|
1978
2280
|
const zones = data["zonesVisited"] ?? [];
|
|
1979
2281
|
const label = data["label"];
|
|
2282
|
+
const importance = data["importance"];
|
|
2283
|
+
const bestEventId = data["bestEventId"];
|
|
2284
|
+
const importanceReason = data["importanceReason"];
|
|
1980
2285
|
return {
|
|
1981
2286
|
trackId: id,
|
|
1982
2287
|
deviceId: Number(data["deviceId"]),
|
|
@@ -1989,7 +2294,10 @@ var TrackStore = class {
|
|
|
1989
2294
|
zonesVisited: zones,
|
|
1990
2295
|
totalDistance: Number(data["totalDistance"] ?? 0),
|
|
1991
2296
|
state: data["state"] ?? "idle",
|
|
1992
|
-
active: false
|
|
2297
|
+
active: false,
|
|
2298
|
+
...typeof importance === "number" ? { importance } : {},
|
|
2299
|
+
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
2300
|
+
...typeof importanceReason === "string" ? { importanceReason } : {}
|
|
1993
2301
|
};
|
|
1994
2302
|
}
|
|
1995
2303
|
};
|
|
@@ -2492,6 +2800,10 @@ var OBJECT_COLUMNS = [
|
|
|
2492
2800
|
{
|
|
2493
2801
|
name: "mediaKey",
|
|
2494
2802
|
type: "TEXT"
|
|
2803
|
+
},
|
|
2804
|
+
{
|
|
2805
|
+
name: "importance",
|
|
2806
|
+
type: "REAL"
|
|
2495
2807
|
}
|
|
2496
2808
|
];
|
|
2497
2809
|
var AUDIO_COLUMNS = [
|
|
@@ -2689,6 +3001,63 @@ var EventStore = class {
|
|
|
2689
3001
|
return updated;
|
|
2690
3002
|
}
|
|
2691
3003
|
/**
|
|
3004
|
+
* Forward-only: stamp `importance` on every already-emitted object event of a
|
|
3005
|
+
* track. Returns the number of events updated. Best-effort per row. Mirrors
|
|
3006
|
+
* `setLabelForTrack`. Called when a track's key-event score is computed (at
|
|
3007
|
+
* expiry) or recomputed (late label) so an event row carries the parent
|
|
3008
|
+
* track's importance without a join.
|
|
3009
|
+
*/
|
|
3010
|
+
async setImportanceForTrack(trackId, importance) {
|
|
3011
|
+
const rows = await this.store.query.query({
|
|
3012
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3013
|
+
filter: { where: { trackId } }
|
|
3014
|
+
});
|
|
3015
|
+
let updated = 0;
|
|
3016
|
+
for (const row of rows) try {
|
|
3017
|
+
await this.store.update.mutate({
|
|
3018
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3019
|
+
id: row.id,
|
|
3020
|
+
data: { importance }
|
|
3021
|
+
});
|
|
3022
|
+
updated++;
|
|
3023
|
+
} catch (err) {
|
|
3024
|
+
this.logger.warn("setImportanceForTrack update failed", { meta: {
|
|
3025
|
+
trackId,
|
|
3026
|
+
eventId: row.id,
|
|
3027
|
+
error: String(err)
|
|
3028
|
+
} });
|
|
3029
|
+
}
|
|
3030
|
+
return updated;
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* The track's highest-confidence object event, its bbox area (as a fraction of
|
|
3034
|
+
* frame area), and that event's id — the SHARED per-track ranking already used
|
|
3035
|
+
* for the best frame, read back from the persisted object events (index
|
|
3036
|
+
* `idx_object_track`). Returns zeros + undefined id when the track has none.
|
|
3037
|
+
* Used by the importance scorer at expiry and by `getKeyEvents` compute-on-read.
|
|
3038
|
+
*/
|
|
3039
|
+
async peakForTrack(trackId) {
|
|
3040
|
+
const rows = await this.store.query.query({
|
|
3041
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
3042
|
+
filter: { where: { trackId } }
|
|
3043
|
+
});
|
|
3044
|
+
let bestConf = -1;
|
|
3045
|
+
let bestEventId;
|
|
3046
|
+
let peakBboxAreaFrac = 0;
|
|
3047
|
+
for (const row of rows) {
|
|
3048
|
+
const conf = typeof row.data["confidence"] === "number" ? row.data["confidence"] : 0;
|
|
3049
|
+
if (conf <= bestConf) continue;
|
|
3050
|
+
bestConf = conf;
|
|
3051
|
+
bestEventId = row.id;
|
|
3052
|
+
peakBboxAreaFrac = bboxAreaFrac(row.data);
|
|
3053
|
+
}
|
|
3054
|
+
return {
|
|
3055
|
+
peakConfidence: bestConf < 0 ? 0 : bestConf,
|
|
3056
|
+
peakBboxAreaFrac,
|
|
3057
|
+
bestEventId
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
/**
|
|
2692
3061
|
* Clear `label` on every already-emitted object event of a track (sets label
|
|
2693
3062
|
* to null so stripNulls/slimObject omit it on read → label is absent/undefined).
|
|
2694
3063
|
* Returns the number of events updated. Best-effort per row. Mirrors
|
|
@@ -2883,7 +3252,8 @@ function slimObject(id, data) {
|
|
|
2883
3252
|
timestamp: data["timestamp"],
|
|
2884
3253
|
className: data["className"],
|
|
2885
3254
|
...typeof data["frameId"] === "string" ? { frameId: data["frameId"] } : {},
|
|
2886
|
-
...typeof data["trackId"] === "string" ? { trackId: data["trackId"] } : {}
|
|
3255
|
+
...typeof data["trackId"] === "string" ? { trackId: data["trackId"] } : {},
|
|
3256
|
+
...typeof data["importance"] === "number" ? { importance: data["importance"] } : {}
|
|
2887
3257
|
};
|
|
2888
3258
|
if (typeof data["label"] === "string") return {
|
|
2889
3259
|
...base,
|
|
@@ -2913,6 +3283,17 @@ function slimAudio(id, data) {
|
|
|
2913
3283
|
}
|
|
2914
3284
|
return base;
|
|
2915
3285
|
}
|
|
3286
|
+
function bboxAreaFrac(data) {
|
|
3287
|
+
const bbox = data["bbox"];
|
|
3288
|
+
const fw = data["frameWidth"];
|
|
3289
|
+
const fh = data["frameHeight"];
|
|
3290
|
+
if (bbox === null || typeof bbox !== "object") return 0;
|
|
3291
|
+
if (typeof fw !== "number" || typeof fh !== "number" || fw <= 0 || fh <= 0) return 0;
|
|
3292
|
+
const w = "w" in bbox && typeof bbox.w === "number" ? bbox.w : 0;
|
|
3293
|
+
const h = "h" in bbox && typeof bbox.h === "number" ? bbox.h : 0;
|
|
3294
|
+
if (w <= 0 || h <= 0) return 0;
|
|
3295
|
+
return w * h / (fw * fh);
|
|
3296
|
+
}
|
|
2916
3297
|
function stripNulls(data) {
|
|
2917
3298
|
const out = {};
|
|
2918
3299
|
for (const [k, v] of Object.entries(data)) if (v !== null) out[k] = v;
|
|
@@ -4532,6 +4913,14 @@ var FACE_COLUMNS = [
|
|
|
4532
4913
|
{
|
|
4533
4914
|
name: "assignedSampleId",
|
|
4534
4915
|
type: "TEXT"
|
|
4916
|
+
},
|
|
4917
|
+
{
|
|
4918
|
+
name: "keyFrameMediaKey",
|
|
4919
|
+
type: "TEXT"
|
|
4920
|
+
},
|
|
4921
|
+
{
|
|
4922
|
+
name: "faceBbox",
|
|
4923
|
+
type: "JSON"
|
|
4535
4924
|
}
|
|
4536
4925
|
];
|
|
4537
4926
|
var FACE_INDEXES = [{
|
|
@@ -4604,7 +4993,9 @@ var FaceStore = class {
|
|
|
4604
4993
|
assigned: Boolean(r.data.assigned),
|
|
4605
4994
|
mediaKey: data.mediaKey ?? void 0,
|
|
4606
4995
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
4607
|
-
assignedSampleId: data.assignedSampleId ?? void 0
|
|
4996
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
4997
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
4998
|
+
faceBbox: data.faceBbox ?? void 0
|
|
4608
4999
|
};
|
|
4609
5000
|
}).filter((f) => !f.assigned);
|
|
4610
5001
|
}
|
|
@@ -4768,8 +5159,11 @@ var FaceStore = class {
|
|
|
4768
5159
|
id: faceId,
|
|
4769
5160
|
...data,
|
|
4770
5161
|
assigned: Boolean(raw.assigned),
|
|
5162
|
+
mediaKey: data.mediaKey ?? void 0,
|
|
4771
5163
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
4772
|
-
assignedSampleId: data.assignedSampleId ?? void 0
|
|
5164
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
5165
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
5166
|
+
faceBbox: data.faceBbox ?? void 0
|
|
4773
5167
|
};
|
|
4774
5168
|
}
|
|
4775
5169
|
/**
|
|
@@ -4813,7 +5207,9 @@ var FaceStore = class {
|
|
|
4813
5207
|
assigned: Boolean(r.data.assigned),
|
|
4814
5208
|
mediaKey: data.mediaKey ?? void 0,
|
|
4815
5209
|
recognizedIdentityId: data.recognizedIdentityId ?? void 0,
|
|
4816
|
-
assignedSampleId: data.assignedSampleId ?? void 0
|
|
5210
|
+
assignedSampleId: data.assignedSampleId ?? void 0,
|
|
5211
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
|
|
5212
|
+
faceBbox: data.faceBbox ?? void 0
|
|
4817
5213
|
};
|
|
4818
5214
|
});
|
|
4819
5215
|
const filterMode = input.filter ?? "all";
|
|
@@ -4878,6 +5274,10 @@ var OBJECT_EMBEDDING_COLUMNS = [
|
|
|
4878
5274
|
{
|
|
4879
5275
|
name: "mediaKey",
|
|
4880
5276
|
type: "TEXT"
|
|
5277
|
+
},
|
|
5278
|
+
{
|
|
5279
|
+
name: "keyFrameMediaKey",
|
|
5280
|
+
type: "TEXT"
|
|
4881
5281
|
}
|
|
4882
5282
|
];
|
|
4883
5283
|
var ObjectEmbeddingStore = class {
|
|
@@ -4924,7 +5324,8 @@ var ObjectEmbeddingStore = class {
|
|
|
4924
5324
|
modelId: input.modelId,
|
|
4925
5325
|
dim: input.embedding.length,
|
|
4926
5326
|
confidence: input.confidence,
|
|
4927
|
-
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {}
|
|
5327
|
+
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {},
|
|
5328
|
+
...input.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: input.keyFrameMediaKey } : {}
|
|
4928
5329
|
};
|
|
4929
5330
|
try {
|
|
4930
5331
|
await this.store.set.mutate({
|
|
@@ -4962,7 +5363,8 @@ var ObjectEmbeddingStore = class {
|
|
|
4962
5363
|
return {
|
|
4963
5364
|
id: r.id,
|
|
4964
5365
|
...data,
|
|
4965
|
-
mediaKey: data.mediaKey ?? void 0
|
|
5366
|
+
mediaKey: data.mediaKey ?? void 0,
|
|
5367
|
+
keyFrameMediaKey: data.keyFrameMediaKey ?? void 0
|
|
4966
5368
|
};
|
|
4967
5369
|
});
|
|
4968
5370
|
}
|
|
@@ -5091,6 +5493,8 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
5091
5493
|
}
|
|
5092
5494
|
//#endregion
|
|
5093
5495
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
5496
|
+
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
5497
|
+
var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
|
|
5094
5498
|
var FaceRecognizer = class {
|
|
5095
5499
|
deps;
|
|
5096
5500
|
gallery = [];
|
|
@@ -5102,6 +5506,9 @@ var FaceRecognizer = class {
|
|
|
5102
5506
|
* true highest-confidence face (holding a buffer in memory is cheap, and a
|
|
5103
5507
|
* track may last well under the best-frame rate-limit window). */
|
|
5104
5508
|
bestTracker = new BestDetectionTracker();
|
|
5509
|
+
/** Throttle for the "dropping imageless track" log — one line per minute at
|
|
5510
|
+
* most, so a busy scene that never produces a face crop can't flood logs. */
|
|
5511
|
+
lastImagelessLogAt = 0;
|
|
5105
5512
|
constructor(deps) {
|
|
5106
5513
|
this.deps = deps;
|
|
5107
5514
|
}
|
|
@@ -5231,6 +5638,17 @@ var FaceRecognizer = class {
|
|
|
5231
5638
|
}
|
|
5232
5639
|
});
|
|
5233
5640
|
}
|
|
5641
|
+
try {
|
|
5642
|
+
await this.deps.recomputeImportance?.(work.trackId);
|
|
5643
|
+
} catch (err) {
|
|
5644
|
+
this.deps.logger.warn("recomputeImportance failed", {
|
|
5645
|
+
tags: { deviceId: input.deviceId },
|
|
5646
|
+
meta: {
|
|
5647
|
+
trackId: work.trackId,
|
|
5648
|
+
error: String(err)
|
|
5649
|
+
}
|
|
5650
|
+
});
|
|
5651
|
+
}
|
|
5234
5652
|
}
|
|
5235
5653
|
}
|
|
5236
5654
|
/**
|
|
@@ -5250,9 +5668,23 @@ var FaceRecognizer = class {
|
|
|
5250
5668
|
} });
|
|
5251
5669
|
return;
|
|
5252
5670
|
}
|
|
5671
|
+
if (held.crop === void 0) {
|
|
5672
|
+
const now = Date.now();
|
|
5673
|
+
if (now - this.lastImagelessLogAt >= FACE_IMAGELESS_LOG_THROTTLE_MS) {
|
|
5674
|
+
this.lastImagelessLogAt = now;
|
|
5675
|
+
this.deps.logger.info("face: dropping imageless track (no crop captured)", {
|
|
5676
|
+
tags: {
|
|
5677
|
+
deviceId,
|
|
5678
|
+
trackId
|
|
5679
|
+
},
|
|
5680
|
+
meta: { score: held.score }
|
|
5681
|
+
});
|
|
5682
|
+
}
|
|
5683
|
+
return;
|
|
5684
|
+
}
|
|
5253
5685
|
const faceId = `face-${trackId}`;
|
|
5254
5686
|
let mediaKey;
|
|
5255
|
-
|
|
5687
|
+
try {
|
|
5256
5688
|
mediaKey = await this.deps.mediaStore.put({
|
|
5257
5689
|
deviceId,
|
|
5258
5690
|
ownerKind: "face",
|
|
@@ -5270,6 +5702,17 @@ var FaceRecognizer = class {
|
|
|
5270
5702
|
}
|
|
5271
5703
|
});
|
|
5272
5704
|
}
|
|
5705
|
+
if (mediaKey === void 0) {
|
|
5706
|
+
this.deps.logger.warn("face: crop store failed — dropping face row", {
|
|
5707
|
+
tags: {
|
|
5708
|
+
deviceId,
|
|
5709
|
+
trackId
|
|
5710
|
+
},
|
|
5711
|
+
meta: { faceId }
|
|
5712
|
+
});
|
|
5713
|
+
return;
|
|
5714
|
+
}
|
|
5715
|
+
const keyFrameMediaKey = this.deps.getKeyFrameMediaKey?.(trackId);
|
|
5273
5716
|
try {
|
|
5274
5717
|
await this.deps.faceStore.insert({
|
|
5275
5718
|
id: faceId,
|
|
@@ -5277,9 +5720,11 @@ var FaceRecognizer = class {
|
|
|
5277
5720
|
trackId,
|
|
5278
5721
|
timestamp: held.timestamp,
|
|
5279
5722
|
embedding: held.embedding,
|
|
5280
|
-
|
|
5723
|
+
mediaKey,
|
|
5281
5724
|
...held.recognizedIdentityId !== void 0 ? { recognizedIdentityId: held.recognizedIdentityId } : {},
|
|
5282
|
-
assigned: false
|
|
5725
|
+
assigned: false,
|
|
5726
|
+
faceBbox: held.bbox,
|
|
5727
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
5283
5728
|
});
|
|
5284
5729
|
this.deps.logger.info("face: buffered to gallery", {
|
|
5285
5730
|
tags: {
|
|
@@ -5288,7 +5733,7 @@ var FaceRecognizer = class {
|
|
|
5288
5733
|
},
|
|
5289
5734
|
meta: {
|
|
5290
5735
|
faceId,
|
|
5291
|
-
hasCrop:
|
|
5736
|
+
hasCrop: true,
|
|
5292
5737
|
recognizedIdentityId: held.recognizedIdentityId ?? null,
|
|
5293
5738
|
score: held.score
|
|
5294
5739
|
}
|
|
@@ -5620,6 +6065,38 @@ var PlateRecognizer = class {
|
|
|
5620
6065
|
}
|
|
5621
6066
|
};
|
|
5622
6067
|
//#endregion
|
|
6068
|
+
//#region src/shared/frame/shared-frame-resolver.ts
|
|
6069
|
+
function frameHandleKey(h) {
|
|
6070
|
+
return `${h.nodeId}:${h.shmId}:${h.slot}:${h.seq}`;
|
|
6071
|
+
}
|
|
6072
|
+
function createSharedFrameResolver(resolve) {
|
|
6073
|
+
let cache = null;
|
|
6074
|
+
return (handle) => {
|
|
6075
|
+
const key = frameHandleKey(handle);
|
|
6076
|
+
if (cache === null || cache.key !== key) cache = {
|
|
6077
|
+
key,
|
|
6078
|
+
value: resolve(handle)
|
|
6079
|
+
};
|
|
6080
|
+
return cache.value;
|
|
6081
|
+
};
|
|
6082
|
+
}
|
|
6083
|
+
//#endregion
|
|
6084
|
+
//#region src/shared/frame/encode-crop.ts
|
|
6085
|
+
/**
|
|
6086
|
+
* JPEG-encode an already-cropped raw RGB (24-bit) buffer. Used for the
|
|
6087
|
+
* NATIVE-resolution crop path: the decode worker returns the exact ROI pixels
|
|
6088
|
+
* at native res, so there is nothing left to crop — only to encode to the same
|
|
6089
|
+
* JPEG contract `extractCrop` produces (quality 90). Kept separate from
|
|
6090
|
+
* `extractCrop` (which crops a FULL frame) so the native path never re-crops.
|
|
6091
|
+
*/
|
|
6092
|
+
async function encodeRgbCropToJpeg(bytes, width, height) {
|
|
6093
|
+
return sharp(Buffer.from(bytes), { raw: {
|
|
6094
|
+
width,
|
|
6095
|
+
height,
|
|
6096
|
+
channels: 3
|
|
6097
|
+
} }).jpeg({ quality: 90 }).toBuffer();
|
|
6098
|
+
}
|
|
6099
|
+
//#endregion
|
|
5623
6100
|
//#region src/pipeline-analytics/pipeline/event-child-crops.ts
|
|
5624
6101
|
/**
|
|
5625
6102
|
* Compute Intersection-over-Union between two (x,y,w,h) bounding boxes.
|
|
@@ -5924,6 +6401,18 @@ var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
|
5924
6401
|
var BEST_FRAME_HYSTERESIS = .05;
|
|
5925
6402
|
/** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
|
|
5926
6403
|
var BEST_FRAME_MIN_GAP_MS = 2e3;
|
|
6404
|
+
/** Design B: cap (px) on the width of the native KEY FRAME (full-frame native
|
|
6405
|
+
* capture). Native resolution is the point, but a full 4K RGB surface over the
|
|
6406
|
+
* transport per new-best is wasteful for a web detail view — 1920px keeps a
|
|
6407
|
+
* sharp native frame while bounding the copy (a miss falls back to the
|
|
6408
|
+
* detection-res frame, which is already ≤640px). */
|
|
6409
|
+
var KEYFRAME_NATIVE_MAX_WIDTH = 1920;
|
|
6410
|
+
/** getKeyEvents: max completed tracks pulled from a window before importance
|
|
6411
|
+
* ranking. Ordering is by importance (not firstSeen) and legacy rows score on
|
|
6412
|
+
* read, so we over-fetch candidates and trim to `limit` after sorting. */
|
|
6413
|
+
var KEY_EVENT_CANDIDATE_CAP = 500;
|
|
6414
|
+
/** getKeyEvents: default page size when the caller omits `limit`. */
|
|
6415
|
+
var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
5927
6416
|
/** Cluster setting key (in the centralized addon store) selecting the SINGLE
|
|
5928
6417
|
* node that runs post-analysis (event/media/audio/motion generation). All
|
|
5929
6418
|
* other nodes are fully inert. No multi-node balancing. Default: the hub. */
|
|
@@ -6043,6 +6532,23 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6043
6532
|
hysteresis: BEST_FRAME_HYSTERESIS,
|
|
6044
6533
|
minGapMs: BEST_FRAME_MIN_GAP_MS
|
|
6045
6534
|
});
|
|
6535
|
+
/** Best (highest-confidence) CLIP-object detection per track — drives ONE
|
|
6536
|
+
* tight object-crop capture whose media key is written onto the embedding
|
|
6537
|
+
* row (so a semantic-search hit's thumbnail IS the embedded crop). Shares the
|
|
6538
|
+
* unified best-per-track decision (`TrackBestSelector`); the persistent
|
|
6539
|
+
* cross-restart "best" gate stays in `ObjectEmbeddingStore.upsertIfBetter`. */
|
|
6540
|
+
objectEmbeddingBestSelector = new TrackBestSelector();
|
|
6541
|
+
/** Design B: the track's shared native key-frame media key, captured at the
|
|
6542
|
+
* best-detection moment (object-embedding best path). Read by the face path
|
|
6543
|
+
* at track end so a face row links to the SAME single key frame. Cleared on
|
|
6544
|
+
* track end. */
|
|
6545
|
+
keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
6546
|
+
/** The shared crop extractor (native-res first, detection-frame fallback),
|
|
6547
|
+
* captured in the constructor so `processFrame` can crop object thumbnails in
|
|
6548
|
+
* the same live-frame window as the face/plate/event-media captures. The
|
|
6549
|
+
* optional `maxWidth` caps the native crop width (used for the full-frame key
|
|
6550
|
+
* frame so a 4K native surface never floods the transport). */
|
|
6551
|
+
captureCrop = null;
|
|
6046
6552
|
shuttingDown = false;
|
|
6047
6553
|
/** True only on the cluster's designated post-processing node. When false the
|
|
6048
6554
|
* addon subscribes to NOTHING — fully inert (no event/media generation). */
|
|
@@ -6142,23 +6648,62 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6142
6648
|
});
|
|
6143
6649
|
const ownNodeIdForFaces = ownNodeId;
|
|
6144
6650
|
const frameReadersForFaces = this.frameReaders;
|
|
6145
|
-
const
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6651
|
+
const pipelineRunnerApi = api.pipelineRunner;
|
|
6652
|
+
const cropMetricLogger = logger.child("NativeCrop");
|
|
6653
|
+
let nativeHits = 0;
|
|
6654
|
+
let nativeFallbacks = 0;
|
|
6655
|
+
let lastCropMetricAt = 0;
|
|
6656
|
+
const NATIVE_CROP_METRIC_INTERVAL_MS = 3e4;
|
|
6657
|
+
const bumpCropMetric = (hit) => {
|
|
6658
|
+
if (hit) nativeHits += 1;
|
|
6659
|
+
else nativeFallbacks += 1;
|
|
6660
|
+
const now = Date.now();
|
|
6661
|
+
if (now - lastCropMetricAt < NATIVE_CROP_METRIC_INTERVAL_MS) return;
|
|
6662
|
+
lastCropMetricAt = now;
|
|
6663
|
+
cropMetricLogger.info("native-crop window", { meta: {
|
|
6664
|
+
nativeHits,
|
|
6665
|
+
detectionFrameFallbacks: nativeFallbacks
|
|
6666
|
+
} });
|
|
6667
|
+
};
|
|
6668
|
+
const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
|
|
6669
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
6670
|
+
try {
|
|
6671
|
+
const native = await pipelineRunnerApi.getNativeCrop.query({
|
|
6672
|
+
handle: frameHandle,
|
|
6673
|
+
bbox: paddedNorm,
|
|
6674
|
+
...maxWidth !== void 0 ? { maxWidth } : {}
|
|
6675
|
+
}, nodePin(frameHandle.nodeId));
|
|
6676
|
+
if (!native || native.width <= 0 || native.height <= 0) return null;
|
|
6677
|
+
return await encodeRgbCropToJpeg(Buffer.from(native.bytes), native.width, native.height);
|
|
6678
|
+
} catch (err) {
|
|
6679
|
+
cropMetricLogger.debug("native crop request failed — falling back", { meta: { error: errMsg(err) } });
|
|
6680
|
+
return null;
|
|
6681
|
+
}
|
|
6682
|
+
};
|
|
6683
|
+
const resolveFrameShared = createSharedFrameResolver((frameHandle) => resolveFrame(frameHandle, {
|
|
6684
|
+
ownNodeId: ownNodeIdForFaces,
|
|
6685
|
+
readers: frameReadersForFaces,
|
|
6686
|
+
getRemoteFrame
|
|
6687
|
+
}));
|
|
6688
|
+
const captureCrop = async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
6153
6689
|
const paddedNorm = padBbox({
|
|
6154
6690
|
x: bbox.x / frameWidth,
|
|
6155
6691
|
y: bbox.y / frameHeight,
|
|
6156
6692
|
w: bbox.w / frameWidth,
|
|
6157
6693
|
h: bbox.h / frameHeight
|
|
6158
6694
|
}, padding);
|
|
6159
|
-
const
|
|
6695
|
+
const nativeCrop = await tryNativeCrop(frameHandle, paddedNorm, maxWidth);
|
|
6696
|
+
if (nativeCrop) {
|
|
6697
|
+
bumpCropMetric(true);
|
|
6698
|
+
return nativeCrop;
|
|
6699
|
+
}
|
|
6700
|
+
bumpCropMetric(false);
|
|
6701
|
+
const decoded = await resolveFrameShared(frameHandle);
|
|
6702
|
+
if (!decoded || decoded.format !== "rgb") return null;
|
|
6703
|
+
const { crop } = await extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
|
|
6160
6704
|
return crop;
|
|
6161
6705
|
};
|
|
6706
|
+
this.captureCrop = captureCrop;
|
|
6162
6707
|
this.faceRecognizer = new FaceRecognizer({
|
|
6163
6708
|
identityStore: this.identityStore,
|
|
6164
6709
|
faceStore: this.faceStore,
|
|
@@ -6166,6 +6711,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6166
6711
|
trackStore: this.trackStore,
|
|
6167
6712
|
eventStore: this.eventStore,
|
|
6168
6713
|
captureCrop,
|
|
6714
|
+
recomputeImportance: (trackId) => {
|
|
6715
|
+
const trackStore = this.trackStore;
|
|
6716
|
+
const eventStore = this.eventStore;
|
|
6717
|
+
if (!trackStore || !eventStore) return Promise.resolve();
|
|
6718
|
+
return recomputeTrackImportance({
|
|
6719
|
+
trackStore,
|
|
6720
|
+
eventStore
|
|
6721
|
+
}, trackId);
|
|
6722
|
+
},
|
|
6723
|
+
getKeyFrameMediaKey: (trackId) => this.keyFrameKeyByTrackId.get(trackId),
|
|
6169
6724
|
logger: logger.child("FaceRecognizer")
|
|
6170
6725
|
});
|
|
6171
6726
|
this.faceRecognizer.refreshGallery();
|
|
@@ -6203,7 +6758,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6203
6758
|
try {
|
|
6204
6759
|
const handler = createEventMediaHandler({ getMedia: async (id) => {
|
|
6205
6760
|
try {
|
|
6206
|
-
return await this.
|
|
6761
|
+
return await this.readMediaByEventOrKey(id);
|
|
6207
6762
|
} catch (err) {
|
|
6208
6763
|
this.ctx.logger.warn("readEventThumbnail failed", { meta: {
|
|
6209
6764
|
eventId: id,
|
|
@@ -6587,6 +7142,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6587
7142
|
this.lastActiveTrackIds.clear();
|
|
6588
7143
|
this.dropoutSkipsByKey.clear();
|
|
6589
7144
|
this.bestFrameTracker.clear();
|
|
7145
|
+
this.objectEmbeddingBestSelector.clear();
|
|
6590
7146
|
this.levelStateByDevice.clear();
|
|
6591
7147
|
this.settingsCacheByDevice.clear();
|
|
6592
7148
|
this.sensitivityCacheByDevice.clear();
|
|
@@ -6742,16 +7298,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6742
7298
|
} });
|
|
6743
7299
|
}
|
|
6744
7300
|
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
6745
|
-
|
|
6746
|
-
|
|
7301
|
+
const objectEmbeddingBests = [];
|
|
7302
|
+
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
7303
|
+
if (!isClipObjectEmbedding(t)) continue;
|
|
7304
|
+
if (this.objectEmbeddingBestSelector.observe({
|
|
6747
7305
|
trackId: t.trackId,
|
|
6748
|
-
|
|
6749
|
-
|
|
7306
|
+
confidence: t.confidence,
|
|
7307
|
+
atMs: result.timestamp,
|
|
6750
7308
|
className: t.className,
|
|
7309
|
+
bbox: t.bbox,
|
|
6751
7310
|
embedding: t.embedding,
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
});
|
|
7311
|
+
embeddingModelId: t.embeddingModelId
|
|
7312
|
+
})) objectEmbeddingBests.push(t);
|
|
6755
7313
|
}
|
|
6756
7314
|
const faceSettings = this.faceRecognizer ? await this.resolveDeviceFaceSettings(deviceId) : null;
|
|
6757
7315
|
const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
|
|
@@ -6824,6 +7382,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6824
7382
|
cropPadding: mediaSettings.cropPadding,
|
|
6825
7383
|
...frameHandle !== void 0 ? { frameHandle } : {}
|
|
6826
7384
|
});
|
|
7385
|
+
if (objectEmbeddingBests.length > 0) this.persistObjectEmbeddingBests(deviceId, result.timestamp, objectEmbeddingBests, frameHandle, result.frameWidth, result.frameHeight, mediaSettings.cropPadding);
|
|
6827
7386
|
for (const e of result.objectEvents) this.ctx.eventBus.emit({
|
|
6828
7387
|
id: `pa-${e.id}`,
|
|
6829
7388
|
timestamp: new Date(e.timestamp),
|
|
@@ -6945,6 +7504,83 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6945
7504
|
* NOT gated by saveThumbnails (a single best still-frame is always useful).
|
|
6946
7505
|
* The per-track best-confidence map is updated here as a side effect.
|
|
6947
7506
|
*/
|
|
7507
|
+
/**
|
|
7508
|
+
* Capture the tight object crop for each new-best CLIP track (native-res via
|
|
7509
|
+
* the shared extractor) and upsert the embedding row with that crop's media
|
|
7510
|
+
* key. The crop uses `putReplacing` so exactly ONE object crop is kept per
|
|
7511
|
+
* track (the current peak). The embedding is upserted even when no crop was
|
|
7512
|
+
* captured (no frame handle) so semantic search still works — the crop just
|
|
7513
|
+
* enhances the search-hit thumbnail. `upsertIfBetter` remains the durable
|
|
7514
|
+
* cross-restart best gate (R3). Best-effort; issued in the live-frame window.
|
|
7515
|
+
*/
|
|
7516
|
+
async persistObjectEmbeddingBests(deviceId, timestamp, bests, frameHandle, frameWidth, frameHeight, cropPadding) {
|
|
7517
|
+
const store = this.objectEmbeddingStore;
|
|
7518
|
+
if (!store) return;
|
|
7519
|
+
await Promise.all(bests.map(async (t) => {
|
|
7520
|
+
if (!isClipObjectEmbedding(t)) return;
|
|
7521
|
+
let mediaKey;
|
|
7522
|
+
let keyFrameMediaKey;
|
|
7523
|
+
if (frameHandle !== void 0 && this.captureCrop && this.mediaStore) {
|
|
7524
|
+
try {
|
|
7525
|
+
const crop = await this.captureCrop(frameHandle, t.bbox, frameWidth, frameHeight, cropPadding);
|
|
7526
|
+
if (crop) mediaKey = await this.mediaStore.putReplacing({
|
|
7527
|
+
deviceId,
|
|
7528
|
+
ownerKind: "track",
|
|
7529
|
+
ownerId: t.trackId,
|
|
7530
|
+
kind: "crop",
|
|
7531
|
+
timestamp,
|
|
7532
|
+
data: crop
|
|
7533
|
+
});
|
|
7534
|
+
} catch (err) {
|
|
7535
|
+
this.ctx.logger.debug("object-embedding crop capture failed", {
|
|
7536
|
+
tags: { deviceId },
|
|
7537
|
+
meta: {
|
|
7538
|
+
trackId: t.trackId,
|
|
7539
|
+
error: errMsg(err)
|
|
7540
|
+
}
|
|
7541
|
+
});
|
|
7542
|
+
}
|
|
7543
|
+
try {
|
|
7544
|
+
const keyFrame = await this.captureCrop(frameHandle, {
|
|
7545
|
+
x: 0,
|
|
7546
|
+
y: 0,
|
|
7547
|
+
w: frameWidth,
|
|
7548
|
+
h: frameHeight
|
|
7549
|
+
}, frameWidth, frameHeight, 0, KEYFRAME_NATIVE_MAX_WIDTH);
|
|
7550
|
+
if (keyFrame) {
|
|
7551
|
+
keyFrameMediaKey = await this.mediaStore.putReplacing({
|
|
7552
|
+
deviceId,
|
|
7553
|
+
ownerKind: "track",
|
|
7554
|
+
ownerId: t.trackId,
|
|
7555
|
+
kind: "keyFrame",
|
|
7556
|
+
timestamp,
|
|
7557
|
+
data: keyFrame
|
|
7558
|
+
});
|
|
7559
|
+
this.keyFrameKeyByTrackId.set(t.trackId, keyFrameMediaKey);
|
|
7560
|
+
}
|
|
7561
|
+
} catch (err) {
|
|
7562
|
+
this.ctx.logger.debug("key-frame capture failed", {
|
|
7563
|
+
tags: { deviceId },
|
|
7564
|
+
meta: {
|
|
7565
|
+
trackId: t.trackId,
|
|
7566
|
+
error: errMsg(err)
|
|
7567
|
+
}
|
|
7568
|
+
});
|
|
7569
|
+
}
|
|
7570
|
+
}
|
|
7571
|
+
await store.upsertIfBetter({
|
|
7572
|
+
trackId: t.trackId,
|
|
7573
|
+
deviceId,
|
|
7574
|
+
timestamp,
|
|
7575
|
+
className: t.className,
|
|
7576
|
+
embedding: t.embedding,
|
|
7577
|
+
modelId: t.embeddingModelId,
|
|
7578
|
+
confidence: t.confidence,
|
|
7579
|
+
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
7580
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
7581
|
+
});
|
|
7582
|
+
}));
|
|
7583
|
+
}
|
|
6948
7584
|
buildSnapshotTargets(tracked, timestamp, media) {
|
|
6949
7585
|
const targets = [];
|
|
6950
7586
|
for (const t of tracked) {
|
|
@@ -7216,9 +7852,36 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7216
7852
|
positions: t.positions.length
|
|
7217
7853
|
}
|
|
7218
7854
|
});
|
|
7219
|
-
this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7855
|
+
const faceEnd = this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7856
|
+
const dropKeyFrameKey = () => {
|
|
7857
|
+
this.keyFrameKeyByTrackId.delete(t.trackId);
|
|
7858
|
+
};
|
|
7859
|
+
if (faceEnd) faceEnd.finally(dropKeyFrameKey);
|
|
7860
|
+
else dropKeyFrameKey();
|
|
7220
7861
|
this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
|
|
7862
|
+
try {
|
|
7863
|
+
const peak = await this.eventStore?.peakForTrack(t.trackId);
|
|
7864
|
+
if (peak) {
|
|
7865
|
+
const { importance, reason } = computeImportance({
|
|
7866
|
+
peakConfidence: this.bestFrameTracker.peak(t.trackId)?.confidence ?? peak.peakConfidence,
|
|
7867
|
+
className: t.className,
|
|
7868
|
+
durationMs: duration,
|
|
7869
|
+
peakBboxAreaFrac: peak.peakBboxAreaFrac,
|
|
7870
|
+
totalDistance: t.totalDistance,
|
|
7871
|
+
zonesVisited: t.zonesVisited,
|
|
7872
|
+
...t.label !== void 0 ? { label: t.label } : {}
|
|
7873
|
+
});
|
|
7874
|
+
await this.trackStore?.setImportance(t.trackId, importance, reason, peak.bestEventId);
|
|
7875
|
+
if (peak.bestEventId !== void 0) await this.eventStore?.setImportanceForTrack(t.trackId, importance);
|
|
7876
|
+
}
|
|
7877
|
+
} catch (err) {
|
|
7878
|
+
this.ctx.logger.debug("importance scoring failed", { meta: {
|
|
7879
|
+
trackId: t.trackId,
|
|
7880
|
+
error: String(err)
|
|
7881
|
+
} });
|
|
7882
|
+
}
|
|
7221
7883
|
this.bestFrameTracker.delete(t.trackId);
|
|
7884
|
+
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
7222
7885
|
this.ctx.eventBus.emit({
|
|
7223
7886
|
id: `pa-end-${t.trackId}`,
|
|
7224
7887
|
timestamp: new Date(t.lastSeen),
|
|
@@ -7441,6 +8104,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7441
8104
|
if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
|
|
7442
8105
|
return this.withMediaUrl(events);
|
|
7443
8106
|
}
|
|
8107
|
+
async getKeyEvents(input) {
|
|
8108
|
+
const trackStore = this.trackStore;
|
|
8109
|
+
if (!trackStore) return [];
|
|
8110
|
+
const eventStore = this.eventStore;
|
|
8111
|
+
try {
|
|
8112
|
+
const candidates = await trackStore.queryHistorical({
|
|
8113
|
+
deviceId: input.deviceId,
|
|
8114
|
+
since: input.since,
|
|
8115
|
+
until: input.until,
|
|
8116
|
+
limit: KEY_EVENT_CANDIDATE_CAP
|
|
8117
|
+
});
|
|
8118
|
+
const peakLookup = (trackId) => eventStore ? eventStore.peakForTrack(trackId) : Promise.resolve({
|
|
8119
|
+
peakConfidence: 0,
|
|
8120
|
+
peakBboxAreaFrac: 0,
|
|
8121
|
+
bestEventId: void 0
|
|
8122
|
+
});
|
|
8123
|
+
return await rankKeyEvents(candidates, {
|
|
8124
|
+
limit: input.limit ?? KEY_EVENT_DEFAULT_LIMIT,
|
|
8125
|
+
...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
|
|
8126
|
+
...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
|
|
8127
|
+
}, peakLookup);
|
|
8128
|
+
} catch (err) {
|
|
8129
|
+
this.ctx.logger.debug("getKeyEvents failed", { meta: { error: String(err) } });
|
|
8130
|
+
return [];
|
|
8131
|
+
}
|
|
8132
|
+
}
|
|
7444
8133
|
async getAudioEvents(input) {
|
|
7445
8134
|
const events = await (this.eventStore?.queryAudio(input) ?? Promise.resolve([]));
|
|
7446
8135
|
if (input.projection !== "slim" || this.eventMediaBaseUrl === null) return events;
|
|
@@ -7483,6 +8172,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7483
8172
|
className: input.classFilter
|
|
7484
8173
|
});
|
|
7485
8174
|
if (embeddingRows.length === 0) return [];
|
|
8175
|
+
const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();
|
|
8176
|
+
const keyFrameKeyByTrackId = /* @__PURE__ */ new Map();
|
|
8177
|
+
for (const row of embeddingRows) {
|
|
8178
|
+
if (row.mediaKey !== void 0) embeddingMediaKeyByTrackId.set(row.trackId, row.mediaKey);
|
|
8179
|
+
if (row.keyFrameMediaKey !== void 0) keyFrameKeyByTrackId.set(row.trackId, row.keyFrameMediaKey);
|
|
8180
|
+
}
|
|
7486
8181
|
const encoderInfo = await api.embeddingEncoder.getInfo.query().catch((err) => {
|
|
7487
8182
|
this.ctx.logger.warn("searchObjectEvents: getInfo failed, proceeding without modelId gate", { meta: { error: String(err) } });
|
|
7488
8183
|
return null;
|
|
@@ -7529,10 +8224,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7529
8224
|
score
|
|
7530
8225
|
});
|
|
7531
8226
|
}
|
|
7532
|
-
for (const { event, score } of bestEventByTrackId.values())
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
8227
|
+
for (const { event, score } of bestEventByTrackId.values()) {
|
|
8228
|
+
const mediaUrl = resolveSearchThumbnailUrl({
|
|
8229
|
+
baseUrl: this.eventMediaBaseUrl,
|
|
8230
|
+
eventId: event.id,
|
|
8231
|
+
...event.trackId !== void 0 && embeddingMediaKeyByTrackId.has(event.trackId) ? { embeddingMediaKey: embeddingMediaKeyByTrackId.get(event.trackId) } : {}
|
|
8232
|
+
});
|
|
8233
|
+
const keyFrameMediaKey = event.trackId !== void 0 ? keyFrameKeyByTrackId.get(event.trackId) : void 0;
|
|
8234
|
+
scored.push({
|
|
8235
|
+
...event,
|
|
8236
|
+
score,
|
|
8237
|
+
...mediaUrl !== void 0 ? { mediaUrl } : {},
|
|
8238
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
8239
|
+
});
|
|
8240
|
+
}
|
|
7536
8241
|
}
|
|
7537
8242
|
scored.sort((a, b) => b.score - a.score);
|
|
7538
8243
|
return scored.slice(0, input.limit);
|
|
@@ -7564,6 +8269,24 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7564
8269
|
* (native-res boxed frame) → any available file. Returns `null` if the
|
|
7565
8270
|
* event has no media at all.
|
|
7566
8271
|
*/
|
|
8272
|
+
/**
|
|
8273
|
+
* Data-plane resolver: an id is EITHER a bare event id (a UUID → the event's
|
|
8274
|
+
* crop, today's behaviour) OR a MediaStore key (`ownerKind:ownerId:kind:ts`,
|
|
8275
|
+
* contains ':' → served directly by key). The object-embedding search hit
|
|
8276
|
+
* points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
|
|
8277
|
+
* key), so this resolves that crop; event ids stay on the event-crop path.
|
|
8278
|
+
*/
|
|
8279
|
+
async readMediaByEventOrKey(id) {
|
|
8280
|
+
if (id.includes(":")) {
|
|
8281
|
+
const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
|
|
8282
|
+
if (!file) return null;
|
|
8283
|
+
return {
|
|
8284
|
+
bytes: Buffer.from(file.base64, "base64"),
|
|
8285
|
+
key: file.key
|
|
8286
|
+
};
|
|
8287
|
+
}
|
|
8288
|
+
return this.readEventThumbnail(id);
|
|
8289
|
+
}
|
|
7567
8290
|
async readEventThumbnail(eventId) {
|
|
7568
8291
|
const files = await (this.mediaStore?.listByOwner("event", eventId) ?? Promise.resolve([]));
|
|
7569
8292
|
const chosen = files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
|