@camstack/addon-post-analysis 1.2.115 → 1.2.117

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-C0uPGtBv.js");
5
+ const require_dist = require("../dist-Dk-BdVHw.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");
@@ -15139,28 +15139,38 @@ function selectSummaryTiles(candidates, opts) {
15139
15139
  };
15140
15140
  }
15141
15141
  /**
15142
+ * Ranked leftover after the quota pass — refill a mosaic that dropped
15143
+ * duplicate frames or missing crops without walking the same track twice.
15144
+ */
15145
+ function remainingSummaryCandidates(kept, taken, rank) {
15146
+ const takenIds = new Set(taken.map((c) => c.trackId));
15147
+ return kept.filter((c) => !takenIds.has(c.trackId)).toSorted(byRankDesc(rank));
15148
+ }
15149
+ /**
15142
15150
  * The media a tile is drawn from, best first.
15143
15151
  *
15144
- * `groupCrop` heads the ladder: it is the group's own clean wide-central-square
15145
- * over the union bbox, and a drawn rectangle is a permanent lie in a digest
15146
- * tile. Singletons have no groupCrop and fall through to the member ladder.
15147
- * `fullFrameBoxed` and `firstFrame` stay absent for the same reason.
15152
+ * First-level group crop only (`groupCrop`), then the group's native-frame
15153
+ * pointer, then a member's clean full frame. Member `thumbnail` / plate /
15154
+ * face crops stay off this ladder: a successful plate read overwrites the
15155
+ * vehicle first-level crop under the same `thumbnail` kind, and a digest
15156
+ * tile of a targa is the wrong picture. Boxed frames stay absent: a drawn
15157
+ * rectangle is a permanent lie in a digest tile. Duplicate driveways from
15158
+ * tracker ID churn are collapsed by `frameDedupeKey`, not by hiding the
15159
+ * full-frame fallback.
15148
15160
  */
15149
15161
  var SUMMARY_MEDIA_LADDER = [
15150
15162
  "groupCrop",
15151
- "keyFrameSmall",
15163
+ "nativeFrame",
15152
15164
  "keyFrame",
15153
- "snapshot",
15154
- "fullFrame",
15155
- "thumbnailSmall",
15156
- "thumbnail"
15165
+ "keyFrameSmall",
15166
+ "fullFrame"
15157
15167
  ];
15158
15168
  /**
15159
15169
  * Pick ONE media row for a track — the cheap question first (index rows only),
15160
15170
  * so exactly one blob is ever read.
15161
15171
  *
15162
- * Within a rung the row CLOSEST to the track's peak is chosen; `snapshot` is a
15163
- * filmstrip and its rows accumulate, so "the first one" would systematically
15172
+ * Within a rung the row CLOSEST to the track's peak is chosen; a member may
15173
+ * carry more than one full-frame still, so "the first one" would systematically
15164
15174
  * pick the moment the subject entered rather than the moment it was best seen.
15165
15175
  */
15166
15176
  function pickSummaryMedia(rows, aroundMs) {
@@ -15171,6 +15181,13 @@ function pickSummaryMedia(rows, aroundMs) {
15171
15181
  }
15172
15182
  return null;
15173
15183
  }
15184
+ /**
15185
+ * Same camera, same wall-clock second → same JPEG. Tracker ID churn
15186
+ * otherwise fills a mosaic with eleven copies of one driveway still.
15187
+ */
15188
+ function frameDedupeKey(deviceId, mediaTimestamp) {
15189
+ return `${deviceId}:${Math.floor(mediaTimestamp / 1e3)}`;
15190
+ }
15174
15191
  //#endregion
15175
15192
  //#region src/notification-center/summary/summary-window.ts
15176
15193
  /**
@@ -15470,7 +15487,12 @@ var NcSummaryProducer = class {
15470
15487
  droppedStationary: filtered.droppedStationary
15471
15488
  };
15472
15489
  }
15473
- const loaded = await this.loadTiles(rule, selection.tiles);
15490
+ const loaded = await this.loadTiles({
15491
+ rule,
15492
+ preferred: selection.tiles,
15493
+ refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
15494
+ maxTiles: rule.maxTiles
15495
+ });
15474
15496
  const detections = countDetections({ tracks: selection.tiles.map((t) => ({
15475
15497
  firstSeen: t.firstSeen,
15476
15498
  lastSeen: t.lastSeen,
@@ -15528,7 +15550,7 @@ var NcSummaryProducer = class {
15528
15550
  tiles: loaded.length,
15529
15551
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
15530
15552
  camerasDropped: selection.camerasDropped,
15531
- droppedNoMedia: selection.tiles.length - loaded.length,
15553
+ droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
15532
15554
  mosaicBytes: mosaic.bytes,
15533
15555
  mosaicPublished: mosaic.ref !== null,
15534
15556
  enqueued,
@@ -15592,10 +15614,18 @@ var NcSummaryProducer = class {
15592
15614
  }
15593
15615
  return all;
15594
15616
  }
15595
- /** One blob per tile — the cheap question (index rows) always first. */
15596
- async loadTiles(rule, tiles) {
15617
+ /** One blob per tile — the cheap question (index rows) always first.
15618
+ * Duplicate camera-seconds are skipped so tracker ID churn cannot paint
15619
+ * the same driveway eleven times; leftover ranked candidates refill. */
15620
+ async loadTiles(input) {
15597
15621
  const out = [];
15598
- for (const candidate of tiles) {
15622
+ const seenFrames = /* @__PURE__ */ new Set();
15623
+ const seenIds = /* @__PURE__ */ new Set();
15624
+ const queue = [...input.preferred, ...input.refill];
15625
+ for (const candidate of queue) {
15626
+ if (out.length >= input.maxTiles) break;
15627
+ if (seenIds.has(candidate.trackId)) continue;
15628
+ seenIds.add(candidate.trackId);
15599
15629
  const tags = { deviceId: candidate.deviceId };
15600
15630
  try {
15601
15631
  const rows = await this.deps.listMediaInfo(candidate.trackId);
@@ -15604,25 +15634,38 @@ var NcSummaryProducer = class {
15604
15634
  this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
15605
15635
  tags,
15606
15636
  meta: {
15607
- ruleId: rule.id,
15637
+ ruleId: input.rule.id,
15608
15638
  trackId: candidate.trackId,
15609
15639
  mediaRows: rows.length
15610
15640
  }
15611
15641
  });
15612
15642
  continue;
15613
15643
  }
15644
+ const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
15645
+ if (seenFrames.has(frameKey)) {
15646
+ this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
15647
+ tags,
15648
+ meta: {
15649
+ ruleId: input.rule.id,
15650
+ trackId: candidate.trackId,
15651
+ frameKey
15652
+ }
15653
+ });
15654
+ continue;
15655
+ }
15614
15656
  const jpeg = await this.deps.readMedia(picked.key);
15615
15657
  if (jpeg === null || jpeg.byteLength === 0) {
15616
15658
  this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
15617
15659
  tags,
15618
15660
  meta: {
15619
- ruleId: rule.id,
15661
+ ruleId: input.rule.id,
15620
15662
  trackId: candidate.trackId,
15621
15663
  mediaKey: picked.key
15622
15664
  }
15623
15665
  });
15624
15666
  continue;
15625
15667
  }
15668
+ seenFrames.add(frameKey);
15626
15669
  out.push({
15627
15670
  candidate,
15628
15671
  jpeg
@@ -15631,7 +15674,7 @@ var NcSummaryProducer = class {
15631
15674
  this.deps.logger.warn("summary tile dropped: reading its media threw", {
15632
15675
  tags,
15633
15676
  meta: {
15634
- ruleId: rule.id,
15677
+ ruleId: input.rule.id,
15635
15678
  trackId: candidate.trackId,
15636
15679
  error: String(err)
15637
15680
  }
@@ -39894,8 +39937,9 @@ var OverlayDetailStateStore = class {
39894
39937
  *
39895
39938
  * See docs/superpowers/specs/2026-07-17-package-zones-design.md §3.3 +
39896
39939
  * docs/superpowers/specs/2026-07-17-detection-config-exposure-design.md
39897
- * (surface C). Dwell default is 15s per operator decision (the design's
39898
- * §3.3 draft said 45s).
39940
+ * (surface C). Confirmation is N frames (default 3); dwell default is 0
39941
+ * (an extra bar). The 15 s dwell plus a 5 s round-gap missed a real
39942
+ * parcel on device 615 (2026-08-24 13:40).
39899
39943
  */
39900
39944
  var PackageDropSettingsSchema = require_dist.object({
39901
39945
  /**
@@ -39916,13 +39960,20 @@ var PackageDropSettingsSchema = require_dist.object({
39916
39960
  */
39917
39961
  packageDropEnabled: require_dist.boolean().default(true),
39918
39962
  /**
39919
- * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
39920
- * promoted stationary package counts as a delivery. Kills a bag briefly
39921
- * set down and snatched back. Operator default 15s. Values beyond the
39922
- * stationary promotion window (30s) are satisfied only once the entry
39923
- * has actually been observed that long.
39963
+ * Frames the box must be re-seen on before it is a parked package.
39964
+ * Same bar as tracker `minHits`: one frame is a flicker, two can still
39965
+ * be a passer-by's bag, three is "it stayed". A settle burst of three
39966
+ * frames in ~1 s is enough; no second governor round after the courier
39967
+ * left. Device 615 2026-08-24 13:40 expired at 1 round / 1.5 s dwell.
39924
39968
  */
39925
- packageDropDwellSec: require_dist.number().int().min(0).max(3600).default(15),
39969
+ packageDropMinFrames: require_dist.number().int().min(1).max(20).default(3),
39970
+ /**
39971
+ * Extra OBSERVED dwell (seconds, since first-seen) on top of N frames.
39972
+ * 0 (the default) means the frame count is enough — a courier who drops
39973
+ * and leaves in 2 s still delivers. Raise this to reject a bag briefly
39974
+ * set down and snatched back.
39975
+ */
39976
+ packageDropDwellSec: require_dist.number().int().min(0).max(3600).default(0),
39926
39977
  /** Reject tiny far-field blobs: min bbox area as a fraction of the frame. */
39927
39978
  packageDropMinBboxAreaFrac: require_dist.number().min(0).max(1).default(.004),
39928
39979
  /**
@@ -39940,13 +39991,15 @@ var PackageDropSettingsSchema = require_dist.object({
39940
39991
  packageDropPickupEnabled: require_dist.boolean().default(true),
39941
39992
  /**
39942
39993
  * A parcel needs a deliverer: a stillness candidate only ever promotes to
39943
- * `delivered` if a non-package track (person/vehicle/animal) was active on
39944
- * the device within this many seconds BEFORE the box first appeared — and a
39945
- * departure only counts as `picked-up` with somebody around the
39946
- * disappearance. 0 disables both gates. Device 615's streetlight wedge
39947
- * cycled delivered→picked-up ~23×/night with nobody in frame, ever.
39948
- * Default 300 s: wide enough to absorb the motion-settle governor's cadence
39949
- * (the box is often first SEEN minutes after the courier left).
39994
+ * `delivered` if a non-package track (person/vehicle/animal) was active IN
39995
+ * A CONFIGURED PACKAGE DROP-OFF ZONE within this many seconds BEFORE the
39996
+ * box first appeared — and a departure only counts as `picked-up` with
39997
+ * somebody in that zone around the disappearance. Whole-camera motion (a
39998
+ * walker at the far gate) does not count. 0 disables both gates. Device
39999
+ * 615's streetlight wedge cycled delivered→picked-up ~23×/night with
40000
+ * nobody in the drop-off zone. Default 300 s: wide enough to absorb the
40001
+ * motion-settle governor's cadence (the box is often first SEEN minutes
40002
+ * after the courier left).
39950
40003
  */
39951
40004
  packageDropActivityWindowSec: require_dist.number().int().min(0).max(3600).default(300),
39952
40005
  /**
@@ -39970,6 +40023,7 @@ function resolvePackageDropSettings(raw) {
39970
40023
  };
39971
40024
  return {
39972
40025
  packageDropEnabled: pick("packageDropEnabled"),
40026
+ packageDropMinFrames: pick("packageDropMinFrames"),
39973
40027
  packageDropDwellSec: pick("packageDropDwellSec"),
39974
40028
  packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
39975
40029
  packageDropMaxBboxAreaFrac: pick("packageDropMaxBboxAreaFrac"),
@@ -40227,29 +40281,6 @@ var DeferredBirthRegistry = class {
40227
40281
  this.byDevice.delete(deviceKey);
40228
40282
  }
40229
40283
  };
40230
- var AnalyticsGroupRecordSchema = require_dist.object({
40231
- id: require_dist.string(),
40232
- deviceId: require_dist.number().int(),
40233
- openedAt: require_dist.number().int(),
40234
- closedAt: require_dist.number().int(),
40235
- timestamp: require_dist.number().int(),
40236
- memberCount: require_dist.number().int(),
40237
- memberTrackIds: require_dist.array(require_dist.string()).readonly(),
40238
- className: require_dist.string(),
40239
- classes: require_dist.array(require_dist.string()).readonly(),
40240
- /** Relative event-media path, or null when the group has no picture yet. */
40241
- mediaUrl: require_dist.string().nullable(),
40242
- singleton: require_dist.boolean()
40243
- });
40244
- var AnalyticsGroupMemberSchema = require_dist.object({
40245
- trackId: require_dist.string(),
40246
- deviceId: require_dist.number().int(),
40247
- className: require_dist.string(),
40248
- firstSeen: require_dist.number().int(),
40249
- lastSeen: require_dist.number().int(),
40250
- mediaUrl: require_dist.string().nullable()
40251
- });
40252
- var AnalyticsGroupDetailSchema = AnalyticsGroupRecordSchema.extend({ members: require_dist.array(AnalyticsGroupMemberSchema).readonly() });
40253
40284
  var ListGroupsInputSchema = require_dist.object({
40254
40285
  deviceId: require_dist.number().int().optional(),
40255
40286
  sinceMs: require_dist.number().int().optional(),
@@ -40259,66 +40290,11 @@ var ListGroupsInputSchema = require_dist.object({
40259
40290
  });
40260
40291
  var groupActions = require_dist.defineCustomActions({
40261
40292
  "analytics.listGroups": require_dist.customAction(ListGroupsInputSchema, require_dist.object({
40262
- groups: require_dist.array(AnalyticsGroupRecordSchema).readonly(),
40293
+ groups: require_dist.array(require_dist.AnalyticsGroupRecordSchema).readonly(),
40263
40294
  hasMore: require_dist.boolean()
40264
40295
  })),
40265
- "analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: AnalyticsGroupDetailSchema.nullable() }))
40296
+ "analytics.getGroup": require_dist.customAction(require_dist.object({ groupId: require_dist.string().min(1) }), require_dist.object({ group: require_dist.AnalyticsGroupDetailSchema.nullable() }))
40266
40297
  });
40267
- //#endregion
40268
- //#region src/pipeline-analytics/group/group-query.ts
40269
- /**
40270
- * Stamp a relative event-media URL.
40271
- *
40272
- * Media keys contain `:` and the data-plane serves them by key. Track ids are
40273
- * bare and fall through to the existing event/track thumbnail resolver. Never
40274
- * percent-encode: the handler does not URL-decode the path.
40275
- */
40276
- function stampEventMediaUrl(baseUrl, id) {
40277
- if (baseUrl === null || baseUrl.length === 0) return null;
40278
- if (id === null || id === void 0 || id.length === 0) return null;
40279
- return `${baseUrl}/${id}`;
40280
- }
40281
- /** Union of every class on the members; `className` is the first, stable order. */
40282
- function aggregateGroupClasses(tracks) {
40283
- const seen = /* @__PURE__ */ new Set();
40284
- const classes = [];
40285
- for (const track of tracks) for (const name of [track.className, ...track.classes ?? []]) {
40286
- if (name.length === 0 || seen.has(name)) continue;
40287
- seen.add(name);
40288
- classes.push(name);
40289
- }
40290
- return {
40291
- className: classes[0] ?? "unknown",
40292
- classes
40293
- };
40294
- }
40295
- function toGroupRecord(input) {
40296
- const { className, classes } = aggregateGroupClasses(input.tracks);
40297
- const mediaId = input.group.mediaKey ?? (input.group.memberCount === 1 ? input.memberTrackIds[0] : null);
40298
- return {
40299
- id: input.group.id,
40300
- deviceId: input.group.deviceId,
40301
- openedAt: input.group.openedAt,
40302
- closedAt: input.group.closedAt,
40303
- timestamp: input.group.timestamp,
40304
- memberCount: input.memberTrackIds.length > 0 ? input.memberTrackIds.length : input.group.memberCount,
40305
- memberTrackIds: [...input.memberTrackIds],
40306
- className,
40307
- classes: [...classes],
40308
- mediaUrl: stampEventMediaUrl(input.eventMediaBaseUrl, mediaId),
40309
- singleton: input.memberTrackIds.length <= 1
40310
- };
40311
- }
40312
- function toGroupMember(member, track, eventMediaBaseUrl) {
40313
- return {
40314
- trackId: member.trackId,
40315
- deviceId: member.deviceId,
40316
- className: track?.className ?? "unknown",
40317
- firstSeen: track?.firstSeen ?? member.timestamp,
40318
- lastSeen: track?.lastSeen ?? member.timestamp,
40319
- mediaUrl: stampEventMediaUrl(eventMediaBaseUrl, member.trackId)
40320
- };
40321
- }
40322
40298
  function trackToLite(track) {
40323
40299
  return {
40324
40300
  trackId: track.trackId,
@@ -40328,116 +40304,43 @@ function trackToLite(track) {
40328
40304
  lastSeen: track.lastSeen
40329
40305
  };
40330
40306
  }
40331
- async function listGroupRecords(ports, input) {
40332
- const page = await ports.groupStore.listRecent(input);
40333
- const groups = [];
40334
- for (const group of page.groups) groups.push(await buildGroupRecord(ports, group));
40335
- return {
40336
- groups,
40337
- hasMore: page.hasMore
40338
- };
40339
- }
40340
- async function getGroupDetail(ports, groupId) {
40341
- const group = await ports.groupStore.get(groupId);
40342
- if (group === null) return null;
40343
- const members = await ports.groupStore.listMembers(groupId);
40344
- const lites = [];
40345
- const rows = [];
40346
- for (const member of members) {
40347
- const track = await ports.loadTrack(member.trackId);
40348
- const lite = track === null ? null : trackToLite(track);
40349
- if (lite !== null) lites.push(lite);
40350
- rows.push(toGroupMember(member, lite, ports.eventMediaBaseUrl()));
40351
- }
40352
- return {
40353
- ...toGroupRecord({
40354
- group,
40355
- memberTrackIds: members.map((m) => m.trackId),
40356
- tracks: lites,
40357
- eventMediaBaseUrl: ports.eventMediaBaseUrl()
40358
- }),
40359
- members: rows
40360
- };
40361
- }
40362
- async function buildGroupRecord(ports, group) {
40363
- const members = await ports.groupStore.listMembers(group.id);
40364
- const lites = [];
40365
- for (const member of members) {
40366
- const track = await ports.loadTrack(member.trackId);
40367
- if (track !== null) lites.push(trackToLite(track));
40368
- }
40369
- return toGroupRecord({
40370
- group,
40371
- memberTrackIds: members.map((m) => m.trackId),
40372
- tracks: lites,
40373
- eventMediaBaseUrl: ports.eventMediaBaseUrl()
40307
+ /**
40308
+ * Gallery and digest read the same ladder: groupCrop, then the native-frame
40309
+ * pointer, then a member's clean full frame. Plate / face / member
40310
+ * thumbnails never reach the picker — a plate read overwrites the vehicle
40311
+ * first-level crop under the same `thumbnail` kind. A multi with no crop
40312
+ * still has a picture if the sealer stored `nativeKey`.
40313
+ */
40314
+ var DIGEST_MEMBER_FALLBACK_KINDS = new Set([
40315
+ "keyFrame",
40316
+ "keyFrameSmall",
40317
+ "fullFrame"
40318
+ ]);
40319
+ function digestMediaRows(input) {
40320
+ const owned = [];
40321
+ if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
40322
+ key: input.mediaKey,
40323
+ kind: "groupCrop",
40324
+ timestamp: input.timestamp
40374
40325
  });
40375
- }
40376
- //#endregion
40377
- //#region src/pipeline-analytics/group/group-crop.ts
40378
- /** Always containSubject: true. Never the 0.5 delivered threshold. */
40379
- function groupWideCentralSquareLayout(unionBbox, frame) {
40380
- return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40381
- }
40382
- async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40383
- const layout = groupWideCentralSquareLayout(unionBbox, {
40384
- W: frameWidth,
40385
- H: frameHeight
40326
+ if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
40327
+ key: input.nativeKey,
40328
+ kind: "nativeFrame",
40329
+ timestamp: input.timestamp
40386
40330
  });
40387
- const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40388
- return composeWideCentralSquareThumbnail(slab, layout);
40331
+ if (owned.length > 0) return owned;
40332
+ return input.memberRows.filter((row) => DIGEST_MEMBER_FALLBACK_KINDS.has(row.kind));
40389
40333
  }
40390
40334
  var MATCH_WINDOW_MS = 1e3;
40391
40335
  var MOVE_EPS = .35;
40336
+ /** Live-assigner window: same predicate, recent positions only. */
40337
+ var LIVE_WINDOW_MS = 3e3;
40392
40338
  /** Registry stationary is out; audio has no bbox and is never a member or a singleton. */
40393
40339
  function isEligibleMember(track) {
40394
40340
  if (track.closedReason === "stationary-promotion") return false;
40395
40341
  if (track.className === "audio") return false;
40396
40342
  return true;
40397
40343
  }
40398
- function temporallyOverlap(a, b, slackMs) {
40399
- return a.firstSeen <= b.lastSeen + slackMs && b.firstSeen <= a.lastSeen + slackMs;
40400
- }
40401
- /** Connected components of temporal overlap (+ slack) per camera. */
40402
- function sessionsOf(tracks) {
40403
- const byDevice = /* @__PURE__ */ new Map();
40404
- for (const t of tracks) {
40405
- const list = byDevice.get(t.deviceId);
40406
- if (list) list.push(t);
40407
- else byDevice.set(t.deviceId, [t]);
40408
- }
40409
- const sessions = [];
40410
- for (const deviceTracks of byDevice.values()) {
40411
- const n = deviceTracks.length;
40412
- const parent = deviceTracks.map((_, i) => i);
40413
- const find = (i) => {
40414
- let cur = i;
40415
- while (parent[cur] !== cur) cur = parent[cur];
40416
- let walk = i;
40417
- while (parent[walk] !== cur) {
40418
- const next = parent[walk];
40419
- parent[walk] = cur;
40420
- walk = next;
40421
- }
40422
- return cur;
40423
- };
40424
- const union = (i, j) => {
40425
- const ri = find(i);
40426
- const rj = find(j);
40427
- if (ri !== rj) parent[ri] = rj;
40428
- };
40429
- for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (temporallyOverlap(deviceTracks[i], deviceTracks[j], 3e3)) union(i, j);
40430
- const buckets = /* @__PURE__ */ new Map();
40431
- for (let i = 0; i < n; i++) {
40432
- const root = find(i);
40433
- const bucket = buckets.get(root);
40434
- if (bucket) bucket.push(deviceTracks[i]);
40435
- else buckets.set(root, [deviceTracks[i]]);
40436
- }
40437
- sessions.push(...buckets.values());
40438
- }
40439
- return sessions;
40440
- }
40441
40344
  function diag(bbox) {
40442
40345
  return Math.hypot(bbox.w, bbox.h);
40443
40346
  }
@@ -40459,9 +40362,21 @@ function meanDiag(positions) {
40459
40362
  for (const p of positions) sum += diag(p.bbox);
40460
40363
  return sum / positions.length;
40461
40364
  }
40462
- function netDisplacement(track) {
40463
- const first = track.positions[0];
40464
- const last = track.positions[track.positions.length - 1];
40365
+ function vecLen(v) {
40366
+ return Math.hypot(v.x, v.y);
40367
+ }
40368
+ function cosine(a, b) {
40369
+ const la = vecLen(a);
40370
+ const lb = vecLen(b);
40371
+ if (la === 0 || lb === 0) return 1;
40372
+ return (a.x * b.x + a.y * b.y) / (la * lb);
40373
+ }
40374
+ function positionsInWindow(track, start, end) {
40375
+ return track.positions.filter((p) => p.timestamp >= start && p.timestamp <= end);
40376
+ }
40377
+ function windowDisplacement(positions) {
40378
+ const first = positions[0];
40379
+ const last = positions[positions.length - 1];
40465
40380
  if (!first || !last) return {
40466
40381
  x: 0,
40467
40382
  y: 0
@@ -40473,24 +40388,19 @@ function netDisplacement(track) {
40473
40388
  y: b.y - a.y
40474
40389
  };
40475
40390
  }
40476
- function vecLen(v) {
40477
- return Math.hypot(v.x, v.y);
40478
- }
40479
- function cosine(a, b) {
40480
- const la = vecLen(a);
40481
- const lb = vecLen(b);
40482
- if (la === 0 || lb === 0) return 1;
40483
- return (a.x * b.x + a.y * b.y) / (la * lb);
40484
- }
40485
- function relativeMotion(track) {
40486
- const mean = meanDiag(track.positions);
40391
+ function windowRelativeMotion(positions) {
40392
+ const mean = meanDiag(positions);
40487
40393
  if (mean <= 0) return 0;
40488
- return vecLen(netDisplacement(track)) / mean;
40394
+ return vecLen(windowDisplacement(positions)) / mean;
40489
40395
  }
40490
- /** A ~ B iff cohesion ≥ 0.5 AND NOT (both move AND cos < 0.3). */
40491
- function pairTogether(a, b) {
40492
- const overlapStart = Math.max(a.firstSeen, b.firstSeen);
40493
- const overlapEnd = Math.min(a.lastSeen, b.lastSeen);
40396
+ /**
40397
+ * Same cohesion + crossing veto as {@link pairTogether}, over `[now − 3 s, now]`.
40398
+ * Displacement/cosine use the window, not the whole track.
40399
+ */
40400
+ function pairTogetherNow(a, b, now) {
40401
+ const start = now - LIVE_WINDOW_MS;
40402
+ const overlapStart = Math.max(a.firstSeen, b.firstSeen, start);
40403
+ const overlapEnd = Math.min(a.lastSeen, b.lastSeen, now);
40494
40404
  if (overlapEnd < overlapStart) return false;
40495
40405
  let joint = 0;
40496
40406
  let close = 0;
@@ -40507,55 +40417,245 @@ function pairTogether(a, b) {
40507
40417
  }
40508
40418
  if (joint === 0) return false;
40509
40419
  if (close / joint < .5) return false;
40510
- const aMoves = relativeMotion(a) >= MOVE_EPS;
40511
- const bMoves = relativeMotion(b) >= MOVE_EPS;
40512
- if (aMoves && bMoves && cosine(netDisplacement(a), netDisplacement(b)) < .3) return false;
40420
+ const wa = positionsInWindow(a, start, now);
40421
+ const wb = positionsInWindow(b, start, now);
40422
+ const aMoves = windowRelativeMotion(wa) >= MOVE_EPS;
40423
+ const bMoves = windowRelativeMotion(wb) >= MOVE_EPS;
40424
+ if (aMoves && bMoves && cosine(windowDisplacement(wa), windowDisplacement(wb)) < .3) return false;
40513
40425
  return true;
40514
40426
  }
40515
- function clusterGroups(tracks) {
40516
- const eligible = tracks.filter(isEligibleMember);
40517
- const out = [];
40518
- for (const session of sessionsOf(eligible)) {
40519
- const pairable = [];
40520
- for (const t of session) if (t.positions.length < 2) out.push({
40521
- deviceId: t.deviceId,
40522
- memberTrackIds: [t.trackId]
40523
- });
40524
- else pairable.push(t);
40525
- if (pairable.length === 0) continue;
40526
- const parent = pairable.map((_, i) => i);
40527
- const find = (i) => {
40528
- let cur = i;
40529
- while (parent[cur] !== cur) cur = parent[cur];
40530
- let walk = i;
40531
- while (parent[walk] !== cur) {
40532
- const next = parent[walk];
40533
- parent[walk] = cur;
40534
- walk = next;
40535
- }
40536
- return cur;
40537
- };
40538
- const union = (i, j) => {
40539
- const ri = find(i);
40540
- const rj = find(j);
40541
- if (ri !== rj) parent[ri] = rj;
40542
- };
40543
- for (let i = 0; i < pairable.length; i++) for (let j = i + 1; j < pairable.length; j++) if (pairTogether(pairable[i], pairable[j])) union(i, j);
40544
- const buckets = /* @__PURE__ */ new Map();
40545
- for (let i = 0; i < pairable.length; i++) {
40546
- const root = find(i);
40547
- const bucket = buckets.get(root);
40548
- const id = pairable[i].trackId;
40549
- if (bucket) bucket.push(id);
40550
- else buckets.set(root, [id]);
40551
- }
40552
- const deviceId = pairable[0].deviceId;
40553
- for (const memberTrackIds of buckets.values()) out.push({
40554
- deviceId,
40555
- memberTrackIds
40427
+ var GroupLiveAssigner = class {
40428
+ nextId;
40429
+ snapshots = /* @__PURE__ */ new Map();
40430
+ groups = /* @__PURE__ */ new Map();
40431
+ membership = /* @__PURE__ */ new Map();
40432
+ /** `${trackId}>${groupId}` → first instant the join predicate held. */
40433
+ joinSince = /* @__PURE__ */ new Map();
40434
+ /** trackId → first instant they failed pair against every remaining member. */
40435
+ leaveSince = /* @__PURE__ */ new Map();
40436
+ constructor(nextId = () => (0, node_crypto.randomUUID)()) {
40437
+ this.nextId = nextId;
40438
+ }
40439
+ observe(track, now) {
40440
+ this.snapshots.set(track.trackId, track);
40441
+ if (!isEligibleMember(track)) {
40442
+ this.removeFromGroup(track.trackId);
40443
+ return;
40444
+ }
40445
+ if (!this.membership.has(track.trackId)) this.birthSingleton(track, now);
40446
+ this.considerLeave(track, now);
40447
+ const groupId = this.membership.get(track.trackId);
40448
+ const group = groupId !== void 0 ? this.groups.get(groupId) : void 0;
40449
+ if (group) for (const id of [...group.members]) {
40450
+ if (id === track.trackId) continue;
40451
+ const snap = this.snapshots.get(id);
40452
+ if (snap) this.considerLeave(snap, now);
40453
+ }
40454
+ this.considerJoin(track, now);
40455
+ }
40456
+ groupOf(trackId) {
40457
+ return this.membership.get(trackId);
40458
+ }
40459
+ snapshot(trackId) {
40460
+ return this.snapshots.get(trackId);
40461
+ }
40462
+ liveGroups() {
40463
+ return [...this.groups.values()].map((g) => this.toLive(g));
40464
+ }
40465
+ idleGroups(now, idleMs) {
40466
+ return this.liveGroups().filter((g) => now >= g.lastActiveAt + idleMs);
40467
+ }
40468
+ drop(ids) {
40469
+ for (const id of ids) {
40470
+ const g = this.groups.get(id);
40471
+ if (!g) continue;
40472
+ for (const member of g.members) this.membership.delete(member);
40473
+ this.groups.delete(id);
40474
+ }
40475
+ }
40476
+ birthSingleton(track, now) {
40477
+ const id = this.nextId();
40478
+ this.groups.set(id, {
40479
+ id,
40480
+ deviceId: track.deviceId,
40481
+ openedAt: now,
40482
+ members: new Set([track.trackId])
40556
40483
  });
40484
+ this.membership.set(track.trackId, id);
40557
40485
  }
40558
- return out;
40486
+ considerLeave(track, now) {
40487
+ const groupId = this.membership.get(track.trackId);
40488
+ if (groupId === void 0) return;
40489
+ const group = this.groups.get(groupId);
40490
+ if (!group || group.members.size < 2) {
40491
+ this.leaveSince.delete(track.trackId);
40492
+ return;
40493
+ }
40494
+ if ([...group.members].filter((id) => id !== track.trackId).some((id) => {
40495
+ const other = this.snapshots.get(id);
40496
+ return other !== void 0 && pairTogetherNow(track, other, now);
40497
+ })) {
40498
+ this.leaveSince.delete(track.trackId);
40499
+ return;
40500
+ }
40501
+ const since = this.leaveSince.get(track.trackId) ?? now;
40502
+ this.leaveSince.set(track.trackId, since);
40503
+ if (now - since < 3e3) return;
40504
+ this.peel(track, now);
40505
+ }
40506
+ peel(track, now) {
40507
+ this.removeFromGroup(track.trackId);
40508
+ this.leaveSince.delete(track.trackId);
40509
+ this.birthSingleton(track, now);
40510
+ }
40511
+ considerJoin(track, now) {
40512
+ const mineId = this.membership.get(track.trackId);
40513
+ if (mineId === void 0) return;
40514
+ const mine = this.groups.get(mineId);
40515
+ if (!mine || mine.members.size !== 1) {
40516
+ this.clearJoinClocks(track.trackId);
40517
+ return;
40518
+ }
40519
+ let best;
40520
+ for (const group of this.groups.values()) {
40521
+ if (group.id === mineId || group.deviceId !== track.deviceId) continue;
40522
+ let gap = Number.POSITIVE_INFINITY;
40523
+ let together = false;
40524
+ for (const memberId of group.members) {
40525
+ const other = this.snapshots.get(memberId);
40526
+ if (!other) continue;
40527
+ if (pairTogetherNow(track, other, now)) {
40528
+ together = true;
40529
+ gap = Math.min(gap, centroidGap(track, other, now));
40530
+ }
40531
+ }
40532
+ if (!together) continue;
40533
+ if (best === void 0 || gap < best.gap) best = {
40534
+ groupId: group.id,
40535
+ gap
40536
+ };
40537
+ }
40538
+ if (best === void 0) {
40539
+ this.clearJoinClocks(track.trackId);
40540
+ return;
40541
+ }
40542
+ const key = `${track.trackId}>${best.groupId}`;
40543
+ for (const existing of [...this.joinSince.keys()]) if (existing.startsWith(`${track.trackId}>`) && existing !== key) this.joinSince.delete(existing);
40544
+ const since = this.joinSince.get(key) ?? now;
40545
+ this.joinSince.set(key, since);
40546
+ if (now - since < 1500) return;
40547
+ this.absorb(track.trackId, mineId, best.groupId);
40548
+ }
40549
+ absorb(trackId, fromId, intoId) {
40550
+ const into = this.groups.get(intoId);
40551
+ if (!into) return;
40552
+ into.members.add(trackId);
40553
+ this.membership.set(trackId, intoId);
40554
+ this.groups.delete(fromId);
40555
+ this.clearJoinClocks(trackId);
40556
+ this.leaveSince.delete(trackId);
40557
+ }
40558
+ removeFromGroup(trackId) {
40559
+ const groupId = this.membership.get(trackId);
40560
+ this.membership.delete(trackId);
40561
+ this.clearJoinClocks(trackId);
40562
+ this.leaveSince.delete(trackId);
40563
+ if (groupId === void 0) return;
40564
+ const group = this.groups.get(groupId);
40565
+ if (!group) return;
40566
+ group.members.delete(trackId);
40567
+ if (group.members.size === 0) this.groups.delete(groupId);
40568
+ }
40569
+ clearJoinClocks(trackId) {
40570
+ for (const key of [...this.joinSince.keys()]) if (key.startsWith(`${trackId}>`)) this.joinSince.delete(key);
40571
+ }
40572
+ toLive(group) {
40573
+ let lastActiveAt = group.openedAt;
40574
+ for (const id of group.members) {
40575
+ const snap = this.snapshots.get(id);
40576
+ if (snap && snap.lastSeen > lastActiveAt) lastActiveAt = snap.lastSeen;
40577
+ }
40578
+ return {
40579
+ id: group.id,
40580
+ deviceId: group.deviceId,
40581
+ memberTrackIds: [...group.members],
40582
+ openedAt: group.openedAt,
40583
+ lastActiveAt
40584
+ };
40585
+ }
40586
+ };
40587
+ function centroidGap(a, b, now) {
40588
+ const pa = a.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, a.positions[0]);
40589
+ const pb = b.positions.reduce((best, p) => Math.abs(p.timestamp - now) < Math.abs(best.timestamp - now) ? p : best, b.positions[0]);
40590
+ if (!pa || !pb) return Number.POSITIVE_INFINITY;
40591
+ const ca = bboxCentroid(pa.bbox);
40592
+ const cb = bboxCentroid(pb.bbox);
40593
+ return Math.hypot(ca.x - cb.x, ca.y - cb.y);
40594
+ }
40595
+ //#endregion
40596
+ //#region src/pipeline-analytics/group/group-session.ts
40597
+ /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40598
+ var GROUP_SESSION_IDLE_MS = 3e4;
40599
+ //#endregion
40600
+ //#region src/pipeline-analytics/group/group-live-buffer.ts
40601
+ function toGroupableTrack(track) {
40602
+ return {
40603
+ trackId: track.trackId,
40604
+ deviceId: track.deviceId,
40605
+ className: track.className,
40606
+ firstSeen: track.firstSeen,
40607
+ lastSeen: track.lastSeen,
40608
+ positions: track.positions.map((p) => ({
40609
+ ...p,
40610
+ bbox: { ...p.bbox }
40611
+ })),
40612
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40613
+ };
40614
+ }
40615
+ var GroupLiveBuffer = class {
40616
+ assigner;
40617
+ primed = /* @__PURE__ */ new Set();
40618
+ constructor(nextId) {
40619
+ this.assigner = new GroupLiveAssigner(nextId);
40620
+ }
40621
+ observe(track, now) {
40622
+ this.assigner.observe(track, now);
40623
+ }
40624
+ groupOf(trackId) {
40625
+ return this.assigner.groupOf(trackId);
40626
+ }
40627
+ snapshot(trackId) {
40628
+ return this.assigner.snapshot(trackId);
40629
+ }
40630
+ liveGroups() {
40631
+ return this.assigner.liveGroups();
40632
+ }
40633
+ idleGroups(now, idleMs = GROUP_SESSION_IDLE_MS) {
40634
+ return this.assigner.idleGroups(now, idleMs);
40635
+ }
40636
+ drop(ids) {
40637
+ this.assigner.drop(ids);
40638
+ }
40639
+ isPrimed(deviceId) {
40640
+ return this.primed.has(deviceId);
40641
+ }
40642
+ markPrimed(deviceId) {
40643
+ this.primed.add(deviceId);
40644
+ }
40645
+ };
40646
+ //#endregion
40647
+ //#region src/pipeline-analytics/group/group-crop.ts
40648
+ /** Always containSubject: true. Never the 0.5 delivered threshold. */
40649
+ function groupWideCentralSquareLayout(unionBbox, frame) {
40650
+ return wideCentralSquareLayout(unionBbox, frame, { containSubject: true });
40651
+ }
40652
+ async function cropGroupFromRgb(rgb, frameWidth, frameHeight, unionBbox) {
40653
+ const layout = groupWideCentralSquareLayout(unionBbox, {
40654
+ W: frameWidth,
40655
+ H: frameHeight
40656
+ });
40657
+ const { crop: slab } = await extractCrop(rgb, frameWidth, frameHeight, layout.fetch);
40658
+ return composeWideCentralSquareThumbnail(slab, layout);
40559
40659
  }
40560
40660
  //#endregion
40561
40661
  //#region src/pipeline-analytics/group/group-instant.ts
@@ -40572,6 +40672,7 @@ function clusterGroups(tracks) {
40572
40672
  *
40573
40673
  * Retrospective. No new capture path.
40574
40674
  */
40675
+ var GROUP_MEDIA_MATCH_MS = 2e3;
40575
40676
  function nearest(positions, t, windowMs) {
40576
40677
  let best;
40577
40678
  let bestDt = windowMs + 1;
@@ -40680,55 +40781,85 @@ function chooseAnchorTrackId(members, ownsCleanFrame) {
40680
40781
  return life !== 0 ? life : a.trackId.localeCompare(b.trackId);
40681
40782
  })[0].trackId;
40682
40783
  }
40683
- //#endregion
40684
- //#region src/pipeline-analytics/group/group-session.ts
40685
40784
  /**
40686
- * Session close → sealed groups. A group is born here, never live.
40785
+ * The RGB source for a group crop is a full native frame. Thumbnails are
40786
+ * already single-subject crops and cannot substitute for union-bbox geometry.
40687
40787
  *
40688
- * Session = temporal chain + 3 s slack, per camera. Close = every overlapping
40689
- * track is persisted AND the newest lastSeen is older than the idle cutoff
40690
- * (the Phase-1 burst's 30 s, reused — not a second clock).
40691
- *
40692
- * Merge and split cannot occur: connected components of a closed session is a
40693
- * partition. Say it out loud so a later live-group attempt does not reach
40694
- * for them here.
40788
+ * Prefer a keyFrame within {@link GROUP_MEDIA_MATCH_MS} of the chosen instant;
40789
+ * if none, take the nearest keyFrame anyway — dropping the crop because the
40790
+ * best-shot was 3 s off the instant is how a recognised person left the
40791
+ * digest with no crop.
40695
40792
  */
40696
- /** Same idle as the notification burst (`NC_GROUP_DEFAULT_IDLE_SEC`). */
40697
- var GROUP_SESSION_IDLE_MS = 3e4;
40698
- function overlaps(a, b) {
40699
- return a.firstSeen <= b.lastSeen + 3e3 && b.firstSeen <= a.lastSeen + 3e3;
40793
+ function pickCleanPixelRow(rows, instantMs) {
40794
+ const ranked = (maxDt) => {
40795
+ const candidates = [];
40796
+ for (const row of rows) {
40797
+ const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40798
+ if (rank < 0) continue;
40799
+ const dt = Math.abs(row.timestamp - instantMs);
40800
+ if (maxDt !== null && dt > maxDt) continue;
40801
+ candidates.push({
40802
+ key: row.key,
40803
+ dt,
40804
+ rank
40805
+ });
40806
+ }
40807
+ return candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40808
+ };
40809
+ const near = ranked(GROUP_MEDIA_MATCH_MS);
40810
+ if (near !== void 0) return {
40811
+ key: near.key,
40812
+ usedFallback: false
40813
+ };
40814
+ const any = ranked(null);
40815
+ if (any === void 0) return null;
40816
+ return {
40817
+ key: any.key,
40818
+ usedFallback: true
40819
+ };
40700
40820
  }
40701
- function sessionSpan(tracks) {
40821
+ //#endregion
40822
+ //#region src/pipeline-analytics/group/group-live-replay.ts
40823
+ function sliceTrackAt(track, now) {
40824
+ const positions = track.positions.filter((p) => p.timestamp <= now);
40825
+ const last = positions[positions.length - 1];
40702
40826
  return {
40703
- firstSeen: Math.min(...tracks.map((t) => t.firstSeen)),
40704
- lastSeen: Math.max(...tracks.map((t) => t.lastSeen))
40827
+ ...track,
40828
+ lastSeen: last?.timestamp ?? track.firstSeen,
40829
+ positions
40705
40830
  };
40706
40831
  }
40707
- /**
40708
- * Sessions whose members are all in `closed` and whose newest lastSeen is
40709
- * older than `idleMs`. An overlapping ACTIVE track keeps the session open.
40710
- */
40711
- function sessionsReadyToSeal(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40712
- const byDevice = /* @__PURE__ */ new Map();
40713
- for (const t of closed) {
40714
- const list = byDevice.get(t.deviceId);
40715
- if (list) list.push(t);
40716
- else byDevice.set(t.deviceId, [t]);
40717
- }
40718
- const ready = [];
40719
- for (const [deviceId, tracks] of byDevice) {
40720
- const deviceActive = active.filter((a) => a.deviceId === deviceId);
40721
- for (const session of sessionsOf(tracks)) {
40722
- const span = sessionSpan(session);
40723
- if (now < span.lastSeen + idleMs) continue;
40724
- if (deviceActive.some((a) => overlaps(span, a))) continue;
40725
- ready.push(session);
40832
+ function replayInto(tracks, observer) {
40833
+ const events = [];
40834
+ const byId = /* @__PURE__ */ new Map();
40835
+ for (const track of tracks) {
40836
+ byId.set(track.trackId, track);
40837
+ if (track.positions.length === 0) {
40838
+ events.push({
40839
+ t: track.lastSeen,
40840
+ trackId: track.trackId
40841
+ });
40842
+ continue;
40726
40843
  }
40844
+ for (const p of track.positions) events.push({
40845
+ t: p.timestamp,
40846
+ trackId: track.trackId
40847
+ });
40848
+ }
40849
+ events.sort((a, b) => a.t - b.t || a.trackId.localeCompare(b.trackId));
40850
+ let lastKey = "";
40851
+ for (const e of events) {
40852
+ const key = `${e.trackId}@${e.t}`;
40853
+ if (key === lastKey) continue;
40854
+ lastKey = key;
40855
+ const track = byId.get(e.trackId);
40856
+ if (!track) continue;
40857
+ observer.observe(sliceTrackAt(track, e.t), e.t);
40727
40858
  }
40728
- return ready;
40729
40859
  }
40730
- function partitionsForSealedSessions(closed, active, now, idleMs = GROUP_SESSION_IDLE_MS) {
40731
- return clusterGroups(sessionsReadyToSeal(closed, active, now, idleMs).flat());
40860
+ function replayLive(tracks, assigner = new GroupLiveAssigner()) {
40861
+ replayInto(tracks, assigner);
40862
+ return assigner;
40732
40863
  }
40733
40864
  //#endregion
40734
40865
  //#region src/pipeline-analytics/group/group-sealer.ts
@@ -40736,9 +40867,9 @@ var DEFAULT_FRAME = {
40736
40867
  width: 640,
40737
40868
  height: 360
40738
40869
  };
40739
- var SEAL_LOOKBACK_MS = 10 * 6e4;
40740
- var BACKFILL_LOOKBACK_MS = 2880 * 6e4;
40741
40870
  var QUERY_LIMIT = 2e3;
40871
+ /** Crash recover: replay this camera only, on its next activity. */
40872
+ var GROUP_CRASH_LOOKBACK_MS = 2 * 6e4;
40742
40873
  function toInstant(t) {
40743
40874
  return {
40744
40875
  trackId: t.trackId,
@@ -40752,93 +40883,164 @@ function toInstant(t) {
40752
40883
  confidence: t.importance
40753
40884
  };
40754
40885
  }
40886
+ function memberBoxesAt(members, at, frame) {
40887
+ const w = Math.max(frame.width, 1);
40888
+ const h = Math.max(frame.height, 1);
40889
+ const boxes = [];
40890
+ for (const member of members) {
40891
+ let best = member.positions[0];
40892
+ let bestDt = Number.POSITIVE_INFINITY;
40893
+ for (const p of member.positions) {
40894
+ const dt = Math.abs(p.timestamp - at);
40895
+ if (dt <= 1e3 && dt < bestDt) {
40896
+ best = p;
40897
+ bestDt = dt;
40898
+ }
40899
+ }
40900
+ if (best === void 0 || bestDt === Number.POSITIVE_INFINITY) continue;
40901
+ boxes.push({
40902
+ bbox: {
40903
+ x: best.bbox.x / w,
40904
+ y: best.bbox.y / h,
40905
+ w: best.bbox.w / w,
40906
+ h: best.bbox.h / h
40907
+ },
40908
+ label: member.className,
40909
+ observedAt: best.timestamp
40910
+ });
40911
+ }
40912
+ return boxes;
40913
+ }
40914
+ function toSealer(track) {
40915
+ return {
40916
+ trackId: track.trackId,
40917
+ deviceId: track.deviceId,
40918
+ className: track.className,
40919
+ firstSeen: track.firstSeen,
40920
+ lastSeen: track.lastSeen,
40921
+ positions: track.positions,
40922
+ ...track.closedReason !== void 0 ? { closedReason: track.closedReason } : {}
40923
+ };
40924
+ }
40755
40925
  var GroupSealer = class {
40756
40926
  deps;
40757
40927
  constructor(deps) {
40758
40928
  this.deps = deps;
40759
40929
  }
40760
- async sealRecent() {
40761
- const now = this.deps.now();
40762
- return this.sealSince(now - SEAL_LOOKBACK_MS, now);
40763
- }
40764
- async backfill() {
40765
- const now = this.deps.now();
40766
- return this.sealSince(now - BACKFILL_LOOKBACK_MS, now);
40930
+ get processEnabled() {
40931
+ return this.deps.enabled ?? false;
40767
40932
  }
40768
- async sealSince(since, now) {
40933
+ async flushIdle(buffer, now = this.deps.now()) {
40934
+ if (!this.processEnabled) return 0;
40769
40935
  let written = 0;
40770
- let devices;
40936
+ for (const group of buffer.idleGroups(now, GROUP_SESSION_IDLE_MS)) if (await this.persistLiveGroup(group, buffer)) {
40937
+ buffer.drop([group.id]);
40938
+ written += 1;
40939
+ }
40940
+ return written;
40941
+ }
40942
+ /**
40943
+ * First activity on camera D after a crash: replay 2 minutes of that camera
40944
+ * into the live buffer, persist whoever is already idle, keep the rest in RAM.
40945
+ */
40946
+ async recoverCamera(deviceId, buffer) {
40947
+ if (!this.processEnabled) return 0;
40948
+ if (buffer.isPrimed(deviceId)) return 0;
40949
+ const now = this.deps.now();
40771
40950
  try {
40772
- devices = await this.deps.trackStore.listDeviceIds();
40951
+ replayInto(await this.deps.trackStore.queryHistorical({
40952
+ deviceId,
40953
+ since: now - GROUP_CRASH_LOOKBACK_MS,
40954
+ until: now,
40955
+ limit: QUERY_LIMIT,
40956
+ projection: "full"
40957
+ }), buffer);
40773
40958
  } catch (err) {
40774
- this.deps.logger.warn("group sealer: listDeviceIds failed", { meta: { error: String(err) } });
40775
- return 0;
40959
+ this.deps.logger.warn("group sealer: lookback failed", {
40960
+ tags: { deviceId },
40961
+ meta: { error: String(err) }
40962
+ });
40963
+ } finally {
40964
+ buffer.markPrimed(deviceId);
40776
40965
  }
40777
- for (const deviceId of devices) try {
40778
- written += await this.sealDevice(deviceId, since, now);
40966
+ return this.flushIdle(buffer, now);
40967
+ }
40968
+ /**
40969
+ * Seal ONE camera over a caller-owned window via live replay.
40970
+ *
40971
+ * This is how a digest recovers groups without a boot scan: the producer
40972
+ * already knows the window. Walking every camera for 48h at initialize()
40973
+ * is what wedged hub-main.
40974
+ */
40975
+ async sealWindow(deviceId, since, until) {
40976
+ if (!this.processEnabled) return 0;
40977
+ try {
40978
+ const closed = await this.deps.trackStore.queryHistorical({
40979
+ deviceId,
40980
+ since,
40981
+ until,
40982
+ limit: QUERY_LIMIT,
40983
+ projection: "full"
40984
+ });
40985
+ if (closed.length === 0) return 0;
40986
+ const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40987
+ const pending = closed.filter((t) => !already.has(t.trackId));
40988
+ if (pending.length === 0) return 0;
40989
+ const assigner = replayLive(pending);
40990
+ const now = this.deps.now();
40991
+ const byId = new Map(pending.map((t) => [t.trackId, t]));
40992
+ let written = 0;
40993
+ for (const group of assigner.idleGroups(now, GROUP_SESSION_IDLE_MS)) {
40994
+ const members = group.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
40995
+ if (members.length === 0) continue;
40996
+ try {
40997
+ await this.persistPartition(members, group.openedAt);
40998
+ written += 1;
40999
+ } catch (err) {
41000
+ this.deps.logger.warn("group sealer: persist failed", {
41001
+ tags: { deviceId },
41002
+ meta: {
41003
+ members: members.map((m) => m.trackId),
41004
+ error: String(err)
41005
+ }
41006
+ });
41007
+ }
41008
+ }
41009
+ return written;
40779
41010
  } catch (err) {
40780
41011
  this.deps.logger.warn("group sealer: device failed", {
40781
41012
  tags: { deviceId },
40782
41013
  meta: { error: String(err) }
40783
41014
  });
41015
+ return 0;
40784
41016
  }
40785
- return written;
40786
41017
  }
40787
- async sealDevice(deviceId, since, now) {
40788
- const closed = await this.deps.trackStore.queryHistorical({
40789
- deviceId,
40790
- since,
40791
- until: now,
40792
- limit: QUERY_LIMIT,
40793
- projection: "full"
40794
- });
40795
- if (closed.length === 0) return 0;
40796
- const already = new Set((await this.deps.groupStore.membersOfTracks(closed.map((t) => t.trackId))).map((m) => m.trackId));
40797
- const pending = closed.filter((t) => !already.has(t.trackId));
40798
- if (pending.length === 0) return 0;
40799
- const active = this.deps.trackStore.getActive(deviceId).map((t) => ({
40800
- deviceId,
40801
- firstSeen: t.firstSeen,
40802
- lastSeen: t.lastSeen
40803
- }));
40804
- const partitions = partitionsForSealedSessions(pending.map(toInstant), active, now, GROUP_SESSION_IDLE_MS);
40805
- if (partitions.length === 0) {
40806
- if (pending.filter((t) => t.closedReason !== "stationary-promotion" && t.className !== "audio").length === 0 && pending.length > 0) this.deps.logger.info("group session produced no groups", {
41018
+ async persistLiveGroup(group, buffer) {
41019
+ const deviceId = group.deviceId;
41020
+ const snaps = group.memberTrackIds.map((id) => buffer.snapshot(id)).filter((t) => t !== void 0);
41021
+ if (snaps.length === 0) return true;
41022
+ const already = new Set((await this.deps.groupStore.membersOfTracks(snaps.map((t) => t.trackId))).map((m) => m.trackId));
41023
+ const pending = snaps.filter((t) => !already.has(t.trackId)).map(toSealer);
41024
+ if (pending.length === 0) return true;
41025
+ try {
41026
+ await this.persistPartition(pending, group.openedAt);
41027
+ return true;
41028
+ } catch (err) {
41029
+ this.deps.logger.warn("group sealer: persist failed", {
40807
41030
  tags: { deviceId },
40808
41031
  meta: {
40809
- dropped: pending.length,
40810
- reason: "no-eligible-members"
41032
+ members: pending.map((m) => m.trackId),
41033
+ error: String(err)
40811
41034
  }
40812
41035
  });
40813
- return 0;
40814
- }
40815
- const byId = new Map(pending.map((t) => [t.trackId, t]));
40816
- let written = 0;
40817
- for (const part of partitions) {
40818
- const members = part.memberTrackIds.map((id) => byId.get(id)).filter((t) => t !== void 0);
40819
- if (members.length === 0) continue;
40820
- if (members.every((m) => already.has(m.trackId))) continue;
40821
- try {
40822
- await this.persistPartition(members);
40823
- written += 1;
40824
- } catch (err) {
40825
- this.deps.logger.warn("group sealer: persist failed", {
40826
- tags: { deviceId },
40827
- meta: {
40828
- members: members.map((m) => m.trackId),
40829
- error: String(err)
40830
- }
40831
- });
40832
- }
41036
+ return false;
40833
41037
  }
40834
- return written;
40835
41038
  }
40836
- async persistPartition(members) {
41039
+ async persistPartition(members, openedAt) {
40837
41040
  const deviceId = members[0].deviceId;
40838
41041
  const frame = this.deps.frameDims(deviceId) ?? DEFAULT_FRAME;
40839
41042
  const instantMembers = members.map(toInstant);
40840
41043
  const instant = chooseGroupInstant(instantMembers, frame);
40841
- const openedAt = Math.min(...members.map((m) => m.firstSeen));
40842
41044
  const closedAt = Math.max(...members.map((m) => m.lastSeen));
40843
41045
  const timestamp = instant?.timestamp ?? closedAt;
40844
41046
  const mediaInfos = await Promise.all(members.map(async (m) => ({
@@ -40874,29 +41076,66 @@ var GroupSealer = class {
40874
41076
  nativeKey
40875
41077
  });
40876
41078
  if (members.length < 2 || instant === null) return;
40877
- const source = await this.pickCleanPixels(mediaInfos, timestamp);
40878
- if (source === null) return;
41079
+ const source = await this.pickCleanPixels(mediaInfos, timestamp, deviceId);
41080
+ if (source === null) {
41081
+ this.deps.logger.info("group sealer: no clean pixels for crop", {
41082
+ tags: { deviceId },
41083
+ meta: {
41084
+ groupId: draft.id,
41085
+ members: members.map((m) => m.trackId)
41086
+ }
41087
+ });
41088
+ return;
41089
+ }
40879
41090
  try {
40880
- const raw = await (0, sharp.default)(source.jpeg).removeAlpha().raw().toBuffer({ resolveWithObject: true });
40881
- if (raw.info.channels !== 3) return;
40882
- const union = {
40883
- x: instant.ux * raw.info.width,
40884
- y: instant.uy * raw.info.height,
40885
- w: instant.uw * raw.info.width,
40886
- h: instant.uh * raw.info.height
40887
- };
40888
- const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
40889
- const mediaKey = await this.deps.mediaStore.put({
41091
+ const raw = await (0, sharp.default)(source.jpeg).removeAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
41092
+ if (raw.info.channels === 3) {
41093
+ const union = {
41094
+ x: instant.ux * raw.info.width,
41095
+ y: instant.uy * raw.info.height,
41096
+ w: instant.uw * raw.info.width,
41097
+ h: instant.uh * raw.info.height
41098
+ };
41099
+ const crop = await cropGroupFromRgb(raw.data, raw.info.width, raw.info.height, union);
41100
+ const mediaKey = await this.deps.mediaStore.put({
41101
+ deviceId,
41102
+ ownerKind: "group",
41103
+ ownerId: draft.id,
41104
+ kind: "thumbnail",
41105
+ timestamp,
41106
+ data: crop
41107
+ });
41108
+ await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
41109
+ }
41110
+ } catch (err) {
41111
+ this.deps.logger.warn("group sealer: crop failed", {
41112
+ tags: { deviceId },
41113
+ meta: {
41114
+ groupId: draft.id,
41115
+ error: String(err)
41116
+ }
41117
+ });
41118
+ }
41119
+ try {
41120
+ const composed = await composeGroupFrame({
41121
+ jpeg: source.jpeg,
41122
+ boxes: memberBoxesAt(instantMembers, timestamp, frame),
41123
+ at: timestamp,
41124
+ logger: this.deps.logger,
41125
+ deviceId,
41126
+ groupKey: draft.id
41127
+ });
41128
+ if (composed === null) return;
41129
+ await this.deps.mediaStore.put({
40890
41130
  deviceId,
40891
41131
  ownerKind: "group",
40892
41132
  ownerId: draft.id,
40893
- kind: "thumbnail",
41133
+ kind: "fullFrameBoxed",
40894
41134
  timestamp,
40895
- data: crop
41135
+ data: composed.bytes
40896
41136
  });
40897
- await this.deps.groupStore.setMediaKey(draft.id, mediaKey);
40898
41137
  } catch (err) {
40899
- this.deps.logger.warn("group sealer: crop failed", {
41138
+ this.deps.logger.warn("group sealer: boxed frame failed", {
40900
41139
  tags: { deviceId },
40901
41140
  meta: {
40902
41141
  groupId: draft.id,
@@ -40905,27 +41144,53 @@ var GroupSealer = class {
40905
41144
  });
40906
41145
  }
40907
41146
  }
40908
- async pickCleanPixels(mediaInfos, instantMs) {
40909
- const candidates = [];
40910
- for (const info of mediaInfos) for (const row of info.rows) {
40911
- const rank = row.kind === "keyFrame" ? 0 : row.kind === "keyFrameSmall" ? 1 : -1;
40912
- if (rank < 0) continue;
40913
- const dt = Math.abs(row.timestamp - instantMs);
40914
- if (dt > 2e3) continue;
40915
- candidates.push({
40916
- key: row.key,
40917
- dt,
40918
- rank
40919
- });
40920
- }
40921
- const chosen = candidates.toSorted((a, b) => a.rank - b.rank || a.dt - b.dt)[0];
40922
- if (chosen === void 0) return null;
41147
+ async pickCleanPixels(mediaInfos, instantMs, deviceId) {
41148
+ const chosen = pickCleanPixelRow(mediaInfos.flatMap((info) => info.rows), instantMs);
41149
+ if (chosen === null) return null;
41150
+ if (chosen.usedFallback) this.deps.logger.info("group sealer: crop pixels not within 2s of instant, using nearest keyFrame", {
41151
+ tags: { deviceId },
41152
+ meta: {
41153
+ instantMs,
41154
+ mediaKey: chosen.key
41155
+ }
41156
+ });
40923
41157
  const file = await this.deps.mediaStore.getByKey(chosen.key);
40924
41158
  if (file === null) return null;
40925
41159
  return { jpeg: Buffer.from(file.base64, "base64") };
40926
41160
  }
40927
41161
  };
40928
41162
  //#endregion
41163
+ //#region src/pipeline-analytics/group/group-cursor.ts
41164
+ function encodeGroupCursor(cursor) {
41165
+ return Buffer.from(JSON.stringify({
41166
+ c: cursor.closedAt,
41167
+ i: cursor.groupId
41168
+ }), "utf8").toString("base64url");
41169
+ }
41170
+ function decodeGroupCursor(raw) {
41171
+ let parsed;
41172
+ try {
41173
+ parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
41174
+ } catch {
41175
+ throw new Error("listGroups: malformed cursor");
41176
+ }
41177
+ if (parsed === null || typeof parsed !== "object" || !("c" in parsed) || !("i" in parsed) || typeof parsed.c !== "number" || !Number.isFinite(parsed.c) || typeof parsed.i !== "string" || parsed.i.length === 0) throw new Error("listGroups: malformed cursor");
41178
+ return {
41179
+ closedAt: parsed.c,
41180
+ groupId: parsed.i
41181
+ };
41182
+ }
41183
+ function compareGroupDesc(a, b) {
41184
+ if (a.closedAt !== b.closedAt) return b.closedAt - a.closedAt;
41185
+ if (a.id === b.id) return 0;
41186
+ return a.id < b.id ? 1 : -1;
41187
+ }
41188
+ /** True when `row` sits strictly AFTER the cursor in DESC order. */
41189
+ function isAfterGroupCursor(row, cursor) {
41190
+ if (row.closedAt < cursor.closedAt) return true;
41191
+ return row.closedAt === cursor.closedAt && row.id < cursor.groupId;
41192
+ }
41193
+ //#endregion
40929
41194
  //#region src/pipeline-analytics/group/group-store.ts
40930
41195
  /**
40931
41196
  * The group ENTITY store — a co-moving partition of one session, with one
@@ -40947,8 +41212,8 @@ var GroupSealer = class {
40947
41212
  */
40948
41213
  /**
40949
41214
  * @durable class=ledger owner=pipeline-analytics
40950
- * write="one row per co-moving group at session close — sealed, never live.
40951
- * Membership is a partition of one camera's eligible tracks. mediaKey is
41215
+ * write="one row per co-moving group, sealed at idle — never a live row.
41216
+ * Membership is the RAM members at persist, not a session partition. mediaKey is
40952
41217
  * the group's own crop; nativeKey is a POINTER to the anchor member's
40953
41218
  * keyFrame, nulled if that member is deleted alone. anchorTrackId and
40954
41219
  * nativeKey are the only fields rewritten after birth."
@@ -41187,6 +41452,24 @@ var TrackGroupStore = class {
41187
41452
  data: r.data
41188
41453
  }));
41189
41454
  }
41455
+ /**
41456
+ * Membership for a page of groups in ONE query. `listGroupRecords` used to
41457
+ * call {@link listMembers} per row — 60 groups is 60 settings-store RPCs,
41458
+ * ~20 s, and the viewer feed times out into a retry loop.
41459
+ */
41460
+ async listMembersForGroups(groupIds) {
41461
+ if (groupIds.length === 0) return [];
41462
+ return (await this.store.query.query({
41463
+ collection: TRACK_GROUP_MEMBERS_COLLECTION,
41464
+ filter: {
41465
+ whereIn: { groupId: [...groupIds] },
41466
+ limit: 1e4
41467
+ }
41468
+ })).map((r) => this.normalizeMember({
41469
+ id: r.id,
41470
+ data: r.data
41471
+ }));
41472
+ }
41190
41473
  async membersOfTracks(trackIds) {
41191
41474
  const out = [];
41192
41475
  for (const trackId of trackIds) {
@@ -41202,20 +41485,29 @@ var TrackGroupStore = class {
41202
41485
  return out;
41203
41486
  }
41204
41487
  /**
41205
- * Newest-closed page for the Library Groups tab.
41488
+ * Newest-closed page for the Groups feed.
41206
41489
  *
41207
41490
  * Time filter is overlap (`closedAt >= since` and `openedAt <= until`),
41208
41491
  * same as {@link listOverlapping}. Pagination is applied AFTER the window
41209
41492
  * so a day cursor is honest rather than a raw-row offset that skips live
41210
- * groups on a busy camera.
41493
+ * groups on a busy camera. `deviceIds: []` is an empty page (same as
41494
+ * `listRecentTracks`); omit the field for every camera. `deviceId` stays
41495
+ * as the single-camera alias the custom-action bridge still sends.
41211
41496
  */
41212
41497
  async listRecent(input) {
41213
41498
  const limit = Math.min(Math.max(input.limit ?? 40, 1), 100);
41214
41499
  const offset = Math.max(input.offset ?? 0, 0);
41215
- const slice = (await this.store.query.query({
41500
+ const deviceIds = input.deviceIds !== void 0 ? input.deviceIds : input.deviceId !== void 0 ? [input.deviceId] : void 0;
41501
+ if (deviceIds !== void 0 && deviceIds.length === 0) return {
41502
+ groups: [],
41503
+ hasMore: false,
41504
+ nextCursor: null
41505
+ };
41506
+ const cursor = input.cursor !== void 0 && input.cursor.length > 0 ? decodeGroupCursor(input.cursor) : null;
41507
+ const filtered = (await this.store.query.query({
41216
41508
  collection: TRACK_GROUPS_COLLECTION,
41217
41509
  filter: {
41218
- ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
41510
+ ...deviceIds !== void 0 && deviceIds.length === 1 ? { where: { deviceId: deviceIds[0] } } : deviceIds !== void 0 ? { whereIn: { deviceId: [...deviceIds] } } : {},
41219
41511
  orderBy: {
41220
41512
  field: "closedAt",
41221
41513
  direction: "desc"
@@ -41228,11 +41520,20 @@ var TrackGroupStore = class {
41228
41520
  })).filter((g) => {
41229
41521
  if (input.sinceMs !== void 0 && g.closedAt < input.sinceMs) return false;
41230
41522
  if (input.untilMs !== void 0 && g.openedAt > input.untilMs) return false;
41523
+ if (cursor !== null && !isAfterGroupCursor(g, cursor)) return false;
41231
41524
  return true;
41232
- }).slice(offset, offset + limit + 1);
41525
+ }).toSorted(compareGroupDesc);
41526
+ const slice = (cursor !== null ? filtered : filtered.slice(offset)).slice(0, limit + 1);
41527
+ const groups = slice.slice(0, limit);
41528
+ const hasMore = slice.length > limit;
41529
+ const last = groups[groups.length - 1];
41233
41530
  return {
41234
- groups: slice.slice(0, limit),
41235
- hasMore: slice.length > limit
41531
+ groups,
41532
+ hasMore,
41533
+ nextCursor: hasMore && last !== void 0 ? encodeGroupCursor({
41534
+ closedAt: last.closedAt,
41535
+ groupId: last.id
41536
+ }) : null
41236
41537
  };
41237
41538
  }
41238
41539
  /** Groups whose life overlaps `[since, until]` on one camera. */
@@ -42564,7 +42865,7 @@ function evaluateStationaryPromotion(input) {
42564
42865
  };
42565
42866
  }
42566
42867
  var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
42567
- minRounds: 2,
42868
+ minHits: 3,
42568
42869
  roundGapMs: 5e3,
42569
42870
  ttlMs: 5 * 6e4,
42570
42871
  assocIou: .3
@@ -42597,8 +42898,9 @@ var PackageStillnessTracker = class {
42597
42898
  const prev = next[index];
42598
42899
  if (prev === void 0) continue;
42599
42900
  consumed.add(prev.id);
42600
- const updated = confirm(prev, input, s, this.config.roundGapMs);
42601
- if (!(updated.promotedAt === null && updated.activityBackedAtOpen && updated.rounds >= this.config.minRounds && input.timestamp - updated.firstSeenAt >= input.minDwellMs)) {
42901
+ const updated = confirm(prev, input, s);
42902
+ const minHits = input.minHits ?? this.config.minHits;
42903
+ if (!(updated.promotedAt === null && updated.activityBackedAtOpen && updated.hits >= minHits && input.timestamp - updated.firstSeenAt >= input.minDwellMs)) {
42602
42904
  next[index] = updated;
42603
42905
  continue;
42604
42906
  }
@@ -42622,7 +42924,7 @@ var PackageStillnessTracker = class {
42622
42924
  id: c.id,
42623
42925
  className: c.className,
42624
42926
  bbox: { ...c.bbox },
42625
- rounds: c.rounds,
42927
+ hits: c.hits,
42626
42928
  firstSeenAt: c.firstSeenAt,
42627
42929
  lastSeenAt: c.lastSeenAt,
42628
42930
  activityBackedAtOpen: c.activityBackedAtOpen
@@ -42680,7 +42982,7 @@ var PackageStillnessTracker = class {
42680
42982
  frameHeight: input.frameHeight,
42681
42983
  firstSeenAt: input.timestamp,
42682
42984
  lastSeenAt: input.timestamp,
42683
- rounds: 1,
42985
+ hits: 1,
42684
42986
  promotedAt: null,
42685
42987
  activityBackedAtOpen,
42686
42988
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
@@ -42690,18 +42992,16 @@ var PackageStillnessTracker = class {
42690
42992
  };
42691
42993
  }
42692
42994
  };
42693
- /** Re-confirm a candidate with this frame's box. A new ROUND is only counted
42694
- * when the gap since the last sighting clears `roundGapMs`, so a burst of
42695
- * frames inside one governor round counts once. */
42696
- function confirm(prev, input, s, roundGapMs) {
42697
- const newRound = input.timestamp - prev.lastSeenAt >= roundGapMs;
42995
+ /** Re-confirm a candidate with this frame's box. Every sighting is a hit —
42996
+ * a settle burst of three frames in one second is three confirmations. */
42997
+ function confirm(prev, input, s) {
42698
42998
  return {
42699
42999
  ...prev,
42700
43000
  bbox: { ...s.bbox },
42701
43001
  frameWidth: input.frameWidth,
42702
43002
  frameHeight: input.frameHeight,
42703
43003
  lastSeenAt: input.timestamp,
42704
- rounds: newRound ? prev.rounds + 1 : prev.rounds,
43004
+ hits: prev.hits + 1,
42705
43005
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
42706
43006
  ...s.label !== void 0 ? { label: s.label } : {},
42707
43007
  ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
@@ -42726,6 +43026,31 @@ function toEntry(c) {
42726
43026
  };
42727
43027
  }
42728
43028
  //#endregion
43029
+ //#region src/pipeline-analytics/pipeline/package/package-zone-activity.ts
43030
+ /**
43031
+ * Whether a live track is motion inside a configured package drop-off zone.
43032
+ *
43033
+ * The stillness activity gate used to stamp ANY non-package track on the
43034
+ * camera. A walker at the far gate then "delivered" the streetlight wedge
43035
+ * on the doormat. The operator's prerequisite is motion in the drop-off
43036
+ * zone the package rule points at — the same zones `PackageDropDetector`
43037
+ * uses for the centroid gate.
43038
+ */
43039
+ function isDelivererInPackageZone(trackZones, packageZoneIds) {
43040
+ if (packageZoneIds.size === 0 || trackZones.length === 0) return false;
43041
+ for (const z of trackZones) if (packageZoneIds.has(z)) return true;
43042
+ return false;
43043
+ }
43044
+ /** Zone ids referenced by enabled `package`-stage rules. Disabled rules do not count. */
43045
+ function collectPackageZoneIds(rules) {
43046
+ const ids = /* @__PURE__ */ new Set();
43047
+ for (const r of rules) {
43048
+ if (r.enabled === false) continue;
43049
+ for (const z of r.zoneIds) ids.add(z);
43050
+ }
43051
+ return ids;
43052
+ }
43053
+ //#endregion
42729
43054
  //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
42730
43055
  /**
42731
43056
  * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
@@ -49350,10 +49675,21 @@ var TrackStore = class {
49350
49675
  * in-memory dedup (the query cap has no DISTINCT); best-effort.
49351
49676
  */
49352
49677
  async listDeviceIds() {
49678
+ return this.collectDeviceIds(0);
49679
+ }
49680
+ /**
49681
+ * Distinct deviceIds with a persisted track whose `lastSeen` is at least
49682
+ * `since`. The group sealer's 10-minute window. Walking from epoch 0 is how
49683
+ * `sealRecent` wedged hub-main every TTL sweep.
49684
+ */
49685
+ async listDeviceIdsSince(since) {
49686
+ return this.collectDeviceIds(Math.max(0, Math.floor(since)));
49687
+ }
49688
+ async collectDeviceIds(fromLastSeen) {
49353
49689
  const seenDevices = /* @__PURE__ */ new Set();
49354
49690
  const seenIds = /* @__PURE__ */ new Set();
49355
49691
  const PAGE = 500;
49356
- let cursor = 0;
49692
+ let cursor = fromLastSeen;
49357
49693
  try {
49358
49694
  for (;;) {
49359
49695
  const rows = await this.store.query.query({
@@ -49618,6 +49954,28 @@ var TrackStore = class {
49618
49954
  return this.rowToTrack(row.id, row.data);
49619
49955
  }
49620
49956
  /**
49957
+ * Batch read of persisted tracks. `listGroupRecords` cannot call
49958
+ * {@link getPersistedByTrackId} per member — that is one settings-store RPC
49959
+ * per row and a 60-group page takes ~20 s, which the viewer feed times out.
49960
+ */
49961
+ async getPersistedByTrackIds(trackIds) {
49962
+ if (trackIds.length === 0) return [];
49963
+ const PAGE = 500;
49964
+ const out = [];
49965
+ for (let i = 0; i < trackIds.length; i += PAGE) {
49966
+ const chunk = trackIds.slice(i, i + PAGE);
49967
+ const rows = await this.store.query.query({
49968
+ collection: TRACKS_COLLECTION,
49969
+ filter: {
49970
+ whereIn: { id: [...chunk] },
49971
+ limit: chunk.length
49972
+ }
49973
+ });
49974
+ for (const row of rows) out.push(this.rowToTrack(row.id, row.data));
49975
+ }
49976
+ return out;
49977
+ }
49978
+ /**
49621
49979
  * Persist a SYNTHETIC track (a sensor/control event projected into the
49622
49980
  * unified track store). Unlike `persistCompleted` there is no live in-RAM
49623
49981
  * state and no trajectory envelope (synthetic tracks carry no positions) —
@@ -54690,11 +55048,22 @@ function buildGlobalSettingsSchema(cap) {
54690
55048
  description: "Also emit a package-picked-up event when the delivered package leaves.",
54691
55049
  default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
54692
55050
  },
55051
+ {
55052
+ type: "slider",
55053
+ key: "packageDropMinFrames",
55054
+ label: "Confirmation frames",
55055
+ description: "How many frames the box must be seen on before it counts as a delivery. One is a flicker; two can still be a passer-by; three is the default bar against false positives. Independent of dwell.",
55056
+ min: 1,
55057
+ max: 10,
55058
+ step: 1,
55059
+ default: PACKAGE_DROP_DEFAULTS.packageDropMinFrames,
55060
+ showValue: true
55061
+ },
54693
55062
  {
54694
55063
  type: "slider",
54695
55064
  key: "packageDropDwellSec",
54696
55065
  label: "Minimum dwell",
54697
- description: "How long a package must stay parked before it counts as a delivery. Higher = fewer false positives from bags briefly set down.",
55066
+ description: "Extra time the package must stay parked after the confirmation frames. 0 (default) means N frames is enough — a courier who drops and leaves in a couple of seconds still delivers. Raise to reject a bag briefly set down.",
54698
55067
  min: 0,
54699
55068
  max: 300,
54700
55069
  step: 5,
@@ -54728,7 +55097,7 @@ function buildGlobalSettingsSchema(cap) {
54728
55097
  type: "slider",
54729
55098
  key: "packageDropActivityWindowSec",
54730
55099
  label: "Deliverer window",
54731
- description: "A delivery only counts if a person or vehicle was active on this camera within this many seconds before the package appeared — and a pick-up only with somebody around when it vanished. 0 disables the requirement.",
55100
+ description: "A delivery only counts if a person or vehicle was in the configured package drop-off zone within this many seconds before the package appeared — and a pick-up only with somebody in that zone when it vanished. Motion elsewhere on the camera does not count. 0 disables the requirement.",
54732
55101
  min: 0,
54733
55102
  max: 1800,
54734
55103
  step: 30,
@@ -56740,6 +57109,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
56740
57109
  plateStore = null;
56741
57110
  groupStore = null;
56742
57111
  groupSealer = null;
57112
+ groupLive = null;
57113
+ /** Serialise first-activity lookback per camera, then observe in order. */
57114
+ groupObserveTail = /* @__PURE__ */ new Map();
56743
57115
  plateRecognizer = null;
56744
57116
  objectEmbeddingStore = null;
56745
57117
  /** Live state of the detached embedding rebuild — see `rebuildObjectEmbeddings`. */
@@ -57150,11 +57522,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
57150
57522
  */
57151
57523
  packageStillness = new PackageStillnessTracker({ newId: () => (0, node_crypto.randomUUID)() });
57152
57524
  /**
57153
- * Frame-clock timestamp of the last NON-package track seen per device — the
57154
- * activity mirror behind the package activity gate ("a parcel needs a
57155
- * deliverer"). Deliberately not {@link lastTrackActivityMs}: that map is
57156
- * stamped by ANY live track, including the package-class FP itself, which
57157
- * would let the streetlight wedge vouch for its own delivery.
57525
+ * Frame-clock timestamp of the last NON-package track seen IN A PACKAGE
57526
+ * DROP-OFF ZONE per device — the activity mirror behind the package
57527
+ * activity gate ("a parcel needs a deliverer"). A walker at the far gate
57528
+ * does not stamp this. Deliberately not {@link lastTrackActivityMs}: that
57529
+ * map is stamped by ANY live track, including the package-class FP itself,
57530
+ * which would let the streetlight wedge vouch for its own delivery.
57158
57531
  */
57159
57532
  lastNonPackageActivityAt = /* @__PURE__ */ new Map();
57160
57533
  /** Last time the oversize-sighting drop was logged per device — one line
@@ -58064,6 +58437,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58064
58437
  media: mediaStore
58065
58438
  });
58066
58439
  this.groupStore = groupStore;
58440
+ this.groupLive = new GroupLiveBuffer();
58067
58441
  this.groupSealer = new GroupSealer({
58068
58442
  trackStore,
58069
58443
  groupStore,
@@ -58078,9 +58452,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58078
58452
  },
58079
58453
  now: () => Date.now()
58080
58454
  });
58081
- this.groupSealer.backfill().catch((err) => {
58082
- logger.warn("group sealer backfill failed", { meta: { error: require_dist.errMsg(err) } });
58083
- });
58084
58455
  return {
58085
58456
  trackStore,
58086
58457
  mediaStore,
@@ -58523,12 +58894,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58523
58894
  buildSummaryPorts(stores) {
58524
58895
  return {
58525
58896
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
58526
- const groups = await stores.groupStore.listOverlapping({
58527
- deviceId,
58528
- sinceMs,
58529
- untilMs,
58530
- limit
58531
- });
58532
58897
  const windowTracks = await stores.trackStore.queryHistorical({
58533
58898
  deviceId,
58534
58899
  since: sinceMs,
@@ -58536,51 +58901,42 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58536
58901
  projection: "slim",
58537
58902
  limit: Math.max(limit, 2e3)
58538
58903
  });
58539
- const trackById = new Map(windowTracks.map((t) => [t.trackId, t]));
58540
58904
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
58541
- const out = [];
58542
- for (const group of groups) {
58543
- const members = await stores.groupStore.listMembers(group.id);
58544
- const tracks = members.map((m) => trackById.get(m.trackId)).filter((t) => t !== void 0);
58545
- if (tracks.length === 0) continue;
58546
- const classes = [...new Set(tracks.flatMap((t) => [t.className, ...t.classes ?? []]))];
58547
- const zones = [...new Set(tracks.flatMap((t) => [...t.zonesVisited]))];
58548
- const importance = Math.max(0, ...tracks.map((t) => t.importance ?? 0));
58549
- const primary = tracks.reduce((best, t) => (t.importance ?? 0) > (best.importance ?? 0) ? t : best);
58550
- out.push({
58551
- trackId: group.id,
58552
- groupId: group.id,
58553
- memberTrackIds: members.map((m) => m.trackId),
58554
- deviceId,
58555
- className: primary.className,
58556
- firstSeen: group.openedAt,
58557
- lastSeen: group.closedAt,
58558
- importance,
58559
- zones,
58560
- classes,
58561
- ...tracks.every((t) => t.closedReason === "stationary-promotion") ? { stationary: true } : {},
58562
- confirmed: tracks.some((t) => t.observations !== void 0 && t.observations >= minHits)
58563
- });
58564
- }
58565
- return out;
58905
+ return windowTracks.map((t) => ({
58906
+ trackId: t.trackId,
58907
+ deviceId,
58908
+ className: t.className,
58909
+ firstSeen: t.firstSeen,
58910
+ lastSeen: t.lastSeen,
58911
+ zones: [...t.zonesVisited],
58912
+ ...t.importance !== void 0 ? { importance: t.importance } : {},
58913
+ ...t.classes !== void 0 && t.classes.length > 0 ? { classes: t.classes } : {},
58914
+ ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
58915
+ confirmed: t.observations !== void 0 && t.observations >= minHits
58916
+ }));
58566
58917
  },
58567
58918
  listMediaInfo: async (id) => {
58568
58919
  const group = await stores.groupStore.get(id);
58569
- if (group?.mediaKey) return [{
58570
- key: group.mediaKey,
58571
- kind: "groupCrop",
58572
- timestamp: group.timestamp
58573
- }];
58574
- if (group && group.memberCount === 1) {
58575
- const memberId = (await stores.groupStore.listMembers(group.id))[0]?.trackId;
58576
- if (!memberId) return [];
58577
- return (await stores.mediaStore.listInfoByOwner("track", memberId)).map((r) => ({
58920
+ if (group) {
58921
+ if (group.mediaKey || group.nativeKey) return digestMediaRows({
58922
+ mediaKey: group.mediaKey,
58923
+ nativeKey: group.nativeKey,
58924
+ timestamp: group.timestamp,
58925
+ memberRows: []
58926
+ });
58927
+ const members = await stores.groupStore.listMembers(group.id);
58928
+ const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
58578
58929
  key: r.key,
58579
58930
  kind: r.kind,
58580
58931
  timestamp: r.timestamp
58581
- }));
58932
+ })));
58933
+ return digestMediaRows({
58934
+ mediaKey: group.mediaKey,
58935
+ nativeKey: group.nativeKey,
58936
+ timestamp: group.timestamp,
58937
+ memberRows
58938
+ });
58582
58939
  }
58583
- if (group) return [];
58584
58940
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
58585
58941
  key: r.key,
58586
58942
  kind: r.kind,
@@ -59826,6 +60182,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59826
60182
  state: t.state
59827
60183
  });
59828
60184
  positionsCountById.set(t.trackId, upserted.positions.length);
60185
+ this.enqueueLiveGroup(upserted, result.timestamp);
59829
60186
  }
59830
60187
  if (result.rawTrackedDetections.length > 0) {
59831
60188
  const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
@@ -60039,6 +60396,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60039
60396
  deviceId,
60040
60397
  tracked: result.tracked,
60041
60398
  packageClasses,
60399
+ packageZoneIds: collectPackageZoneIds(proxy?.state.zoneRules.value?.package ?? []),
60042
60400
  settings: packageSettings,
60043
60401
  frameWidth: result.frameWidth,
60044
60402
  frameHeight: result.frameHeight,
@@ -61749,8 +62107,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61749
62107
  async sweepExpiredTracks() {
61750
62108
  if (this.shuttingDown || !this.trackStore) return;
61751
62109
  await this.trackCloser.sweep();
61752
- this.groupSealer?.sealRecent();
62110
+ if (this.isPostProcessingNode && this.groupLive) this.groupSealer?.flushIdle(this.groupLive);
61753
62111
  }
62112
+ /** Hub-only: assign this sighting in RAM. First activity on a camera
62113
+ * replays 2 minutes of that camera (crash recover), then observe is sync. */
62114
+ enqueueLiveGroup(track, now) {}
61754
62115
  /** Unified retention (Phase 1): earliest retained footage per device — the
61755
62116
  * follow-recordings cutoff source. Cached 10 min: availability answers in
61756
62117
  * ~70 ms but the sweep asks once per device per pass. `null` = no footage
@@ -62196,7 +62557,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62196
62557
  runPackageStillness(input) {
62197
62558
  if (this.packageDropDetector === null) return;
62198
62559
  const tags = { deviceId: input.deviceId };
62199
- for (const t of input.tracked) if (!input.packageClasses.has(t.className)) {
62560
+ for (const t of input.tracked) {
62561
+ if (input.packageClasses.has(t.className)) continue;
62562
+ if (!isDelivererInPackageZone(t.zones, input.packageZoneIds)) continue;
62200
62563
  this.lastNonPackageActivityAt.set(input.deviceId, input.timestamp);
62201
62564
  break;
62202
62565
  }
@@ -62243,6 +62606,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62243
62606
  frameWidth: input.frameWidth,
62244
62607
  frameHeight: input.frameHeight,
62245
62608
  timestamp: input.timestamp,
62609
+ minHits: input.settings.packageDropMinFrames,
62246
62610
  minDwellMs: input.settings.packageDropDwellSec * 1e3,
62247
62611
  activityWindowMs: input.settings.packageDropActivityWindowSec * 1e3,
62248
62612
  ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
@@ -62346,9 +62710,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62346
62710
  for (const ex of outcome.expired) {
62347
62711
  const observedDwellMs = ex.lastSeenAt - ex.firstSeenAt;
62348
62712
  const requiredDwellMs = input.settings.packageDropDwellSec * 1e3;
62713
+ const requiredHits = input.settings.packageDropMinFrames;
62349
62714
  const reasons = [];
62350
62715
  if (!ex.activityBackedAtOpen) reasons.push("no-activity-before-open");
62351
- if (ex.rounds < 2) reasons.push("rounds");
62716
+ if (ex.hits < requiredHits) reasons.push("hits");
62352
62717
  if (observedDwellMs < requiredDwellMs) reasons.push("dwell");
62353
62718
  this.ctx.logger.info("package candidate expired without promotion", {
62354
62719
  tags,
@@ -62356,8 +62721,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62356
62721
  candidateId: ex.id,
62357
62722
  className: ex.className,
62358
62723
  bbox: ex.bbox,
62359
- rounds: ex.rounds,
62360
- minRounds: 2,
62724
+ hits: ex.hits,
62725
+ minHits: requiredHits,
62361
62726
  observedDwellMs,
62362
62727
  requiredDwellMs,
62363
62728
  activityBackedAtOpen: ex.activityBackedAtOpen,
@@ -62394,6 +62759,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62394
62759
  };
62395
62760
  if (plausible) this.stationaryRegistry?.promote(entry);
62396
62761
  processor.dropTrack(track.trackId);
62762
+ const peek = this.trackStore?.peekActive(track.trackId);
62763
+ if (peek) this.enqueueLiveGroup(toGroupableTrack({
62764
+ trackId: track.trackId,
62765
+ deviceId,
62766
+ className: track.className,
62767
+ firstSeen: peek.firstSeen,
62768
+ lastSeen: timestamp,
62769
+ positions: peek.positions,
62770
+ closedReason: "stationary-promotion"
62771
+ }), timestamp);
62397
62772
  this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
62398
62773
  this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
62399
62774
  tags: { deviceId },
@@ -62609,6 +62984,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
62609
62984
  async listRecentTracks(input) {
62610
62985
  return this.queryFacade.listRecentTracks(input);
62611
62986
  }
62987
+ async listGroups(input) {
62988
+ return {
62989
+ groups: [],
62990
+ nextCursor: null
62991
+ };
62992
+ }
62993
+ async getGroup(input) {
62994
+ return null;
62995
+ }
62612
62996
  /**
62613
62997
  * Every event kind the device can produce: built-ins (motion + audio),
62614
62998
  * detection classes actually observed on the device, and sensor kinds
@@ -63753,33 +64137,38 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63753
64137
  buildFaceRescoreActionHandlers() {
63754
64138
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
63755
64139
  }
63756
- buildGroupActionHandlers() {
63757
- const ports = () => {
63758
- const groupStore = this.groupStore;
63759
- if (groupStore === null) return null;
63760
- return {
63761
- groupStore,
63762
- loadTrack: async (trackId) => {
63763
- const tracks = this.trackStore;
63764
- if (!tracks) return null;
63765
- return tracks.getActiveByTrack(trackId) ?? await tracks.getPersistedByTrackId(trackId);
63766
- },
63767
- eventMediaBaseUrl: () => this.eventMediaBaseUrl
63768
- };
64140
+ groupQueryPorts() {
64141
+ const groupStore = this.groupStore;
64142
+ if (groupStore === null) return null;
64143
+ return {
64144
+ groupStore,
64145
+ loadTracks: async (trackIds) => {
64146
+ const map = /* @__PURE__ */ new Map();
64147
+ const tracks = this.trackStore;
64148
+ if (!tracks || trackIds.length === 0) return map;
64149
+ const missing = [];
64150
+ for (const id of trackIds) {
64151
+ const active = tracks.getActiveByTrack(id);
64152
+ if (active !== null) map.set(id, trackToLite(active));
64153
+ else missing.push(id);
64154
+ }
64155
+ if (missing.length === 0) return map;
64156
+ for (const persisted of await tracks.getPersistedByTrackIds(missing)) map.set(persisted.trackId, trackToLite(persisted));
64157
+ return map;
64158
+ },
64159
+ eventMediaBaseUrl: () => this.eventMediaBaseUrl
63769
64160
  };
64161
+ }
64162
+ buildGroupActionHandlers() {
63770
64163
  return {
63771
64164
  "analytics.listGroups": async (input) => {
63772
- const ready = ports();
63773
- if (ready === null) return {
64165
+ return {
63774
64166
  groups: [],
63775
64167
  hasMore: false
63776
64168
  };
63777
- return listGroupRecords(ready, input);
63778
64169
  },
63779
64170
  "analytics.getGroup": async (input) => {
63780
- const ready = ports();
63781
- if (ready === null) return { group: null };
63782
- return { group: await getGroupDetail(ready, input.groupId) };
64171
+ return { group: null };
63783
64172
  }
63784
64173
  };
63785
64174
  }