@camstack/addon-post-analysis 1.1.29 → 1.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-AFLbpmAs.js → dist-U51kCBdm.js} +4647 -4639
- package/dist/{dist-CFjLqX2m.mjs → dist-yPsKFcJL.mjs} +4648 -4640
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-DtltlqrH.js → node-BFF5_uIc.js} +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D0V4LPAq.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DeoVBjEB.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-B71dWJNT.mjs → hostInit-Bd2d1PYo.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +347 -22
- package/dist/pipeline-analytics/index.mjs +346 -22
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
5
|
+
const require_dist = require("../dist-U51kCBdm.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
let node_fs$1 = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
node_fs = require_dist.__toESM(node_fs);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as embeddingEncoderCapability, m as BaseAddon, s as hfModelUrl } from "../dist-
|
|
1
|
+
import { a as embeddingEncoderCapability, m as BaseAddon, s as hfModelUrl } from "../dist-yPsKFcJL.mjs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path$1 from "node:path";
|
|
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
|
|
|
3
3
|
var e = {
|
|
4
4
|
"@camstack/sdk": {
|
|
5
5
|
name: "@camstack/sdk",
|
|
6
|
-
version: "1.1.
|
|
6
|
+
version: "1.1.25",
|
|
7
7
|
scope: ["default"],
|
|
8
8
|
loaded: !1,
|
|
9
9
|
from: "addon_pipeline_analytics_widgets",
|
|
@@ -18,7 +18,7 @@ var e = {
|
|
|
18
18
|
},
|
|
19
19
|
"@camstack/types": {
|
|
20
20
|
name: "@camstack/types",
|
|
21
|
-
version: "1.1.
|
|
21
|
+
version: "1.1.46",
|
|
22
22
|
scope: ["default"],
|
|
23
23
|
loaded: !1,
|
|
24
24
|
from: "addon_pipeline_analytics_widgets",
|
|
@@ -33,7 +33,7 @@ var e = {
|
|
|
33
33
|
},
|
|
34
34
|
"@camstack/ui-library": {
|
|
35
35
|
name: "@camstack/ui-library",
|
|
36
|
-
version: "1.1.
|
|
36
|
+
version: "1.1.37",
|
|
37
37
|
scope: ["default"],
|
|
38
38
|
loaded: !1,
|
|
39
39
|
from: "addon_pipeline_analytics_widgets",
|
|
@@ -36,7 +36,7 @@ async function r() {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"@camstack/types": {
|
|
39
|
-
version: "1.1.
|
|
39
|
+
version: "1.1.46",
|
|
40
40
|
scope: "default",
|
|
41
41
|
shareConfig: {
|
|
42
42
|
singleton: !0,
|
|
@@ -45,7 +45,7 @@ async function r() {
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"@camstack/sdk": {
|
|
48
|
-
version: "1.1.
|
|
48
|
+
version: "1.1.25",
|
|
49
49
|
scope: "default",
|
|
50
50
|
shareConfig: {
|
|
51
51
|
singleton: !0,
|
|
@@ -81,7 +81,7 @@ async function r() {
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"@camstack/ui-library": {
|
|
84
|
-
version: "1.1.
|
|
84
|
+
version: "1.1.37",
|
|
85
85
|
scope: "default",
|
|
86
86
|
shareConfig: {
|
|
87
87
|
singleton: !0,
|
|
@@ -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-
|
|
5
|
+
const require_dist = require("../dist-U51kCBdm.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -2501,6 +2501,14 @@ var StationaryObjectRegistry = class {
|
|
|
2501
2501
|
count(deviceId) {
|
|
2502
2502
|
return this.byDevice.get(deviceId)?.size ?? 0;
|
|
2503
2503
|
}
|
|
2504
|
+
/** Device ids that currently hold at least one parked entry — drives the
|
|
2505
|
+
* occupancy baseline sampler (a detached camera with parked cars still
|
|
2506
|
+
* gets a flat history baseline). */
|
|
2507
|
+
deviceIds() {
|
|
2508
|
+
const ids = [];
|
|
2509
|
+
for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
|
|
2510
|
+
return ids;
|
|
2511
|
+
}
|
|
2504
2512
|
/** Record that a frame was processed for a device — advances the OBSERVED
|
|
2505
2513
|
* clock that drives entry expiry in {@link sweep}. */
|
|
2506
2514
|
noteFrame(deviceId, timestamp) {
|
|
@@ -2720,6 +2728,26 @@ function rowToEntry(id, data) {
|
|
|
2720
2728
|
};
|
|
2721
2729
|
}
|
|
2722
2730
|
//#endregion
|
|
2731
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
|
|
2732
|
+
/**
|
|
2733
|
+
* Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
|
|
2734
|
+
* Empty when the entry has no frame dims (can't normalise) or no zone matches.
|
|
2735
|
+
*/
|
|
2736
|
+
function computeStationaryEntryZones(entry, zones) {
|
|
2737
|
+
if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
|
|
2738
|
+
const centroidPx = bboxCentroid(entry.bbox);
|
|
2739
|
+
const point = {
|
|
2740
|
+
x: centroidPx.x / entry.frameWidth,
|
|
2741
|
+
y: centroidPx.y / entry.frameHeight
|
|
2742
|
+
};
|
|
2743
|
+
const matched = [];
|
|
2744
|
+
for (const zone of zones) {
|
|
2745
|
+
if (zone.polygon.length < 3) continue;
|
|
2746
|
+
if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
|
|
2747
|
+
}
|
|
2748
|
+
return matched;
|
|
2749
|
+
}
|
|
2750
|
+
//#endregion
|
|
2723
2751
|
//#region src/pipeline-analytics/pipeline/track-appearance.ts
|
|
2724
2752
|
/**
|
|
2725
2753
|
* Pure: no side effects. `continuing` = still active from last frame;
|
|
@@ -5408,6 +5436,17 @@ var RESOLUTION_MS = {
|
|
|
5408
5436
|
* latest state is never lost.
|
|
5409
5437
|
*/
|
|
5410
5438
|
var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
|
|
5439
|
+
/**
|
|
5440
|
+
* Cadence of the synthetic occupancy baseline. When a camera is detached (no
|
|
5441
|
+
* inference frames) but has persisted parked objects, the history ring would
|
|
5442
|
+
* otherwise stay empty and the chart would read "No occupancy history yet". A
|
|
5443
|
+
* device WITH parked entries gets one hydrated sample per this interval — a
|
|
5444
|
+
* flat baseline of the parked count — so the graph shows the parking lot's
|
|
5445
|
+
* standing occupancy instead of a gap. No sample is emitted for a device
|
|
5446
|
+
* without entries, and a real `recordFrame` in the same window suppresses the
|
|
5447
|
+
* baseline (it already appended a richer sample).
|
|
5448
|
+
*/
|
|
5449
|
+
var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
|
|
5411
5450
|
var ZoneAnalyticsProvider = class {
|
|
5412
5451
|
ctx;
|
|
5413
5452
|
snapshots = /* @__PURE__ */ new Map();
|
|
@@ -5424,8 +5463,17 @@ var ZoneAnalyticsProvider = class {
|
|
|
5424
5463
|
/** Last logged frame-wide occupancy total per device — so the occupancy log
|
|
5425
5464
|
* fires only when the count actually changes, not every inference frame. */
|
|
5426
5465
|
lastOccupancyTotal = /* @__PURE__ */ new Map();
|
|
5466
|
+
/** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
|
|
5467
|
+
* device with parked objects but no live frames. `null` when disabled. */
|
|
5468
|
+
baselineTimer = null;
|
|
5427
5469
|
constructor(ctx) {
|
|
5428
5470
|
this.ctx = ctx;
|
|
5471
|
+
if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
|
|
5472
|
+
this.baselineTimer = setInterval(() => {
|
|
5473
|
+
this.appendBaselineSamples();
|
|
5474
|
+
}, BASELINE_SAMPLE_INTERVAL_MS);
|
|
5475
|
+
this.baselineTimer.unref?.();
|
|
5476
|
+
}
|
|
5429
5477
|
this.sliceThrottle = new SliceThrottler({
|
|
5430
5478
|
intervalMs: SLICE_WRITE_INTERVAL_MS$1,
|
|
5431
5479
|
equalsIgnoringTs: snapshotEqualsIgnoringTs,
|
|
@@ -5447,9 +5495,10 @@ var ZoneAnalyticsProvider = class {
|
|
|
5447
5495
|
/** Stop pending throttle timers — called from addon shutdown. */
|
|
5448
5496
|
destroy() {
|
|
5449
5497
|
this.sliceThrottle.destroy();
|
|
5498
|
+
if (this.baselineTimer) clearInterval(this.baselineTimer);
|
|
5450
5499
|
}
|
|
5451
5500
|
async getCurrentSnapshot({ deviceId }) {
|
|
5452
|
-
return this.snapshots.get(deviceId) ??
|
|
5501
|
+
return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
|
|
5453
5502
|
}
|
|
5454
5503
|
async getZoneHistory(input) {
|
|
5455
5504
|
return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
|
|
@@ -5502,6 +5551,65 @@ var ZoneAnalyticsProvider = class {
|
|
|
5502
5551
|
this.lastOccupancyTotal.delete(deviceId);
|
|
5503
5552
|
this.sliceThrottle.forgetDevice(deviceId);
|
|
5504
5553
|
}
|
|
5554
|
+
/**
|
|
5555
|
+
* Build an occupancy snapshot for a device purely from its parked-object
|
|
5556
|
+
* registry (no live frame). Returns `null` when hydration is unavailable or
|
|
5557
|
+
* the device has no parked objects — a device with neither frames nor entries
|
|
5558
|
+
* legitimately reports `null`. The snapshot's `ts` is the most recent
|
|
5559
|
+
* `lastConfirmedAt` across entries, falling back to the current tick.
|
|
5560
|
+
*/
|
|
5561
|
+
async hydrateFromRegistry(deviceId) {
|
|
5562
|
+
const listStationary = this.ctx.listStationaryObjects;
|
|
5563
|
+
if (!listStationary) return null;
|
|
5564
|
+
const entries = listStationary(deviceId);
|
|
5565
|
+
if (entries.length === 0) return null;
|
|
5566
|
+
let zones = [];
|
|
5567
|
+
try {
|
|
5568
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
5569
|
+
} catch (err) {
|
|
5570
|
+
this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
|
|
5571
|
+
tags: { deviceId },
|
|
5572
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
5573
|
+
});
|
|
5574
|
+
}
|
|
5575
|
+
const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
|
|
5576
|
+
return buildStationarySnapshot({
|
|
5577
|
+
deviceId,
|
|
5578
|
+
entries,
|
|
5579
|
+
zones,
|
|
5580
|
+
timestamp: ts
|
|
5581
|
+
});
|
|
5582
|
+
}
|
|
5583
|
+
/**
|
|
5584
|
+
* Baseline sampler tick: for every device with parked objects, append a
|
|
5585
|
+
* hydrated sample to the history ring at the CURRENT time — but only when a
|
|
5586
|
+
* real frame hasn't already appended a sample within this interval (frames
|
|
5587
|
+
* flowing = richer samples, no synthetic baseline needed).
|
|
5588
|
+
*/
|
|
5589
|
+
async appendBaselineSamples() {
|
|
5590
|
+
const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
|
|
5591
|
+
const now = Date.now();
|
|
5592
|
+
for (const deviceId of deviceIds) {
|
|
5593
|
+
const ring = this.history.get(deviceId);
|
|
5594
|
+
if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
|
|
5595
|
+
const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
|
|
5596
|
+
if (entries.length === 0) continue;
|
|
5597
|
+
let zones = [];
|
|
5598
|
+
try {
|
|
5599
|
+
zones = await this.ctx.resolveZones?.(deviceId) ?? [];
|
|
5600
|
+
} catch {}
|
|
5601
|
+
const snapshot = buildStationarySnapshot({
|
|
5602
|
+
deviceId,
|
|
5603
|
+
entries,
|
|
5604
|
+
zones,
|
|
5605
|
+
timestamp: now
|
|
5606
|
+
});
|
|
5607
|
+
if (snapshot) {
|
|
5608
|
+
this.appendHistory(deviceId, snapshot);
|
|
5609
|
+
this.sliceThrottle.push(deviceId, snapshot);
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5505
5613
|
appendHistory(deviceId, snapshot) {
|
|
5506
5614
|
const ring = this.history.get(deviceId) ?? [];
|
|
5507
5615
|
const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
|
|
@@ -5592,6 +5700,39 @@ function computeSnapshot(input) {
|
|
|
5592
5700
|
...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
|
|
5593
5701
|
};
|
|
5594
5702
|
}
|
|
5703
|
+
/** Most recent `lastConfirmedAt` across parked entries (0 when none). */
|
|
5704
|
+
function mostRecentStationaryConfirmedAt(entries) {
|
|
5705
|
+
let max = 0;
|
|
5706
|
+
for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
|
|
5707
|
+
return max;
|
|
5708
|
+
}
|
|
5709
|
+
/**
|
|
5710
|
+
* Build an occupancy snapshot from parked-object registry entries alone —
|
|
5711
|
+
* used when no live inference frame is available (fresh respawn, camera
|
|
5712
|
+
* detached). Each entry is folded into the frame aggregate AND attributed to
|
|
5713
|
+
* the zones its normalised bbox centroid falls inside (via
|
|
5714
|
+
* {@link computeStationaryEntryZones}), so a zone drawn over a parked car
|
|
5715
|
+
* reports a count of 1. Returns `null` for an empty entry list. Reuses
|
|
5716
|
+
* {@link computeSnapshot} — the SAME aggregation the live frame path runs.
|
|
5717
|
+
*/
|
|
5718
|
+
function buildStationarySnapshot(input) {
|
|
5719
|
+
if (input.entries.length === 0) return null;
|
|
5720
|
+
const tracked = input.entries.map((e) => ({
|
|
5721
|
+
trackId: `stationary:${e.id}`,
|
|
5722
|
+
className: e.className,
|
|
5723
|
+
zones: computeStationaryEntryZones(e, input.zones)
|
|
5724
|
+
}));
|
|
5725
|
+
const first = input.entries[0];
|
|
5726
|
+
return computeSnapshot({
|
|
5727
|
+
deviceId: input.deviceId,
|
|
5728
|
+
timestamp: input.timestamp,
|
|
5729
|
+
frameWidth: first.frameWidth,
|
|
5730
|
+
frameHeight: first.frameHeight,
|
|
5731
|
+
tracked,
|
|
5732
|
+
zones: input.zones,
|
|
5733
|
+
stationaryObjects: input.entries
|
|
5734
|
+
});
|
|
5735
|
+
}
|
|
5595
5736
|
//#endregion
|
|
5596
5737
|
//#region src/pipeline-analytics/audio-metrics-provider.ts
|
|
5597
5738
|
var AUDIO_METRICS_CAP_NAME = "audio-metrics";
|
|
@@ -7669,6 +7810,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
|
7669
7810
|
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
7670
7811
|
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
7671
7812
|
/**
|
|
7813
|
+
* Consecutive frame-plane misses ("frame + crop both missed") after which a step
|
|
7814
|
+
* is ABANDONED for the track. The decode worker serves native crops from a RAM
|
|
7815
|
+
* lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
|
|
7816
|
+
* from an evicted handle and is a guaranteed miss forever. Retrying a
|
|
7817
|
+
* permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
|
|
7818
|
+
* consecutive misses (each ≥ one tick apart) confidently means the frame is gone
|
|
7819
|
+
* for good, while still tolerating a single transient decode-worker hiccup /
|
|
7820
|
+
* respawn on a genuinely live track (the counter resets on any resolved result).
|
|
7821
|
+
*/
|
|
7822
|
+
var MAX_CONSECUTIVE_FRAME_MISSES = 3;
|
|
7823
|
+
/**
|
|
7672
7824
|
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
7673
7825
|
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
7674
7826
|
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
@@ -7689,7 +7841,9 @@ var DetailScheduler = class {
|
|
|
7689
7841
|
firedCount: 1,
|
|
7690
7842
|
lastFiredAt: nowMs,
|
|
7691
7843
|
sticky: false,
|
|
7692
|
-
retryPending: false
|
|
7844
|
+
retryPending: false,
|
|
7845
|
+
consecutiveFrameMisses: 0,
|
|
7846
|
+
abandoned: false
|
|
7693
7847
|
};
|
|
7694
7848
|
steps.set(stepAnnounce.stepId, state);
|
|
7695
7849
|
requests.push({
|
|
@@ -7722,7 +7876,7 @@ var DetailScheduler = class {
|
|
|
7722
7876
|
tick(nowMs) {
|
|
7723
7877
|
const requests = [];
|
|
7724
7878
|
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
7725
|
-
if (state.sticky) continue;
|
|
7879
|
+
if (state.sticky || state.abandoned) continue;
|
|
7726
7880
|
if (state.retryPending) {
|
|
7727
7881
|
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
7728
7882
|
if (!this.underMaxPerTrack(state)) {
|
|
@@ -7760,7 +7914,8 @@ var DetailScheduler = class {
|
|
|
7760
7914
|
if (!steps) return;
|
|
7761
7915
|
const state = steps.get(stepId);
|
|
7762
7916
|
if (!state) return;
|
|
7763
|
-
if (state.sticky) return;
|
|
7917
|
+
if (state.sticky || state.abandoned) return;
|
|
7918
|
+
state.consecutiveFrameMisses = 0;
|
|
7764
7919
|
const { stickyOnConfidence } = state.announce.cadence;
|
|
7765
7920
|
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
7766
7921
|
state.sticky = true;
|
|
@@ -7775,11 +7930,37 @@ var DetailScheduler = class {
|
|
|
7775
7930
|
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
7776
7931
|
}
|
|
7777
7932
|
}
|
|
7933
|
+
/**
|
|
7934
|
+
* A dispatched request could not resolve a frame AT ALL — the frame handle
|
|
7935
|
+
* lease was evicted AND the crop fallback was unavailable (the "frame + crop
|
|
7936
|
+
* both missed" outcome). This is fundamentally different from `onResult(null)`:
|
|
7937
|
+
* there the frame plane WORKED and the model merely returned nothing (worth a
|
|
7938
|
+
* retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
|
|
7939
|
+
* handle every time, so it can never recover from this request. It is
|
|
7940
|
+
* retry-eligible only for a bounded number of CONSECUTIVE attempts; after
|
|
7941
|
+
* {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
|
|
7942
|
+
* track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
|
|
7943
|
+
* is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
|
|
7944
|
+
*/
|
|
7945
|
+
onFrameMiss(trackId, stepId, _nowMs) {
|
|
7946
|
+
const steps = this.tracks.get(trackId);
|
|
7947
|
+
if (!steps) return;
|
|
7948
|
+
const state = steps.get(stepId);
|
|
7949
|
+
if (!state) return;
|
|
7950
|
+
if (state.sticky || state.abandoned) return;
|
|
7951
|
+
state.consecutiveFrameMisses += 1;
|
|
7952
|
+
if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
|
|
7953
|
+
state.abandoned = true;
|
|
7954
|
+
state.retryPending = false;
|
|
7955
|
+
return;
|
|
7956
|
+
}
|
|
7957
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
7958
|
+
}
|
|
7778
7959
|
onTrackEnded(trackId) {
|
|
7779
7960
|
this.tracks.delete(trackId);
|
|
7780
7961
|
}
|
|
7781
7962
|
canFire(state, nowMs) {
|
|
7782
|
-
if (state.sticky) return false;
|
|
7963
|
+
if (state.sticky || state.abandoned) return false;
|
|
7783
7964
|
if (!this.underMaxPerTrack(state)) return false;
|
|
7784
7965
|
return this.intervalElapsed(state, nowMs);
|
|
7785
7966
|
}
|
|
@@ -7960,8 +8141,12 @@ var TrackDetailDispatcher = class {
|
|
|
7960
8141
|
}
|
|
7961
8142
|
async dispatch(deviceId, dev, req, frame) {
|
|
7962
8143
|
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
8144
|
+
if (details === null) {
|
|
8145
|
+
dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
|
|
8146
|
+
return;
|
|
8147
|
+
}
|
|
7963
8148
|
let topScore = null;
|
|
7964
|
-
if (details
|
|
8149
|
+
if (details.length > 0) {
|
|
7965
8150
|
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
7966
8151
|
const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
|
|
7967
8152
|
if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
|
|
@@ -9294,6 +9479,40 @@ function classifyAudioFrame(top, cfg) {
|
|
|
9294
9479
|
//#endregion
|
|
9295
9480
|
//#region src/pipeline-analytics/event-media-handler.ts
|
|
9296
9481
|
var CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
9482
|
+
/** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
|
|
9483
|
+
* `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
|
|
9484
|
+
var THUMB_DEFAULT_SIZE = 160;
|
|
9485
|
+
var THUMB_MIN_SIZE$1 = 64;
|
|
9486
|
+
var THUMB_MAX_SIZE$1 = 320;
|
|
9487
|
+
/**
|
|
9488
|
+
* Parse the `?kind=…` query into a preferred stored media kind. Returns null
|
|
9489
|
+
* when unset. The value is a free-form kind token (e.g. `crop`); the resolver
|
|
9490
|
+
* validates it against the known kinds.
|
|
9491
|
+
*/
|
|
9492
|
+
function parseEventMediaKind(query) {
|
|
9493
|
+
const kind = new URLSearchParams(query).get("kind");
|
|
9494
|
+
return kind !== null && kind.length > 0 ? kind : null;
|
|
9495
|
+
}
|
|
9496
|
+
/**
|
|
9497
|
+
* Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
|
|
9498
|
+
* when no small-square rendering was requested (the caller then serves the
|
|
9499
|
+
* stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
|
|
9500
|
+
* `size` / `w` / `h` (clamped to [64, 320], default 160).
|
|
9501
|
+
*/
|
|
9502
|
+
function parseEventMediaVariant(query) {
|
|
9503
|
+
const params = new URLSearchParams(query);
|
|
9504
|
+
if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
|
|
9505
|
+
const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
|
|
9506
|
+
let size = THUMB_DEFAULT_SIZE;
|
|
9507
|
+
if (sizeRaw !== null) {
|
|
9508
|
+
const n = Number.parseInt(sizeRaw, 10);
|
|
9509
|
+
if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
|
|
9510
|
+
}
|
|
9511
|
+
return {
|
|
9512
|
+
kind: "thumb",
|
|
9513
|
+
size
|
|
9514
|
+
};
|
|
9515
|
+
}
|
|
9297
9516
|
/**
|
|
9298
9517
|
* Create a data-plane handler that serves event thumbnails as JPEG images.
|
|
9299
9518
|
*
|
|
@@ -9307,14 +9526,20 @@ function createEventMediaHandler(deps) {
|
|
|
9307
9526
|
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
9308
9527
|
return;
|
|
9309
9528
|
}
|
|
9310
|
-
const
|
|
9529
|
+
const url = req.url ?? "/";
|
|
9530
|
+
const qIdx = url.indexOf("?");
|
|
9531
|
+
const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
9532
|
+
const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
|
|
9533
|
+
const eventId = rawPath.replace(/^\/+/, "");
|
|
9311
9534
|
if (!eventId || eventId.includes("/")) {
|
|
9312
9535
|
res.writeHead(404).end();
|
|
9313
9536
|
return;
|
|
9314
9537
|
}
|
|
9538
|
+
const variant = parseEventMediaVariant(query);
|
|
9539
|
+
const preferKind = parseEventMediaKind(query);
|
|
9315
9540
|
let media = null;
|
|
9316
9541
|
try {
|
|
9317
|
-
media = await deps.getMedia(eventId);
|
|
9542
|
+
media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
|
|
9318
9543
|
} catch {
|
|
9319
9544
|
const body = "Internal server error";
|
|
9320
9545
|
res.writeHead(500, {
|
|
@@ -9347,6 +9572,27 @@ function createEventMediaHandler(deps) {
|
|
|
9347
9572
|
else res.end(Buffer.from(media.bytes));
|
|
9348
9573
|
};
|
|
9349
9574
|
}
|
|
9575
|
+
/** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
|
|
9576
|
+
var THUMB_QUALITY = 70;
|
|
9577
|
+
/**
|
|
9578
|
+
* Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
|
|
9579
|
+
* stored 640×360 `crop`). Center-crop cover to a square then downscale to
|
|
9580
|
+
* `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
|
|
9581
|
+
* showing the object, not the full 16:9 crop. Output is a fraction of the source
|
|
9582
|
+
* (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
|
|
9583
|
+
* from tiny HTTP-cached tiles instead of full base64 payloads.
|
|
9584
|
+
*
|
|
9585
|
+
* `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
|
|
9586
|
+
* crops the overflow symmetrically — the center square of a square-safe crop
|
|
9587
|
+
* fully contains the detector bbox, so the object stays framed.
|
|
9588
|
+
*/
|
|
9589
|
+
async function makeSquareThumb(bytes, size) {
|
|
9590
|
+
const edge = Math.max(64, Math.min(320, Math.round(size)));
|
|
9591
|
+
return (0, sharp.default)(Buffer.from(bytes)).resize(edge, edge, {
|
|
9592
|
+
fit: "cover",
|
|
9593
|
+
position: "centre"
|
|
9594
|
+
}).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
9595
|
+
}
|
|
9350
9596
|
//#endregion
|
|
9351
9597
|
//#region src/pipeline-analytics/index.ts
|
|
9352
9598
|
/**
|
|
@@ -9402,6 +9648,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
9402
9648
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
9403
9649
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
9404
9650
|
/**
|
|
9651
|
+
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
9652
|
+
* order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
|
|
9653
|
+
* degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
|
|
9654
|
+
* `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
|
|
9655
|
+
*/
|
|
9656
|
+
var CLEAN_MEDIA_KINDS = [
|
|
9657
|
+
"crop",
|
|
9658
|
+
"fullFrame",
|
|
9659
|
+
"keyFrame"
|
|
9660
|
+
];
|
|
9661
|
+
/**
|
|
9662
|
+
* Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
|
|
9663
|
+
* `preferKind` if it is itself clean and present, else the first available
|
|
9664
|
+
* {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
|
|
9665
|
+
* exists (caller 404s → the viewer shows an icon).
|
|
9666
|
+
*/
|
|
9667
|
+
function pickCleanMedia(files, preferKind) {
|
|
9668
|
+
const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
|
|
9669
|
+
if (isClean(preferKind)) {
|
|
9670
|
+
const exact = files.find((f) => f.kind === preferKind);
|
|
9671
|
+
if (exact) return exact;
|
|
9672
|
+
}
|
|
9673
|
+
for (const kind of CLEAN_MEDIA_KINDS) {
|
|
9674
|
+
const found = files.find((f) => f.kind === kind);
|
|
9675
|
+
if (found) return found;
|
|
9676
|
+
}
|
|
9677
|
+
}
|
|
9678
|
+
/**
|
|
9405
9679
|
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
9406
9680
|
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
9407
9681
|
*/
|
|
@@ -9613,7 +9887,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9613
9887
|
let storage = this.ctx.kernel.storage;
|
|
9614
9888
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
9615
9889
|
if (mediaRoot) {
|
|
9616
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
9890
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BFF5_uIc.js"));
|
|
9617
9891
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
9618
9892
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
9619
9893
|
}
|
|
@@ -9820,7 +10094,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9820
10094
|
});
|
|
9821
10095
|
this.zoneAnalytics = new ZoneAnalyticsProvider({
|
|
9822
10096
|
logger: logger.child("ZoneAnalytics"),
|
|
9823
|
-
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
|
|
10097
|
+
fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
|
|
10098
|
+
listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
|
|
10099
|
+
listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
|
|
10100
|
+
resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
|
|
9824
10101
|
});
|
|
9825
10102
|
this.audioMetrics = new AudioMetricsProvider({
|
|
9826
10103
|
logger: logger.child("AudioMetrics"),
|
|
@@ -9836,9 +10113,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
9836
10113
|
}
|
|
9837
10114
|
});
|
|
9838
10115
|
try {
|
|
9839
|
-
const handler = createEventMediaHandler({ getMedia: async (id) => {
|
|
10116
|
+
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
9840
10117
|
try {
|
|
9841
|
-
return await this.readMediaByEventOrKey(id);
|
|
10118
|
+
return await this.readMediaByEventOrKey(id, variant, preferKind);
|
|
9842
10119
|
} catch (err) {
|
|
9843
10120
|
this.ctx.logger.warn("readEventThumbnail failed", { meta: {
|
|
9844
10121
|
eventId: id,
|
|
@@ -10341,7 +10618,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
10341
10618
|
const stationaryAsTracked = stationaryViews.map((v) => ({
|
|
10342
10619
|
trackId: `stationary:${v.id}`,
|
|
10343
10620
|
className: v.className,
|
|
10344
|
-
zones:
|
|
10621
|
+
zones: computeStationaryEntryZones(v, liveZones)
|
|
10345
10622
|
}));
|
|
10346
10623
|
this.zoneAnalytics?.recordFrame({
|
|
10347
10624
|
deviceId,
|
|
@@ -11777,6 +12054,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
11777
12054
|
return null;
|
|
11778
12055
|
}
|
|
11779
12056
|
}
|
|
12057
|
+
/**
|
|
12058
|
+
* Resolve a device's current 0–1 zone catalogue independent of the live
|
|
12059
|
+
* frame path — used by zone-analytics snapshot hydration + the occupancy
|
|
12060
|
+
* baseline sampler when the camera is detached (no frames). Warms the proxy
|
|
12061
|
+
* (cold read via `fetchDevice`) and, when the cached slice is empty, forces
|
|
12062
|
+
* one `refresh()` round-trip so a just-created proxy returns real zones.
|
|
12063
|
+
*/
|
|
12064
|
+
async resolveDeviceZones(deviceId) {
|
|
12065
|
+
const proxy = await this.ensureProxy(deviceId);
|
|
12066
|
+
if (!proxy) return [];
|
|
12067
|
+
const cached = proxy.state.zones.value?.zones;
|
|
12068
|
+
if (cached && cached.length > 0) return cached;
|
|
12069
|
+
await proxy.state.zones.refresh().catch(() => void 0);
|
|
12070
|
+
return proxy.state.zones.value?.zones ?? [];
|
|
12071
|
+
}
|
|
11780
12072
|
releaseProxy(deviceId) {
|
|
11781
12073
|
const unsubs = this.proxyUnsubs.get(deviceId);
|
|
11782
12074
|
if (unsubs) for (const u of unsubs) try {
|
|
@@ -12152,19 +12444,51 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12152
12444
|
* points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
|
|
12153
12445
|
* key), so this resolves that crop; event ids stay on the event-crop path.
|
|
12154
12446
|
*/
|
|
12155
|
-
async readMediaByEventOrKey(id) {
|
|
12156
|
-
|
|
12157
|
-
|
|
12158
|
-
|
|
12447
|
+
async readMediaByEventOrKey(id, variant, preferKind) {
|
|
12448
|
+
const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
|
|
12449
|
+
if (base === null || variant === void 0) return base;
|
|
12450
|
+
return this.applyThumbVariant(base, variant);
|
|
12451
|
+
}
|
|
12452
|
+
async readMediaByKey(id) {
|
|
12453
|
+
const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
|
|
12454
|
+
if (!file) return null;
|
|
12455
|
+
return {
|
|
12456
|
+
bytes: Buffer.from(file.base64, "base64"),
|
|
12457
|
+
key: file.key
|
|
12458
|
+
};
|
|
12459
|
+
}
|
|
12460
|
+
/**
|
|
12461
|
+
* Render a small center-cropped square from a resolved event media blob for
|
|
12462
|
+
* the reel / list surfaces. The returned `key` is variant-distinct so the
|
|
12463
|
+
* data-plane ETag never collides with the full-size blob's. On any encode
|
|
12464
|
+
* failure the full blob is served (a thumb must never 500 / blank a tile).
|
|
12465
|
+
*/
|
|
12466
|
+
async applyThumbVariant(media, variant) {
|
|
12467
|
+
try {
|
|
12159
12468
|
return {
|
|
12160
|
-
bytes:
|
|
12161
|
-
key:
|
|
12469
|
+
bytes: await makeSquareThumb(media.bytes, variant.size),
|
|
12470
|
+
key: `${media.key}|t${variant.size}`
|
|
12162
12471
|
};
|
|
12472
|
+
} catch (err) {
|
|
12473
|
+
this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
|
|
12474
|
+
key: media.key,
|
|
12475
|
+
size: variant.size,
|
|
12476
|
+
error: require_dist.errMsg(err)
|
|
12477
|
+
} });
|
|
12478
|
+
return media;
|
|
12163
12479
|
}
|
|
12164
|
-
return this.readEventThumbnail(id);
|
|
12165
12480
|
}
|
|
12166
|
-
async readEventThumbnail(id) {
|
|
12481
|
+
async readEventThumbnail(id, preferKind) {
|
|
12167
12482
|
const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
|
|
12483
|
+
if (preferKind !== void 0 && preferKind.length > 0) {
|
|
12484
|
+
const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
|
|
12485
|
+
const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
|
|
12486
|
+
if (!clean) return null;
|
|
12487
|
+
return {
|
|
12488
|
+
bytes: Buffer.from(clean.base64, "base64"),
|
|
12489
|
+
key: clean.key
|
|
12490
|
+
};
|
|
12491
|
+
}
|
|
12168
12492
|
const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
|
|
12169
12493
|
if (chosenEvent) return {
|
|
12170
12494
|
bytes: Buffer.from(chosenEvent.base64, "base64"),
|
|
@@ -12750,5 +13074,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12750
13074
|
};
|
|
12751
13075
|
//#endregion
|
|
12752
13076
|
exports.default = PipelineAnalyticsAddon;
|
|
13077
|
+
exports.pickCleanMedia = pickCleanMedia;
|
|
12753
13078
|
exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
|
|
12754
13079
|
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|