@camstack/addon-post-analysis 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-Bu9eXObI.mjs";
1
+ import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-gQ5DHTYd.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -3355,6 +3355,24 @@ async function cascadeDeleteTracks(registry, trackStore, trackIds) {
3355
3355
  for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
3356
3356
  }
3357
3357
  /**
3358
+ * Full whole-track cascade for ONE track: events → their media + track media →
3359
+ * track row. Delegates to the generalized `cascadeDeleteTracks` so the shipped
3360
+ * `deleteTracks` provider path flows through the same engine while preserving
3361
+ * the exact legacy call order (event ids stay local to the delete). Adapts the
3362
+ * narrow legacy store shapes onto `TrackScopedStore` — a superset store (faces /
3363
+ * embeddings) is opted in by the CALLER's registry, not by this legacy shim.
3364
+ */
3365
+ async function cascadeDeleteTrack(stores, trackId) {
3366
+ await cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
3367
+ for (const id of ids) {
3368
+ const eventIds = await stores.eventStore.deleteByTrack(id);
3369
+ if (eventIds.length > 0) await stores.mediaStore.deleteForEvents([...eventIds]);
3370
+ }
3371
+ } }, { deleteByTracks: async (ids) => {
3372
+ await stores.mediaStore.deleteForTracks([...ids]);
3373
+ } }], stores.trackStore, [trackId]);
3374
+ }
3375
+ /**
3358
3376
  * Widened batch whole-track deletion — the ONE engine behind the three §5 entry
3359
3377
  * points. Runs the FULL registry cascade per track (via `cascadeDeleteTracks`
3360
3378
  * with a single-id list so the track root is deleted last), fires cleanup on
@@ -4492,6 +4510,7 @@ var SINGLE_INSTANCE_KINDS = new Set([
4492
4510
  "keyFrame",
4493
4511
  "keyFrameSmall",
4494
4512
  "thumbnail",
4513
+ "thumbnailSmall",
4495
4514
  "firstFrame",
4496
4515
  "lastFrame"
4497
4516
  ]);
@@ -6472,6 +6491,106 @@ function squareSafeCropRegionNormalized(bbox, frame, padding) {
6472
6491
  };
6473
6492
  }
6474
6493
  //#endregion
6494
+ //#region src/shared/frame/crop-extractor.ts
6495
+ /**
6496
+ * Extracts a JPEG-encoded crop from a raw frame buffer using a normalized bounding box.
6497
+ * Coordinates are clamped to frame bounds to avoid out-of-range errors.
6498
+ */
6499
+ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
6500
+ const rawLeft = Math.round(bbox.x * frameWidth);
6501
+ const rawTop = Math.round(bbox.y * frameHeight);
6502
+ const rawWidth = Math.round(bbox.w * frameWidth);
6503
+ const rawHeight = Math.round(bbox.h * frameHeight);
6504
+ const left = Math.max(0, Math.min(rawLeft, frameWidth - 1));
6505
+ const top = Math.max(0, Math.min(rawTop, frameHeight - 1));
6506
+ const width = Math.max(1, Math.min(rawWidth, frameWidth - left));
6507
+ const height = Math.max(1, Math.min(rawHeight, frameHeight - top));
6508
+ return {
6509
+ crop: await sharp(frameData, { raw: {
6510
+ width: frameWidth,
6511
+ height: frameHeight,
6512
+ channels: 3
6513
+ } }).extract({
6514
+ left,
6515
+ top,
6516
+ width,
6517
+ height
6518
+ }).jpeg({ quality: 90 }).toBuffer(),
6519
+ width,
6520
+ height
6521
+ };
6522
+ }
6523
+ //#endregion
6524
+ //#region src/shared/frame/square-subject-crop.ts
6525
+ /** Expansion factor applied to the bbox long side to frame the subject with a
6526
+ * little breathing room (operator choice: ×1.2). */
6527
+ var SQUARE_SUBJECT_CROP_EXPANSION = 1.2;
6528
+ /**
6529
+ * Compute a SQUARE crop region in PIXEL space around the subject bbox.
6530
+ *
6531
+ * Algorithm:
6532
+ * 1. center: cx = x + w/2, cy = y + h/2
6533
+ * 2. side = max(w, h) × SQUARE_SUBJECT_CROP_EXPANSION
6534
+ * 3. clamp side to fit the frame: side = min(side, W, H) — a square can never
6535
+ * exceed the frame's SHORT edge (this is the "subject bigger than the
6536
+ * frame's short side" case)
6537
+ * 4. center-and-clamp the origin so the square stays fully inside the frame
6538
+ * 5. round to integer pixels
6539
+ *
6540
+ * The returned region fully contains the bbox whenever the bbox itself fits in a
6541
+ * square of the frame's short side (always true for real detections).
6542
+ */
6543
+ function squareSubjectCropRegion(bbox, frame) {
6544
+ const { W, H } = frame;
6545
+ const cx = bbox.x + bbox.w / 2;
6546
+ const cy = bbox.y + bbox.h / 2;
6547
+ const longerSide = Math.max(bbox.w, bbox.h);
6548
+ const side = Math.min(longerSide * SQUARE_SUBJECT_CROP_EXPANSION, W, H);
6549
+ const rawX0 = cx - side / 2;
6550
+ const rawY0 = cy - side / 2;
6551
+ const x0 = Math.max(0, Math.min(rawX0, W - side));
6552
+ const y0 = Math.max(0, Math.min(rawY0, H - side));
6553
+ return {
6554
+ x: Math.round(x0),
6555
+ y: Math.round(y0),
6556
+ w: Math.round(side),
6557
+ h: Math.round(side)
6558
+ };
6559
+ }
6560
+ /**
6561
+ * The same SQUARE region as {@link squareSubjectCropRegion}, expressed in
6562
+ * NORMALIZED [0,1]×[0,1] coordinates. A normalized box maps DIRECTLY onto a
6563
+ * native-resolution surface of the SAME aspect ratio, so the region computed
6564
+ * from the detection-frame dimensions addresses the exact same ROI on the
6565
+ * runner's retained native frame. Reuses the pixel geometry verbatim (single
6566
+ * source of truth) and divides by the frame dimensions.
6567
+ */
6568
+ function squareSubjectCropRegionNormalized(bbox, frame) {
6569
+ const region = squareSubjectCropRegion(bbox, frame);
6570
+ return {
6571
+ x: region.x / frame.W,
6572
+ y: region.y / frame.H,
6573
+ w: region.w / frame.W,
6574
+ h: region.h / frame.H
6575
+ };
6576
+ }
6577
+ /**
6578
+ * Derive the `thumbnailSmall` JPEG from an already-encoded native subject-crop
6579
+ * JPEG. Downscales to at most {@link THUMBNAIL_SMALL_MAX_WIDTH} on the long side
6580
+ * (aspect preserved). If the native crop's long side is already ≤ the cap, the
6581
+ * ORIGINAL buffer is returned unchanged (store as-is, never upscale, no wasted
6582
+ * re-encode).
6583
+ */
6584
+ async function deriveThumbnailSmall(nativeJpeg) {
6585
+ const meta = await sharp(nativeJpeg).metadata();
6586
+ const longSide = Math.max(meta.width ?? 0, meta.height ?? 0);
6587
+ if (longSide > 0 && longSide <= 480) return nativeJpeg;
6588
+ return sharp(nativeJpeg).resize(480, 480, {
6589
+ fit: "inside",
6590
+ withoutEnlargement: true
6591
+ }).jpeg({ quality: 88 }).toBuffer();
6592
+ }
6593
+ //#endregion
6475
6594
  //#region src/shared/frame/box-drawer.ts
6476
6595
  var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
6477
6596
  var DEFAULT_QUALITY = 80;
@@ -6640,7 +6759,8 @@ var EventMediaDispatcher = class {
6640
6759
  const snapshots = input.snapshots ?? [];
6641
6760
  const empty = {
6642
6761
  storedSnapshots: [],
6643
- thumbnailTrackIds: []
6762
+ thumbnailTrackIds: [],
6763
+ rasterFallbacks: []
6644
6764
  };
6645
6765
  if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
6646
6766
  let decoded;
@@ -6692,21 +6812,67 @@ var EventMediaDispatcher = class {
6692
6812
  });
6693
6813
  return empty;
6694
6814
  }
6815
+ const rasterFallbacks = await this.collectRasterFallbacks(frameData, fw, fh, trackFrames, snapshots, input.cropPadding, input.rasterFallbackWantedTrackIds);
6695
6816
  for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, fw, fh, ev, input.cropPadding);
6696
6817
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf);
6697
6818
  const storedSnapshots = [];
6698
6819
  const thumbnailTrackIds = [];
6699
6820
  for (const sn of snapshots) {
6700
- const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, input.cropPadding);
6821
+ const res = await this.writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn);
6701
6822
  if (res.storedSnapshot) storedSnapshots.push(res.storedSnapshot);
6702
6823
  if (res.thumbnailWritten) thumbnailTrackIds.push(sn.trackId);
6703
6824
  }
6704
6825
  return {
6705
6826
  storedSnapshots,
6706
- thumbnailTrackIds
6827
+ thumbnailTrackIds,
6828
+ rasterFallbacks
6707
6829
  };
6708
6830
  }
6709
6831
  /**
6832
+ * Cut ONE clean detection-raster subject crop per WANTED track that has a
6833
+ * target (firstFrame or snapshot) in this frame — the "first available
6834
+ * detection-raster frame" of the zero-media fallback. Cropped from the
6835
+ * already-resolved `frameData` at its REAL resolution via {@link extractCrop}
6836
+ * (extract-only — NEVER upscaled). Deduped per trackId (first target wins).
6837
+ * A per-track encode failure is skipped (logged) — a missing fallback simply
6838
+ * leaves the track with no last-resort preview, never an error.
6839
+ */
6840
+ async collectRasterFallbacks(frameData, fw, fh, trackFrames, snapshots, cropPadding, wanted) {
6841
+ if (!wanted || wanted.size === 0) return [];
6842
+ const seen = /* @__PURE__ */ new Set();
6843
+ const out = [];
6844
+ const candidates = [...trackFrames.map((t) => ({
6845
+ trackId: t.trackId,
6846
+ timestamp: t.timestamp,
6847
+ bbox: t.bbox
6848
+ })), ...snapshots.map((s) => ({
6849
+ trackId: s.trackId,
6850
+ timestamp: s.timestamp,
6851
+ bbox: s.bbox
6852
+ }))];
6853
+ for (const c of candidates) {
6854
+ if (!wanted.has(c.trackId) || seen.has(c.trackId)) continue;
6855
+ seen.add(c.trackId);
6856
+ try {
6857
+ const { crop } = await extractCrop(frameData, fw, fh, squareSafeCropRegionNormalized(c.bbox, {
6858
+ W: fw,
6859
+ H: fh
6860
+ }, cropPadding));
6861
+ out.push({
6862
+ trackId: c.trackId,
6863
+ timestamp: c.timestamp,
6864
+ jpeg: crop
6865
+ });
6866
+ } catch (err) {
6867
+ this.deps.logger.debug("event media: raster fallback crop failed", { meta: {
6868
+ trackId: c.trackId,
6869
+ error: err instanceof Error ? err.message : String(err)
6870
+ } });
6871
+ }
6872
+ }
6873
+ return out;
6874
+ }
6875
+ /**
6710
6876
  * Periodic per-track media (§5). The boxed FULL frame is encoded once and
6711
6877
  * shared by the appended `snapshot` (timeline filmstrip) and the rolling
6712
6878
  * `lastFrame` (overwrite). The best `thumbnail` is DIFFERENT: a clean
@@ -6718,7 +6884,7 @@ var EventMediaDispatcher = class {
6718
6884
  * failed). `thumbnailWritten` reports whether a best `thumbnail` actually
6719
6885
  * landed this frame (#27-A) so the caller can stop forcing retries.
6720
6886
  */
6721
- async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn, cropPadding) {
6887
+ async writeTrackSnapshot(deviceId, frameHandle, frameData, fw, fh, sn) {
6722
6888
  if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return {
6723
6889
  storedSnapshot: null,
6724
6890
  thumbnailWritten: false
@@ -6745,8 +6911,11 @@ var EventMediaDispatcher = class {
6745
6911
  if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
6746
6912
  let thumbnailWritten = false;
6747
6913
  if (sn.bestThumbnail) {
6748
- const crop = await this.cropSubjectRegion(frameHandle, fw, fh, sn.bbox, cropPadding);
6749
- if (crop) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
6914
+ const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
6915
+ if (variants) {
6916
+ thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
6917
+ await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
6918
+ }
6750
6919
  }
6751
6920
  return {
6752
6921
  storedSnapshot: stored,
@@ -6796,6 +6965,54 @@ var EventMediaDispatcher = class {
6796
6965
  }
6797
6966
  }
6798
6967
  /**
6968
+ * The best-shot subject crop as its TWO persisted variants (best-crop
6969
+ * fast-load, 2026-07-21). ONE native fetch: the SQUARE-framed subject ROI
6970
+ * (side = max(w,h)×1.2, clamped to the frame — {@link squareSubjectCropRegionNormalized})
6971
+ * is requested from the runner's retained native surface with NO `maxWidth`
6972
+ * (uncapped TRUE native, decision #3) → the `thumbnail`. The `thumbnailSmall`
6973
+ * is DERIVED by downscaling that SAME JPEG to the 480 long-side cap — never a
6974
+ * second `getNativeCropJpeg`, never upscaled ({@link deriveThumbnailSmall}
6975
+ * returns the native buffer as-is when it is already ≤ 480).
6976
+ *
6977
+ * NATIVE-OR-NOTHING: any miss/error (or a runner without the method) returns
6978
+ * `null` after a loud `logger.warn`; the caller SKIPS the write and the
6979
+ * per-frame retry lands a real native crop later. Never a local resize
6980
+ * upscale of a ≤640 tile (a blurred lie).
6981
+ */
6982
+ async cropSubjectVariants(frameHandle, fw, fh, bbox) {
6983
+ if (!this.deps.getNativeCropJpeg) {
6984
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
6985
+ shmId: frameHandle.shmId,
6986
+ reason: "no-native-cap"
6987
+ } });
6988
+ return null;
6989
+ }
6990
+ try {
6991
+ const norm = squareSubjectCropRegionNormalized(bbox, {
6992
+ W: fw,
6993
+ H: fh
6994
+ });
6995
+ const native = await this.deps.getNativeCropJpeg(frameHandle, norm);
6996
+ if (!native) {
6997
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
6998
+ shmId: frameHandle.shmId,
6999
+ reason: "native-miss"
7000
+ } });
7001
+ return null;
7002
+ }
7003
+ return {
7004
+ thumbnail: native,
7005
+ thumbnailSmall: await deriveThumbnailSmall(native)
7006
+ };
7007
+ } catch (err) {
7008
+ this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
7009
+ shmId: frameHandle.shmId,
7010
+ error: err instanceof Error ? err.message : String(err)
7011
+ } });
7012
+ return null;
7013
+ }
7014
+ }
7015
+ /**
6799
7016
  * The boxed timeline tile (`firstFrame`/`snapshot`/`lastFrame`): the track's
6800
7017
  * OWN box burned onto the NATIVE full frame downscaled to
6801
7018
  * {@link SNAPSHOT_MAX_WIDTH}. On a native miss it falls back to the ≤640
@@ -6922,36 +7139,6 @@ var EventMediaDispatcher = class {
6922
7139
  }
6923
7140
  };
6924
7141
  //#endregion
6925
- //#region src/shared/frame/crop-extractor.ts
6926
- /**
6927
- * Extracts a JPEG-encoded crop from a raw frame buffer using a normalized bounding box.
6928
- * Coordinates are clamped to frame bounds to avoid out-of-range errors.
6929
- */
6930
- async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
6931
- const rawLeft = Math.round(bbox.x * frameWidth);
6932
- const rawTop = Math.round(bbox.y * frameHeight);
6933
- const rawWidth = Math.round(bbox.w * frameWidth);
6934
- const rawHeight = Math.round(bbox.h * frameHeight);
6935
- const left = Math.max(0, Math.min(rawLeft, frameWidth - 1));
6936
- const top = Math.max(0, Math.min(rawTop, frameHeight - 1));
6937
- const width = Math.max(1, Math.min(rawWidth, frameWidth - left));
6938
- const height = Math.max(1, Math.min(rawHeight, frameHeight - top));
6939
- return {
6940
- crop: await sharp(frameData, { raw: {
6941
- width: frameWidth,
6942
- height: frameHeight,
6943
- channels: 3
6944
- } }).extract({
6945
- left,
6946
- top,
6947
- width,
6948
- height
6949
- }).jpeg({ quality: 90 }).toBuffer(),
6950
- width,
6951
- height
6952
- };
6953
- }
6954
- //#endregion
6955
7142
  //#region src/pipeline-analytics/embedding/embedding-dispatcher.ts
6956
7143
  function selectCropBbox(detection) {
6957
7144
  return detection.refinedBbox ?? detection.bbox;
@@ -8740,6 +8927,16 @@ function planPeriodicMedia(input) {
8740
8927
  bestThumbnail: input.isNewBest || !thumbnailLanded
8741
8928
  };
8742
8929
  }
8930
+ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = { suppressMaxDurationMs: 1e3 };
8931
+ /**
8932
+ * Classify a closing track's persistence outcome. Pure — see the module header
8933
+ * for the full contract.
8934
+ */
8935
+ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
8936
+ if (input.hasMedia) return "persist";
8937
+ if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
8938
+ return input.hasRasterFallback ? "raster-fallback" : "persist";
8939
+ }
8743
8940
  //#endregion
8744
8941
  //#region src/pipeline-analytics/best-thumbnail-guard.ts
8745
8942
  /**
@@ -11992,10 +12189,28 @@ function pickEventOwnedMedia(files) {
11992
12189
  return files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
11993
12190
  }
11994
12191
  /** Pick a track's fallback media in the shared cadence-preference order the
11995
- * KeyEvent path uses: best `thumbnail` rolling `lastFrame` → `firstFrame` →
11996
- * newest `snapshot` → any track blob. Undefined when the track owns no media. */
12192
+ * KeyEvent path uses: `thumbnailSmall` (480 fast-load)best `thumbnail` →
12193
+ * rolling `lastFrame` → `firstFrame` newest `snapshot` any track blob.
12194
+ * Undefined when the track owns no media. */
11997
12195
  function pickTrackFallbackMedia(files) {
11998
- return files.find((f) => f.kind === "thumbnail") ?? files.find((f) => f.kind === "lastFrame") ?? files.find((f) => f.kind === "firstFrame") ?? [...files].reverse().find((f) => f.kind === "snapshot") ?? files[files.length - 1];
12196
+ return files.find((f) => f.kind === "thumbnailSmall") ?? files.find((f) => f.kind === "thumbnail") ?? files.find((f) => f.kind === "lastFrame") ?? files.find((f) => f.kind === "firstFrame") ?? [...files].reverse().find((f) => f.kind === "snapshot") ?? files[files.length - 1];
12197
+ }
12198
+ /**
12199
+ * Crop-forced resolution (the `?kind=crop` reel/timeline path). Prefer a CLEAN
12200
+ * (never-boxed) frame via the injected `pickClean`; when the track owns NO clean
12201
+ * frame at all — the regression class created by retiring `crop`/`fullFrame`
12202
+ * production (2026-07-21), leaving degenerate tracks with only boxed
12203
+ * `lastFrame`/`snapshot`/`firstFrame` — degrade to ANY track media rather than
12204
+ * returning undefined. A boxed frame (with the target's box drawn) is a real
12205
+ * preview; an empty tile is not. Clean is always tried FIRST so the common case
12206
+ * still serves an unboxed tile. Undefined only when the owner has NO media.
12207
+ *
12208
+ * `pickClean` is injected because the clean-kind picker (`pickCleanMedia`) lives
12209
+ * in the addon `index.ts` over the concrete `MediaFileKind` set; this keeps the
12210
+ * composition pure + unit-testable here.
12211
+ */
12212
+ function resolveCropForcedMedia(files, pickClean) {
12213
+ return pickClean(files) ?? pickTrackFallbackMedia(files);
11999
12214
  }
12000
12215
  /**
12001
12216
  * Resolve the default-path media for an event id: the event's own media when it
@@ -12353,6 +12568,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12353
12568
  * area) + emit time, so a material improvement is measured against the
12354
12569
  * last emit and debounced. Seeded at `start`, dropped at `end`. */
12355
12570
  trackLifecycleUpdateMem = /* @__PURE__ */ new Map();
12571
+ /** Per-active-track close-policy state (spec §"Zero-media track policy"):
12572
+ * whether the track ever reached the tracker's hit-confirmation
12573
+ * (`trackAge >= minHits`) and the FIRST retained detection-raster subject
12574
+ * crop. Both are read ONLY at close (`sweepExpiredTracks`) to decide
12575
+ * suppress / raster-fallback / persist for a track that ends with no media.
12576
+ * `deviceId` lets `clearDevice` prune without an active-track lookup. Dropped
12577
+ * on every teardown path. */
12578
+ trackClosureState = /* @__PURE__ */ new Map();
12356
12579
  /** The shared crop extractor (native-res first, detection-frame fallback),
12357
12580
  * captured in the constructor so `processFrame` can crop object thumbnails in
12358
12581
  * the same live-frame window as the face/plate/event-media captures. The
@@ -12844,6 +13067,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12844
13067
  this.bindingCache?.onBindingsChanged(data);
12845
13068
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
12846
13069
  this.trackStore?.clearDevice(data.deviceId);
13070
+ this.clearClosureStateForDevice(data.deviceId);
12847
13071
  this.stationaryRegistry?.forgetDevice(data.deviceId);
12848
13072
  this.overlayState.clearDevice(data.deviceId);
12849
13073
  this.overlaySynthesisWarnAt.delete(data.deviceId);
@@ -12862,6 +13086,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12862
13086
  this.unsubDeviceUnreg = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (ev) => {
12863
13087
  const { deviceId } = ev.data;
12864
13088
  this.trackStore?.clearDevice(deviceId);
13089
+ this.clearClosureStateForDevice(deviceId);
12865
13090
  this.stationaryRegistry?.forgetDevice(deviceId);
12866
13091
  this.overlayState.clearDevice(deviceId);
12867
13092
  this.overlaySynthesisWarnAt.delete(deviceId);
@@ -13217,6 +13442,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13217
13442
  this.thumbnailInFlight.clear();
13218
13443
  this.keyFrameInFlight.clear();
13219
13444
  this.trackLifecycleUpdateMem.clear();
13445
+ this.trackClosureState.clear();
13220
13446
  this.objectEmbeddingBestSelector.clear();
13221
13447
  this.levelStateByDevice.clear();
13222
13448
  this.settingsCacheByDevice.clear();
@@ -13335,6 +13561,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13335
13561
  });
13336
13562
  positionsCountById.set(t.trackId, upserted.positions.length);
13337
13563
  }
13564
+ if (result.rawTrackedDetections.length > 0) {
13565
+ const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
13566
+ for (const rt of result.rawTrackedDetections) if (rt.trackAge >= minHits) this.markTrackConfirmed(deviceId, rt.trackId);
13567
+ }
13338
13568
  const log = this.ctx.logger.withTags({ deviceId });
13339
13569
  const prevIds = this.lastActiveTrackIds.get(key) ?? /* @__PURE__ */ new Set();
13340
13570
  const firstFrameTargets = [];
@@ -13387,6 +13617,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13387
13617
  ...t.label ? { label: t.label } : {}
13388
13618
  });
13389
13619
  this.trackStore.seedSnapshotClock(id, result.timestamp, t.bbox);
13620
+ this.lastFrameAtByTrack.set(id, result.timestamp);
13390
13621
  }
13391
13622
  this.ctx.eventBus.emit({
13392
13623
  id: `pa-${randomUUID()}`,
@@ -13558,14 +13789,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13558
13789
  } });
13559
13790
  const thumbInFlightTrackIds = snapshotTargets.filter((t) => t.bestThumbnail).map((t) => t.trackId);
13560
13791
  for (const trackId of thumbInFlightTrackIds) this.thumbnailInFlight.add(trackId);
13792
+ const rasterFallbackWantedTrackIds = /* @__PURE__ */ new Set();
13793
+ for (const t of firstFrameTargets) if (!this.trackClosureState.get(t.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(t.trackId);
13794
+ for (const s of snapshotTargets) if (!this.trackClosureState.get(s.trackId)?.rasterFallback) rasterFallbackWantedTrackIds.add(s.trackId);
13561
13795
  this.eventMediaDispatcher.captureForFrame({
13562
13796
  deviceId,
13563
13797
  frameHandle,
13564
13798
  events: eventTargets,
13565
13799
  trackFrames: firstFrameTargets,
13566
13800
  snapshots: snapshotTargets,
13567
- cropPadding: mediaSettings.cropPadding
13801
+ cropPadding: mediaSettings.cropPadding,
13802
+ rasterFallbackWantedTrackIds
13568
13803
  }).then((res) => {
13804
+ for (const rf of res.rasterFallbacks) {
13805
+ const st = this.ensureTrackClosureState(deviceId, rf.trackId);
13806
+ if (!st.rasterFallback) st.rasterFallback = {
13807
+ jpeg: rf.jpeg,
13808
+ timestamp: rf.timestamp
13809
+ };
13810
+ }
13569
13811
  for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
13570
13812
  timestamp: s.timestamp,
13571
13813
  position: {
@@ -14576,6 +14818,44 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14576
14818
  const expired = await this.trackStore.expireStale(Date.now());
14577
14819
  for (const t of expired) {
14578
14820
  const duration = t.lastSeen - t.firstSeen;
14821
+ const closure = this.trackClosureState.get(t.trackId);
14822
+ const outcome = decideZeroMediaPolicy({
14823
+ durationMs: duration,
14824
+ hasMedia: (await this.mediaStore?.listByOwner("track", t.trackId) ?? []).length > 0,
14825
+ confirmed: closure?.confirmed ?? false,
14826
+ hasRasterFallback: closure?.rasterFallback !== void 0
14827
+ });
14828
+ if (outcome === "suppress") {
14829
+ this.ctx.logger.info("track suppressed (zero-media false birth)", {
14830
+ tags: { deviceId: t.deviceId },
14831
+ meta: {
14832
+ trackId: t.trackId,
14833
+ className: t.className,
14834
+ durationMs: duration,
14835
+ positions: t.positions.length
14836
+ }
14837
+ });
14838
+ await this.suppressZeroMediaTrack(t.deviceId, t.trackId);
14839
+ continue;
14840
+ }
14841
+ if (outcome === "raster-fallback" && closure?.rasterFallback) try {
14842
+ await this.mediaStore?.put({
14843
+ deviceId: t.deviceId,
14844
+ ownerKind: "track",
14845
+ ownerId: t.trackId,
14846
+ kind: "thumbnail",
14847
+ timestamp: closure.rasterFallback.timestamp,
14848
+ data: closure.rasterFallback.jpeg
14849
+ });
14850
+ } catch (err) {
14851
+ this.ctx.logger.debug("zero-media raster fallback put failed", {
14852
+ tags: { deviceId: t.deviceId },
14853
+ meta: {
14854
+ trackId: t.trackId,
14855
+ error: String(err)
14856
+ }
14857
+ });
14858
+ }
14579
14859
  this.ctx.logger.info("track ended", {
14580
14860
  tags: { deviceId: t.deviceId },
14581
14861
  meta: {
@@ -14686,12 +14966,53 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14686
14966
  });
14687
14967
  this.emitTrackLifecycle(endPayload, t.lastSeen);
14688
14968
  this.trackLifecycleUpdateMem.delete(t.trackId);
14969
+ this.trackClosureState.delete(t.trackId);
14689
14970
  }
14690
14971
  } catch (err) {
14691
14972
  if (this.shuttingDown) return;
14692
14973
  this.ctx.logger.debug("sweepExpiredTracks failed", { meta: { error: String(err) } });
14693
14974
  }
14694
14975
  }
14976
+ /**
14977
+ * Undo a zero-media false-birth track (spec §"Zero-media track policy"): tear
14978
+ * down its live per-track state (mirroring the natural end MINUS importance
14979
+ * scoring + the lifecycle `end`/`TrackEnded` events) and cascade-delete the
14980
+ * just-persisted row + any events/media so nothing dangles. Best-effort.
14981
+ */
14982
+ async suppressZeroMediaTrack(deviceId, trackId) {
14983
+ const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, trackId);
14984
+ const dropKeyFrame = () => {
14985
+ this.keyFrameKeyByTrackId.delete(trackId);
14986
+ };
14987
+ if (faceEnd) faceEnd.finally(dropKeyFrame);
14988
+ else dropKeyFrame();
14989
+ this.plateRecognizer?.onTrackEnd(deviceId, trackId);
14990
+ this.bestFrameTracker.delete(trackId);
14991
+ this.objectEmbeddingBestSelector.delete(trackId);
14992
+ this.lastFrameAtByTrack.delete(trackId);
14993
+ this.thumbnailLandedTracks.delete(trackId);
14994
+ this.thumbnailInFlight.delete(trackId);
14995
+ this.keyFrameInFlight.delete(trackId);
14996
+ this.detailDispatcher?.onTrackEnded(deviceId, trackId);
14997
+ this.overlayState.onTrackEnded(deviceId, trackId);
14998
+ this.trackLifecycleUpdateMem.delete(trackId);
14999
+ this.trackClosureState.delete(trackId);
15000
+ if (this.eventStore && this.mediaStore && this.trackStore) try {
15001
+ await cascadeDeleteTrack({
15002
+ eventStore: this.eventStore,
15003
+ mediaStore: this.mediaStore,
15004
+ trackStore: this.trackStore
15005
+ }, trackId);
15006
+ } catch (err) {
15007
+ this.ctx.logger.debug("zero-media suppression cascade failed", {
15008
+ tags: { deviceId },
15009
+ meta: {
15010
+ trackId,
15011
+ error: String(err)
15012
+ }
15013
+ });
15014
+ }
15015
+ }
14695
15016
  async sweepRetention() {
14696
15017
  if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
14697
15018
  const now = Date.now();
@@ -14901,6 +15222,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
14901
15222
  this.thumbnailInFlight.delete(track.trackId);
14902
15223
  this.keyFrameInFlight.delete(track.trackId);
14903
15224
  this.trackLifecycleUpdateMem.delete(track.trackId);
15225
+ this.trackClosureState.delete(track.trackId);
14904
15226
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
14905
15227
  this.overlayState.onTrackEnded(deviceId, track.trackId);
14906
15228
  this.lastActiveTrackIds.get(key)?.delete(track.trackId);
@@ -15199,6 +15521,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15199
15521
  }
15200
15522
  async clearTracks(input) {
15201
15523
  this.trackStore?.clearDevice(input.deviceId);
15524
+ this.clearClosureStateForDevice(input.deviceId);
15202
15525
  this.stationaryRegistry?.clearDevice(input.deviceId);
15203
15526
  this.overlayState.clearDevice(input.deviceId);
15204
15527
  this.overlaySynthesisWarnAt.delete(input.deviceId);
@@ -15411,6 +15734,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15411
15734
  clearLiveTrackState(deviceId, trackId) {
15412
15735
  this.detailDispatcher?.onTrackEnded(deviceId, trackId);
15413
15736
  this.overlayState.onTrackEnded(deviceId, trackId);
15737
+ this.trackClosureState.delete(trackId);
15738
+ }
15739
+ /** Lazily create + return a track's close-policy state (zero-media policy). */
15740
+ ensureTrackClosureState(deviceId, trackId) {
15741
+ let st = this.trackClosureState.get(trackId);
15742
+ if (!st) {
15743
+ st = {
15744
+ deviceId,
15745
+ confirmed: false
15746
+ };
15747
+ this.trackClosureState.set(trackId, st);
15748
+ }
15749
+ return st;
15750
+ }
15751
+ /** Sticky-mark a track as confirmed once it reaches the tracker's hit gate. */
15752
+ markTrackConfirmed(deviceId, trackId) {
15753
+ this.ensureTrackClosureState(deviceId, trackId).confirmed = true;
15754
+ }
15755
+ /** Drop every close-policy entry for a device (mirrors `trackStore.clearDevice`). */
15756
+ clearClosureStateForDevice(deviceId) {
15757
+ for (const [trackId, st] of this.trackClosureState) if (st.deviceId === deviceId) this.trackClosureState.delete(trackId);
15414
15758
  }
15415
15759
  async deleteTracks(input) {
15416
15760
  const cascade = this.buildTrackCascadeRegistry();
@@ -15705,11 +16049,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
15705
16049
  const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
15706
16050
  if (preferKind !== void 0 && preferKind.length > 0) {
15707
16051
  const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
15708
- const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
15709
- if (!clean) return null;
16052
+ const chosen = resolveCropForcedMedia([...eventFiles, ...trackFiles], (files) => pickCleanMedia(files, preferKind));
16053
+ if (!chosen) return null;
15710
16054
  return {
15711
- bytes: Buffer.from(clean.base64, "base64"),
15712
- key: clean.key
16055
+ bytes: Buffer.from(chosen.base64, "base64"),
16056
+ key: chosen.key
15713
16057
  };
15714
16058
  }
15715
16059
  const chosen = await resolveDefaultEventMedia({
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-2w2j7Rm4.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-ChHurWQz.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",