@camstack/addon-post-analysis 1.1.42 → 1.2.1
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-Cgv25jQz.mjs → dist-D4KAS857.mjs} +30 -14
- package/dist/{dist-BveR79HO.js → dist-DMg7kQfI.js} +30 -14
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-C-QTkE6o.js → node--uX7wh6R.js} +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DgeV9o4P.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-ddQyIsuX.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-CnO6PKAO.mjs → hostInit-DGlQ_y2f.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +459 -294
- package/dist/pipeline-analytics/index.mjs +458 -293
- 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-DMg7kQfI.js");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
7
|
let sharp = require("sharp");
|
|
8
8
|
sharp = require_dist.__toESM(sharp);
|
|
@@ -3655,6 +3655,10 @@ var TRACKS_COLUMNS = [
|
|
|
3655
3655
|
name: "source",
|
|
3656
3656
|
type: "TEXT"
|
|
3657
3657
|
},
|
|
3658
|
+
{
|
|
3659
|
+
name: "producingDeviceName",
|
|
3660
|
+
type: "TEXT"
|
|
3661
|
+
},
|
|
3658
3662
|
{
|
|
3659
3663
|
name: "firstSeen",
|
|
3660
3664
|
type: "INTEGER",
|
|
@@ -4362,6 +4366,7 @@ var TrackStore = class {
|
|
|
4362
4366
|
className: t.className,
|
|
4363
4367
|
...t.label !== void 0 ? { label: t.label } : {},
|
|
4364
4368
|
source: t.source ?? "sensor",
|
|
4369
|
+
...t.producingDeviceName !== void 0 ? { producingDeviceName: t.producingDeviceName } : {},
|
|
4365
4370
|
firstSeen: t.firstSeen,
|
|
4366
4371
|
lastSeen: t.lastSeen,
|
|
4367
4372
|
positions: [...t.positions],
|
|
@@ -4423,6 +4428,7 @@ var TrackStore = class {
|
|
|
4423
4428
|
const classes = data["classes"];
|
|
4424
4429
|
const label = data["label"];
|
|
4425
4430
|
const source = data["source"];
|
|
4431
|
+
const producingDeviceName = data["producingDeviceName"];
|
|
4426
4432
|
const importance = data["importance"];
|
|
4427
4433
|
const bestEventId = data["bestEventId"];
|
|
4428
4434
|
const importanceReason = data["importanceReason"];
|
|
@@ -4443,6 +4449,7 @@ var TrackStore = class {
|
|
|
4443
4449
|
className: String(data["className"]),
|
|
4444
4450
|
...typeof label === "string" ? { label } : {},
|
|
4445
4451
|
...source === "sensor" || source === "pipeline" ? { source } : {},
|
|
4452
|
+
...typeof producingDeviceName === "string" ? { producingDeviceName } : {},
|
|
4446
4453
|
firstSeen: Number(data["firstSeen"]),
|
|
4447
4454
|
lastSeen: Number(data["lastSeen"]),
|
|
4448
4455
|
positions,
|
|
@@ -4488,6 +4495,7 @@ var PLATE_MEDIA_OWNER_PREFIX = "plate-";
|
|
|
4488
4495
|
*/
|
|
4489
4496
|
var SINGLE_INSTANCE_KINDS = new Set([
|
|
4490
4497
|
"keyFrame",
|
|
4498
|
+
"keyFrameSmall",
|
|
4491
4499
|
"thumbnail",
|
|
4492
4500
|
"firstFrame",
|
|
4493
4501
|
"lastFrame"
|
|
@@ -5244,6 +5252,32 @@ var EventStore = class {
|
|
|
5244
5252
|
});
|
|
5245
5253
|
}
|
|
5246
5254
|
/**
|
|
5255
|
+
* Resolve the OWNING track id for an object event id. Powers the event-media
|
|
5256
|
+
* resolver's track fallback: a new-style object event owns no crop, so its
|
|
5257
|
+
* `mediaUrl` degrades to the track's cadence media, addressed by this id.
|
|
5258
|
+
* Returns `null` when the id is not a persisted object event (e.g. a KeyEvent
|
|
5259
|
+
* whose id is already a track id, or a motion/audio event) — best-effort.
|
|
5260
|
+
*/
|
|
5261
|
+
async getTrackIdForEvent(eventId) {
|
|
5262
|
+
try {
|
|
5263
|
+
const row = await this.store.get.query({
|
|
5264
|
+
collection: OBJECT_EVENTS_COLLECTION,
|
|
5265
|
+
key: eventId
|
|
5266
|
+
});
|
|
5267
|
+
if (row !== null && typeof row === "object" && "trackId" in row) {
|
|
5268
|
+
const trackId = row["trackId"];
|
|
5269
|
+
if (typeof trackId === "string" && trackId.length > 0) return trackId;
|
|
5270
|
+
}
|
|
5271
|
+
return null;
|
|
5272
|
+
} catch (err) {
|
|
5273
|
+
this.logger.debug("getTrackIdForEvent failed", { meta: {
|
|
5274
|
+
eventId,
|
|
5275
|
+
error: String(err)
|
|
5276
|
+
} });
|
|
5277
|
+
return null;
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
/**
|
|
5247
5281
|
* Forward-only: set `label` on every already-emitted object event of a track.
|
|
5248
5282
|
* Returns the number of events updated. Best-effort per row. Used when face
|
|
5249
5283
|
* recognition assigns a name to a track after its events were emitted.
|
|
@@ -6150,6 +6184,7 @@ var LinkedCamerasCache = class {
|
|
|
6150
6184
|
for (const cameraId of cameraIds) try {
|
|
6151
6185
|
const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
|
|
6152
6186
|
for (const d of devices) {
|
|
6187
|
+
if (d.producesTrackedEvents === false) continue;
|
|
6153
6188
|
const list = next.get(d.deviceId);
|
|
6154
6189
|
if (list === void 0) next.set(d.deviceId, [cameraId]);
|
|
6155
6190
|
else if (!list.includes(cameraId)) list.push(cameraId);
|
|
@@ -6216,10 +6251,13 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
|
6216
6251
|
*
|
|
6217
6252
|
* A linked sensor/control state change is projected into the UNIFIED track
|
|
6218
6253
|
* store as a SYNTHETIC track: `className` = the event kind (`contact` /
|
|
6219
|
-
* `lock` / `switch` …), `source: 'sensor'`, media = an on-demand
|
|
6254
|
+
* `lock` / `switch` …), `source: 'sensor'`, media = an on-demand best shot of
|
|
6220
6255
|
* the linked camera in the SAME `getTrackMedia` shape the UIs consume (no
|
|
6221
|
-
* bbox → full frame), `
|
|
6222
|
-
*
|
|
6256
|
+
* bbox → full frame), persisted under `kind: 'keyFrameSmall'` — the kind the
|
|
6257
|
+
* viewer representative-image selectors (hero/clean/reel) actually consume; a
|
|
6258
|
+
* `'snapshot'`-kind blob is ignored by those chains, which is what stuck the
|
|
6259
|
+
* pa-synth reel on "loading". `positions: []`. It joins the timeline via the
|
|
6260
|
+
* existing time-based clustering — no new join mechanism.
|
|
6223
6261
|
*
|
|
6224
6262
|
* The raw `SensorEvent` remains the durable record; this track is its
|
|
6225
6263
|
* projection. Snapshots are debounced per `(camera, kind)` so a chattery
|
|
@@ -6227,6 +6265,28 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
|
6227
6265
|
* `source:'sensor'` tracks (they carry no trajectory).
|
|
6228
6266
|
*/
|
|
6229
6267
|
var DEFAULT_DEBOUNCE_MS = 1e4;
|
|
6268
|
+
/** A hanging snapshot cap must not stall the sensor state-change path. */
|
|
6269
|
+
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
6270
|
+
/** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
|
|
6271
|
+
var SnapshotTimeoutError = class extends Error {
|
|
6272
|
+
constructor(ms) {
|
|
6273
|
+
super(`snapshot cap timed out after ${ms}ms`);
|
|
6274
|
+
this.name = "SnapshotTimeoutError";
|
|
6275
|
+
}
|
|
6276
|
+
};
|
|
6277
|
+
/** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
|
|
6278
|
+
function withTimeout$1(promise, ms) {
|
|
6279
|
+
return new Promise((resolve, reject) => {
|
|
6280
|
+
const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
|
|
6281
|
+
promise.then((value) => {
|
|
6282
|
+
clearTimeout(timer);
|
|
6283
|
+
resolve(value);
|
|
6284
|
+
}, (err) => {
|
|
6285
|
+
clearTimeout(timer);
|
|
6286
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
6287
|
+
});
|
|
6288
|
+
});
|
|
6289
|
+
}
|
|
6230
6290
|
/** Full-frame, position-less placeholder (spatial consumers skip these). */
|
|
6231
6291
|
function zeroPosition(timestamp) {
|
|
6232
6292
|
return {
|
|
@@ -6244,6 +6304,7 @@ function zeroPosition(timestamp) {
|
|
|
6244
6304
|
var SyntheticSensorTrackMaterializer = class {
|
|
6245
6305
|
deps;
|
|
6246
6306
|
debounceMs;
|
|
6307
|
+
snapshotTimeoutMs;
|
|
6247
6308
|
makeId;
|
|
6248
6309
|
now;
|
|
6249
6310
|
/** Last snapshot time per `${cameraId}:${kind}`. */
|
|
@@ -6251,10 +6312,29 @@ var SyntheticSensorTrackMaterializer = class {
|
|
|
6251
6312
|
constructor(deps) {
|
|
6252
6313
|
this.deps = deps;
|
|
6253
6314
|
this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
6315
|
+
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
|
|
6254
6316
|
this.makeId = deps.makeId ?? (() => `pa-synth-${(0, node_crypto.randomUUID)()}`);
|
|
6255
6317
|
this.now = deps.now ?? Date.now;
|
|
6256
6318
|
}
|
|
6257
6319
|
/**
|
|
6320
|
+
* Fetch the linked camera's snapshot under a bounded timeout. A hanging
|
|
6321
|
+
* snapshot cap is skipped (returns null + warns) so the track still lands;
|
|
6322
|
+
* a null / failed fetch is likewise reported and skipped.
|
|
6323
|
+
*/
|
|
6324
|
+
async fetchSnapshot(cameraId) {
|
|
6325
|
+
try {
|
|
6326
|
+
const snap = await withTimeout$1(this.deps.snapshot.getSnapshot({
|
|
6327
|
+
deviceId: cameraId,
|
|
6328
|
+
force: true
|
|
6329
|
+
}), this.snapshotTimeoutMs);
|
|
6330
|
+
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
|
|
6331
|
+
return snap;
|
|
6332
|
+
} catch (err) {
|
|
6333
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
|
|
6334
|
+
return null;
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
/**
|
|
6258
6338
|
* Materialize a synthetic track for a sensor/control event on `cameraId`.
|
|
6259
6339
|
* Returns the persisted track, or null when debounced. Snapshot failure
|
|
6260
6340
|
* still lands a track (with no media) — it never blocks the sensor record.
|
|
@@ -6267,28 +6347,23 @@ var SyntheticSensorTrackMaterializer = class {
|
|
|
6267
6347
|
const trackId = this.makeId();
|
|
6268
6348
|
const ts = input.timestamp;
|
|
6269
6349
|
let mediaKey = null;
|
|
6270
|
-
|
|
6271
|
-
|
|
6350
|
+
const snap = await this.fetchSnapshot(input.cameraId);
|
|
6351
|
+
if (snap !== null) try {
|
|
6352
|
+
const raw = Buffer.from(snap.base64, "base64");
|
|
6353
|
+
let data = raw;
|
|
6354
|
+
try {
|
|
6355
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
6356
|
+
} catch (err) {
|
|
6357
|
+
this.deps.onError?.("downscaleSnapshot", err);
|
|
6358
|
+
}
|
|
6359
|
+
mediaKey = await this.deps.media.put({
|
|
6272
6360
|
deviceId: input.cameraId,
|
|
6273
|
-
|
|
6361
|
+
ownerKind: "track",
|
|
6362
|
+
ownerId: trackId,
|
|
6363
|
+
kind: "keyFrameSmall",
|
|
6364
|
+
timestamp: ts,
|
|
6365
|
+
data
|
|
6274
6366
|
});
|
|
6275
|
-
if (snap !== null) {
|
|
6276
|
-
const raw = Buffer.from(snap.base64, "base64");
|
|
6277
|
-
let data = raw;
|
|
6278
|
-
try {
|
|
6279
|
-
data = await downscaleFullFrameJpeg(raw);
|
|
6280
|
-
} catch (err) {
|
|
6281
|
-
this.deps.onError?.("downscaleSnapshot", err);
|
|
6282
|
-
}
|
|
6283
|
-
mediaKey = await this.deps.media.put({
|
|
6284
|
-
deviceId: input.cameraId,
|
|
6285
|
-
ownerKind: "track",
|
|
6286
|
-
ownerId: trackId,
|
|
6287
|
-
kind: "snapshot",
|
|
6288
|
-
timestamp: ts,
|
|
6289
|
-
data
|
|
6290
|
-
});
|
|
6291
|
-
} else this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
|
|
6292
6367
|
} catch (err) {
|
|
6293
6368
|
this.deps.onError?.("snapshotMedia", err);
|
|
6294
6369
|
}
|
|
@@ -6297,6 +6372,7 @@ var SyntheticSensorTrackMaterializer = class {
|
|
|
6297
6372
|
deviceId: input.cameraId,
|
|
6298
6373
|
className: input.kind,
|
|
6299
6374
|
source: "sensor",
|
|
6375
|
+
...input.producingDeviceName !== void 0 ? { producingDeviceName: input.producingDeviceName } : {},
|
|
6300
6376
|
firstSeen: ts,
|
|
6301
6377
|
lastSeen: ts,
|
|
6302
6378
|
positions: [],
|
|
@@ -6329,6 +6405,22 @@ async function resolveFrame(handle, deps) {
|
|
|
6329
6405
|
return deps.getRemoteFrame(handle);
|
|
6330
6406
|
}
|
|
6331
6407
|
//#endregion
|
|
6408
|
+
//#region src/shared/frame/shared-frame-resolver.ts
|
|
6409
|
+
function frameHandleKey(h) {
|
|
6410
|
+
return `${h.nodeId}:${h.shmId}:${h.slot}:${h.seq}`;
|
|
6411
|
+
}
|
|
6412
|
+
function createSharedFrameResolver(resolve) {
|
|
6413
|
+
let cache = null;
|
|
6414
|
+
return (handle) => {
|
|
6415
|
+
const key = frameHandleKey(handle);
|
|
6416
|
+
if (cache === null || cache.key !== key) cache = {
|
|
6417
|
+
key,
|
|
6418
|
+
value: resolve(handle)
|
|
6419
|
+
};
|
|
6420
|
+
return cache.value;
|
|
6421
|
+
};
|
|
6422
|
+
}
|
|
6423
|
+
//#endregion
|
|
6332
6424
|
//#region src/shared/frame/square-safe-crop.ts
|
|
6333
6425
|
/**
|
|
6334
6426
|
* Compute a square-safe 16:9 crop region in pixel space.
|
|
@@ -6457,27 +6549,30 @@ async function drawBoxedFrame(frameData, frameWidth, frameHeight, boxes, opts =
|
|
|
6457
6549
|
}
|
|
6458
6550
|
//#endregion
|
|
6459
6551
|
//#region src/pipeline-analytics/services/event-media-dispatcher.ts
|
|
6460
|
-
/** All event media is stored at NATIVE resolution (no downscale), high quality.
|
|
6461
|
-
* Small downscaled `thumbnail`s are intentionally left out for now — when we
|
|
6462
|
-
* reintroduce them they'll be a separate small kind. */
|
|
6463
|
-
var MEDIA_QUALITY = 88;
|
|
6464
|
-
/** Output dimensions for the LOCAL-FALLBACK square-safe 16:9 crops
|
|
6465
|
-
* (crop/faceCrop/plateCrop/thumbnail) cut from the resolved ≤640 frame. */
|
|
6466
|
-
var CROP_WIDTH = 640;
|
|
6467
|
-
var CROP_HEIGHT = 360;
|
|
6468
|
-
var CROP_QUALITY = 80;
|
|
6469
6552
|
/**
|
|
6470
|
-
*
|
|
6471
|
-
* `
|
|
6472
|
-
*
|
|
6473
|
-
*
|
|
6474
|
-
*
|
|
6475
|
-
* the ≤640 local crop, so quality never regresses below today's behaviour.
|
|
6553
|
+
* Long-side cap (px) for the boxed timeline tiles — `firstFrame`, `snapshot`,
|
|
6554
|
+
* `lastFrame`. These are the low-res "timeline filmstrip" images: a native full
|
|
6555
|
+
* frame downscaled to 960 with ONLY the track's own box burned in. A native miss
|
|
6556
|
+
* falls back to the ≤640 detection frame (an honest lower-res frame, NEVER an
|
|
6557
|
+
* upscale).
|
|
6476
6558
|
*/
|
|
6477
|
-
var
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6559
|
+
var SNAPSHOT_MAX_WIDTH = 960;
|
|
6560
|
+
/**
|
|
6561
|
+
* Map a detection-frame pixel box (`fromW`×`fromH`, the ≤640 raster the tracker
|
|
6562
|
+
* ran on) onto the native full frame (`toW`×`toH`, the 960-downscaled native
|
|
6563
|
+
* surface). Both frames show the SAME scene at the same aspect ratio, so a
|
|
6564
|
+
* uniform per-axis scale places the box correctly on the higher-res frame.
|
|
6565
|
+
*/
|
|
6566
|
+
function scaleBox(bbox, fromW, fromH, toW, toH, label) {
|
|
6567
|
+
const sx = toW / fromW;
|
|
6568
|
+
const sy = toH / fromH;
|
|
6569
|
+
return {
|
|
6570
|
+
x: bbox.x * sx,
|
|
6571
|
+
y: bbox.y * sy,
|
|
6572
|
+
w: bbox.w * sx,
|
|
6573
|
+
h: bbox.h * sy,
|
|
6574
|
+
...label ? { label } : {}
|
|
6575
|
+
};
|
|
6481
6576
|
}
|
|
6482
6577
|
/**
|
|
6483
6578
|
* True when a packed-RGB frame is (near-)uniform in every channel — the
|
|
@@ -6509,20 +6604,41 @@ function isUniformRgbFrame(data, width, height) {
|
|
|
6509
6604
|
}
|
|
6510
6605
|
/**
|
|
6511
6606
|
* Generates object-event + track media FROM the decoded detection-pipeline
|
|
6512
|
-
* frame (the `frameHandle`/
|
|
6513
|
-
* snapshot cap.
|
|
6514
|
-
*
|
|
6515
|
-
*
|
|
6516
|
-
*
|
|
6517
|
-
*
|
|
6518
|
-
*
|
|
6519
|
-
*
|
|
6520
|
-
*
|
|
6607
|
+
* frame (the `frameHandle`/native surface the detector ran on), NEVER the device
|
|
6608
|
+
* snapshot cap.
|
|
6609
|
+
*
|
|
6610
|
+
* Per object event it writes ONLY the enrichment child crops
|
|
6611
|
+
* (`faceCrop`/`plateCrop`) — the retired `crop`/`fullFrame`/`fullFrameBoxed`
|
|
6612
|
+
* kinds are no longer produced (2026-07-21; old stored tracks keep reading them,
|
|
6613
|
+
* the enum is unchanged). The representative event image is now the track
|
|
6614
|
+
* `thumbnail`/`keyFrame`.
|
|
6615
|
+
*
|
|
6616
|
+
* Per track it writes the boxed timeline tiles — a `firstFrame` on track start,
|
|
6617
|
+
* plus periodic `snapshot`/`lastFrame` — each cut from the runner's NATIVE full
|
|
6618
|
+
* frame downscaled to {@link SNAPSHOT_MAX_WIDTH} (960) with ONLY the track's own
|
|
6619
|
+
* box burned in, falling back to the ≤640 detection frame (honest lower res,
|
|
6620
|
+
* never an upscale) on a native miss. The best `thumbnail` stays a clean,
|
|
6621
|
+
* uncapped native subject crop (native-or-nothing).
|
|
6622
|
+
*
|
|
6623
|
+
* The detection frame is resolved ONCE per call (blank-frame guard); native full
|
|
6624
|
+
* frames + subject crops are fetched per target by handle. Frames recycle in
|
|
6625
|
+
* milliseconds, so the caller must invoke this immediately after producing the
|
|
6626
|
+
* events while the handle is still live.
|
|
6521
6627
|
*/
|
|
6522
6628
|
var EventMediaDispatcher = class {
|
|
6523
6629
|
deps;
|
|
6630
|
+
/**
|
|
6631
|
+
* Per-frame single-slot memo of the native 960 full frame (S3): every track
|
|
6632
|
+
* tile in ONE `captureForFrame` pass shares the SAME `frameHandle`, so multiple
|
|
6633
|
+
* `boxedTimelineFrame` calls would otherwise each round-trip the runner for the
|
|
6634
|
+
* identical native full frame. Keyed by frame identity (`createSharedFrameResolver`)
|
|
6635
|
+
* so a recycled slot (new `seq`) is a fresh fetch. `maxWidth` is always
|
|
6636
|
+
* {@link SNAPSHOT_MAX_WIDTH} here (the only dispatcher use of the fetch).
|
|
6637
|
+
*/
|
|
6638
|
+
sharedNativeFullFrame;
|
|
6524
6639
|
constructor(deps) {
|
|
6525
6640
|
this.deps = deps;
|
|
6641
|
+
this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
|
|
6526
6642
|
}
|
|
6527
6643
|
async captureForFrame(input) {
|
|
6528
6644
|
const { deviceId, frameHandle, events, trackFrames } = input;
|
|
@@ -6581,8 +6697,8 @@ var EventMediaDispatcher = class {
|
|
|
6581
6697
|
});
|
|
6582
6698
|
return empty;
|
|
6583
6699
|
}
|
|
6584
|
-
for (const ev of events) await this.writeEventMedia(deviceId, frameHandle,
|
|
6585
|
-
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
|
|
6700
|
+
for (const ev of events) await this.writeEventMedia(deviceId, frameHandle, fw, fh, ev, input.cropPadding);
|
|
6701
|
+
for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf);
|
|
6586
6702
|
const storedSnapshots = [];
|
|
6587
6703
|
const thumbnailTrackIds = [];
|
|
6588
6704
|
for (const sn of snapshots) {
|
|
@@ -6613,21 +6729,7 @@ var EventMediaDispatcher = class {
|
|
|
6613
6729
|
thumbnailWritten: false
|
|
6614
6730
|
};
|
|
6615
6731
|
let boxed = null;
|
|
6616
|
-
if (sn.appendSnapshot || sn.rollingLastFrame)
|
|
6617
|
-
boxed = await drawBoxedFrame(frameData, fw, fh, [{
|
|
6618
|
-
...sn.bbox,
|
|
6619
|
-
...sn.label ? { label: sn.label } : {}
|
|
6620
|
-
}], { quality: MEDIA_QUALITY });
|
|
6621
|
-
} catch (err) {
|
|
6622
|
-
this.deps.logger.warn("event media: track snapshot encode failed", {
|
|
6623
|
-
tags: { deviceId },
|
|
6624
|
-
meta: {
|
|
6625
|
-
deviceId,
|
|
6626
|
-
trackId: sn.trackId,
|
|
6627
|
-
error: err instanceof Error ? err.message : String(err)
|
|
6628
|
-
}
|
|
6629
|
-
});
|
|
6630
|
-
}
|
|
6732
|
+
if (sn.appendSnapshot || sn.rollingLastFrame) boxed = await this.boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, sn.trackId, sn.bbox, sn.label);
|
|
6631
6733
|
let stored = null;
|
|
6632
6734
|
if (sn.appendSnapshot && boxed) try {
|
|
6633
6735
|
const mediaKey = await this.deps.mediaStore.put({
|
|
@@ -6647,19 +6749,9 @@ var EventMediaDispatcher = class {
|
|
|
6647
6749
|
} catch {}
|
|
6648
6750
|
if (sn.rollingLastFrame && boxed) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
6649
6751
|
let thumbnailWritten = false;
|
|
6650
|
-
if (sn.bestThumbnail)
|
|
6651
|
-
const crop = await this.cropSubjectRegion(frameHandle,
|
|
6652
|
-
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6653
|
-
} catch (err) {
|
|
6654
|
-
this.deps.logger.warn("event media: track thumbnail crop failed", {
|
|
6655
|
-
tags: { deviceId },
|
|
6656
|
-
meta: {
|
|
6657
|
-
deviceId,
|
|
6658
|
-
trackId: sn.trackId,
|
|
6659
|
-
error: err instanceof Error ? err.message : String(err)
|
|
6660
|
-
}
|
|
6661
|
-
});
|
|
6662
|
-
if (boxed) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
|
|
6752
|
+
if (sn.bestThumbnail) {
|
|
6753
|
+
const crop = await this.cropSubjectRegion(frameHandle, fw, fh, sn.bbox, cropPadding);
|
|
6754
|
+
if (crop) thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, crop);
|
|
6663
6755
|
}
|
|
6664
6756
|
return {
|
|
6665
6757
|
storedSnapshot: stored,
|
|
@@ -6671,143 +6763,126 @@ var EventMediaDispatcher = class {
|
|
|
6671
6763
|
* object-event `crop` kind, the child `faceCrop`/`plateCrop`, and the track
|
|
6672
6764
|
* `thumbnail`: the square-safe 16:9 region around the bbox with NO box drawn.
|
|
6673
6765
|
*
|
|
6674
|
-
* NATIVE-
|
|
6675
|
-
* surface (normalized [0,1] coords map directly onto it)
|
|
6676
|
-
*
|
|
6677
|
-
*
|
|
6678
|
-
*
|
|
6679
|
-
*
|
|
6766
|
+
* NATIVE-OR-NOTHING: the region is requested from the runner's retained native
|
|
6767
|
+
* surface (normalized [0,1] coords map directly onto it) with NO `maxWidth` —
|
|
6768
|
+
* the crop at TRUE native resolution (decision #3). On any miss/error (or a
|
|
6769
|
+
* runner without the method) it returns `null` after a loud `logger.warn`; the
|
|
6770
|
+
* caller SKIPS the write and the per-frame retry lands a real native crop
|
|
6771
|
+
* later. It is NEVER upscaled — an upscaled ≤640 tile is a blurred lie
|
|
6772
|
+
* (case-study lapVar 5–7), so no local resize fallback exists. Runs inside the
|
|
6773
|
+
* live-handle window opened by `captureForFrame`.
|
|
6680
6774
|
*/
|
|
6681
|
-
async cropSubjectRegion(frameHandle,
|
|
6682
|
-
if (this.deps.getNativeCropJpeg)
|
|
6775
|
+
async cropSubjectRegion(frameHandle, fw, fh, bbox, cropPadding) {
|
|
6776
|
+
if (!this.deps.getNativeCropJpeg) {
|
|
6777
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
|
|
6778
|
+
shmId: frameHandle.shmId,
|
|
6779
|
+
reason: "no-native-cap"
|
|
6780
|
+
} });
|
|
6781
|
+
return null;
|
|
6782
|
+
}
|
|
6783
|
+
try {
|
|
6683
6784
|
const norm = squareSafeCropRegionNormalized(bbox, {
|
|
6684
6785
|
W: fw,
|
|
6685
6786
|
H: fh
|
|
6686
6787
|
}, cropPadding);
|
|
6687
|
-
const native = await this.deps.getNativeCropJpeg(frameHandle, norm
|
|
6788
|
+
const native = await this.deps.getNativeCropJpeg(frameHandle, norm);
|
|
6688
6789
|
if (native) return native;
|
|
6790
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
|
|
6791
|
+
shmId: frameHandle.shmId,
|
|
6792
|
+
reason: "native-miss"
|
|
6793
|
+
} });
|
|
6794
|
+
return null;
|
|
6689
6795
|
} catch (err) {
|
|
6690
|
-
this.deps.logger.
|
|
6796
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", { meta: {
|
|
6691
6797
|
shmId: frameHandle.shmId,
|
|
6692
6798
|
error: err instanceof Error ? err.message : String(err)
|
|
6693
6799
|
} });
|
|
6800
|
+
return null;
|
|
6694
6801
|
}
|
|
6695
|
-
return this.cropSubjectRegionLocal(frameData, fw, fh, bbox, cropPadding);
|
|
6696
6802
|
}
|
|
6697
6803
|
/**
|
|
6698
|
-
*
|
|
6699
|
-
*
|
|
6804
|
+
* The boxed timeline tile (`firstFrame`/`snapshot`/`lastFrame`): the track's
|
|
6805
|
+
* OWN box burned onto the NATIVE full frame downscaled to
|
|
6806
|
+
* {@link SNAPSHOT_MAX_WIDTH}. On a native miss it falls back to the ≤640
|
|
6807
|
+
* detection frame (`frameData`) downscaled to the same cap with a `logger.warn`
|
|
6808
|
+
* — an honest LOWER-res frame, NEVER an upscale. Returns the JPEG, or `null`
|
|
6809
|
+
* only if the sharp encode itself throws.
|
|
6700
6810
|
*/
|
|
6701
|
-
async
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
H: fh
|
|
6705
|
-
}, cropPadding);
|
|
6706
|
-
const left = Math.max(0, Math.min(region.x, fw - 1));
|
|
6707
|
-
const top = Math.max(0, Math.min(region.y, fh - 1));
|
|
6708
|
-
const width = Math.max(1, Math.min(region.w, fw - left));
|
|
6709
|
-
const height = Math.max(1, Math.min(region.h, fh - top));
|
|
6710
|
-
return await (0, sharp.default)(frameData, { raw: {
|
|
6711
|
-
width: fw,
|
|
6712
|
-
height: fh,
|
|
6713
|
-
channels: 3
|
|
6714
|
-
} }).extract({
|
|
6715
|
-
left,
|
|
6716
|
-
top,
|
|
6717
|
-
width,
|
|
6718
|
-
height
|
|
6719
|
-
}).resize(CROP_WIDTH, CROP_HEIGHT).jpeg({ quality: CROP_QUALITY }).toBuffer();
|
|
6720
|
-
}
|
|
6721
|
-
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
6811
|
+
async boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, ownerId, bbox, label) {
|
|
6812
|
+
let nf = null;
|
|
6813
|
+
let fetchThrew = false;
|
|
6722
6814
|
try {
|
|
6723
|
-
await this.
|
|
6724
|
-
deviceId,
|
|
6725
|
-
ownerKind: "track",
|
|
6726
|
-
ownerId: trackId,
|
|
6727
|
-
kind,
|
|
6728
|
-
timestamp,
|
|
6729
|
-
data
|
|
6730
|
-
});
|
|
6731
|
-
return true;
|
|
6815
|
+
nf = await this.sharedNativeFullFrame(frameHandle);
|
|
6732
6816
|
} catch (err) {
|
|
6733
|
-
|
|
6817
|
+
fetchThrew = true;
|
|
6818
|
+
this.deps.logger.warn("native full frame fetch threw — 640 boxed fallback", {
|
|
6734
6819
|
tags: { deviceId },
|
|
6735
6820
|
meta: {
|
|
6736
6821
|
deviceId,
|
|
6737
|
-
|
|
6822
|
+
ownerId,
|
|
6738
6823
|
error: err instanceof Error ? err.message : String(err)
|
|
6739
6824
|
}
|
|
6740
6825
|
});
|
|
6741
|
-
return false;
|
|
6742
6826
|
}
|
|
6743
|
-
}
|
|
6744
|
-
async writeEventMedia(deviceId, frameHandle, frameData, fw, fh, ev, cropPadding) {
|
|
6745
|
-
const box = {
|
|
6746
|
-
...ev.bbox,
|
|
6747
|
-
label: caption(ev.className, ev.confidence, ev.label)
|
|
6748
|
-
};
|
|
6749
6827
|
try {
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
kind: "crop",
|
|
6756
|
-
timestamp: ev.timestamp,
|
|
6757
|
-
data: crop
|
|
6758
|
-
});
|
|
6759
|
-
} catch (err) {
|
|
6760
|
-
this.deps.logger.warn("event media: crop failed", {
|
|
6828
|
+
if (nf && nf.format === "rgb" && nf.width > 0 && nf.height > 0) {
|
|
6829
|
+
const box = scaleBox(bbox, fw, fh, nf.width, nf.height, label);
|
|
6830
|
+
return await drawBoxedFrame(nf.data, nf.width, nf.height, [box], { quality: 88 });
|
|
6831
|
+
}
|
|
6832
|
+
if (!fetchThrew) this.deps.logger.warn("native full frame miss — 640 boxed fallback (no upscale)", {
|
|
6761
6833
|
tags: { deviceId },
|
|
6762
6834
|
meta: {
|
|
6763
6835
|
deviceId,
|
|
6764
|
-
|
|
6765
|
-
|
|
6836
|
+
ownerId,
|
|
6837
|
+
shmId: frameHandle.shmId
|
|
6766
6838
|
}
|
|
6767
6839
|
});
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
ownerId: ev.eventId,
|
|
6775
|
-
kind: "fullFrame",
|
|
6776
|
-
timestamp: ev.timestamp,
|
|
6777
|
-
data: fullFrame
|
|
6840
|
+
return await drawBoxedFrame(frameData, fw, fh, [{
|
|
6841
|
+
...bbox,
|
|
6842
|
+
...label ? { label } : {}
|
|
6843
|
+
}], {
|
|
6844
|
+
quality: 88,
|
|
6845
|
+
maxWidth: SNAPSHOT_MAX_WIDTH
|
|
6778
6846
|
});
|
|
6779
6847
|
} catch (err) {
|
|
6780
|
-
this.deps.logger.warn("event media:
|
|
6848
|
+
this.deps.logger.warn("event media: timeline frame encode failed", {
|
|
6781
6849
|
tags: { deviceId },
|
|
6782
6850
|
meta: {
|
|
6783
6851
|
deviceId,
|
|
6784
|
-
|
|
6852
|
+
ownerId,
|
|
6785
6853
|
error: err instanceof Error ? err.message : String(err)
|
|
6786
6854
|
}
|
|
6787
6855
|
});
|
|
6856
|
+
return null;
|
|
6788
6857
|
}
|
|
6858
|
+
}
|
|
6859
|
+
async replaceKind(deviceId, trackId, kind, timestamp, data) {
|
|
6789
6860
|
try {
|
|
6790
|
-
|
|
6791
|
-
await this.deps.mediaStore.put({
|
|
6861
|
+
await this.deps.mediaStore.putReplacing({
|
|
6792
6862
|
deviceId,
|
|
6793
|
-
ownerKind: "
|
|
6794
|
-
ownerId:
|
|
6795
|
-
kind
|
|
6796
|
-
timestamp
|
|
6797
|
-
data
|
|
6863
|
+
ownerKind: "track",
|
|
6864
|
+
ownerId: trackId,
|
|
6865
|
+
kind,
|
|
6866
|
+
timestamp,
|
|
6867
|
+
data
|
|
6798
6868
|
});
|
|
6869
|
+
return true;
|
|
6799
6870
|
} catch (err) {
|
|
6800
|
-
this.deps.logger.
|
|
6871
|
+
this.deps.logger.debug(`event media: ${kind} replace failed`, {
|
|
6801
6872
|
tags: { deviceId },
|
|
6802
6873
|
meta: {
|
|
6803
6874
|
deviceId,
|
|
6804
|
-
|
|
6875
|
+
trackId,
|
|
6805
6876
|
error: err instanceof Error ? err.message : String(err)
|
|
6806
6877
|
}
|
|
6807
6878
|
});
|
|
6879
|
+
return false;
|
|
6808
6880
|
}
|
|
6881
|
+
}
|
|
6882
|
+
async writeEventMedia(deviceId, frameHandle, fw, fh, ev, cropPadding) {
|
|
6809
6883
|
if (ev.childCrops) for (const child of ev.childCrops) try {
|
|
6810
|
-
const childCropData = await this.cropSubjectRegion(frameHandle,
|
|
6884
|
+
const childCropData = await this.cropSubjectRegion(frameHandle, fw, fh, child.bbox, cropPadding);
|
|
6885
|
+
if (!childCropData) continue;
|
|
6811
6886
|
await this.deps.mediaStore.put({
|
|
6812
6887
|
deviceId,
|
|
6813
6888
|
ownerKind: "event",
|
|
@@ -6827,13 +6902,10 @@ var EventMediaDispatcher = class {
|
|
|
6827
6902
|
});
|
|
6828
6903
|
}
|
|
6829
6904
|
}
|
|
6830
|
-
async writeTrackFrame(deviceId, frameData, fw, fh, tf) {
|
|
6831
|
-
const
|
|
6832
|
-
|
|
6833
|
-
...tf.label ? { label: tf.label } : {}
|
|
6834
|
-
};
|
|
6905
|
+
async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
|
|
6906
|
+
const boxed = await this.boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, tf.trackId, tf.bbox, tf.label);
|
|
6907
|
+
if (!boxed) return;
|
|
6835
6908
|
try {
|
|
6836
|
-
const boxed = await drawBoxedFrame(frameData, fw, fh, [box], { quality: MEDIA_QUALITY });
|
|
6837
6909
|
await this.deps.mediaStore.put({
|
|
6838
6910
|
deviceId,
|
|
6839
6911
|
ownerKind: "track",
|
|
@@ -8709,34 +8781,13 @@ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight) {
|
|
|
8709
8781
|
}
|
|
8710
8782
|
//#endregion
|
|
8711
8783
|
//#region src/pipeline-analytics/pipeline/key-frame-capture.ts
|
|
8712
|
-
/**
|
|
8713
|
-
* Pure helpers for the per-track NATIVE-resolution `keyFrame` capture
|
|
8714
|
-
* (Design B — one native full-frame per track at its best-detection moment).
|
|
8715
|
-
*
|
|
8716
|
-
* ## Why this exists (the missing native keyFrame)
|
|
8717
|
-
*
|
|
8718
|
-
* `keyFrame` was historically captured ONLY inside the CLIP object-embedding
|
|
8719
|
-
* best path (`persistObjectEmbeddingBests`, gated by `isClipObjectEmbedding`).
|
|
8720
|
-
* Under the two-plane pipeline the root frame carries NO CLIP embedding (clip is
|
|
8721
|
-
* a per-track DETAIL served via `runDetailSubtree`, and is disabled cluster-
|
|
8722
|
-
* wide), so that gate was never satisfied and the native `keyFrame` was NEVER
|
|
8723
|
-
* produced — every stored frame stayed at the ≤640×360 detection resolution.
|
|
8724
|
-
*
|
|
8725
|
-
* The fix decouples the `keyFrame` from the clip path: it is captured on the
|
|
8726
|
-
* GENERAL best-frame signal (the same `bestThumbnail` decision that drives the
|
|
8727
|
-
* `thumbnail`), reusing the WORKING native crop path (`captureCrop` →
|
|
8728
|
-
* `pipelineRunner.getNativeCrop`, which cuts the ROI from the decode worker's
|
|
8729
|
-
* retained NATIVE surface and only falls back to the detection frame on a miss).
|
|
8730
|
-
* A full-frame ROI at {@link KEYFRAME_NATIVE_MAX_WIDTH} therefore yields a frame
|
|
8731
|
-
* LARGER than the detection raster (up to the cap), which is the whole point of
|
|
8732
|
-
* the `keyFrame` kind.
|
|
8733
|
-
*/
|
|
8734
8784
|
/** Cap (px) on the width of the native KEY FRAME (full-frame native capture).
|
|
8735
|
-
* Native resolution is the point
|
|
8736
|
-
*
|
|
8737
|
-
*
|
|
8738
|
-
*
|
|
8739
|
-
|
|
8785
|
+
* Native resolution is the point: a 4K cam (3840) resolves TRUE native (no
|
|
8786
|
+
* resize, 3840 ≤ 4096); an 8K cam is bounded to 4096 (still far past the retired
|
|
8787
|
+
* 1920 cap). This honours "native, no 1920 intermediate" while keeping the raw
|
|
8788
|
+
* rgb transfer wire-safe. Env-overridable (`CAMSTACK_KEYFRAME_NATIVE_MAX_WIDTH`)
|
|
8789
|
+
* for a homelab that wants a tighter/looser bound. */
|
|
8790
|
+
var KEYFRAME_NATIVE_MAX_WIDTH = Number.parseInt(process.env.CAMSTACK_KEYFRAME_NATIVE_MAX_WIDTH ?? "", 10) || 4096;
|
|
8740
8791
|
/**
|
|
8741
8792
|
* The tracks whose native `keyFrame` should be (re)captured THIS frame: exactly
|
|
8742
8793
|
* the tracks that hit a GENUINE new best-frame moment (`keyFrame`), NOT the
|
|
@@ -8747,31 +8798,39 @@ function selectKeyFrameTrackIds(targets) {
|
|
|
8747
8798
|
return targets.filter((t) => t.keyFrame).map((t) => t.trackId);
|
|
8748
8799
|
}
|
|
8749
8800
|
/**
|
|
8750
|
-
*
|
|
8751
|
-
*
|
|
8752
|
-
*
|
|
8753
|
-
* a
|
|
8801
|
+
* Gate for the clean best-shot native `keyFrame` (design 2026-07-21): the keyFrame
|
|
8802
|
+
* is the track's CLEAN native best frame and MUST NEVER be persisted from the
|
|
8803
|
+
* ≤640 RAM fallback (`tier: 'ram-fullframe'`) — that would pin the stored keyFrame
|
|
8804
|
+
* at detection resolution. On a `ram-fullframe` tier the caller drops the write
|
|
8805
|
+
* and the per-frame re-fire retries a real native surface later.
|
|
8806
|
+
*
|
|
8807
|
+
* An ABSENT tier (`undefined`, from a pre-tier runner on a mixed-version cluster)
|
|
8808
|
+
* is ACCEPTED exactly as before the tier field existed — back-compat, never break
|
|
8809
|
+
* mixed-version clusters. Only an explicit `ram-fullframe` is rejected.
|
|
8810
|
+
*
|
|
8811
|
+
* NOTE: this gates the native `keyFrame` ONLY. The 960 boxed TIMELINE tiles keep
|
|
8812
|
+
* accepting the ≤640 fallback (an honest low-res timeline is acceptable there).
|
|
8754
8813
|
*/
|
|
8755
|
-
function
|
|
8756
|
-
return
|
|
8757
|
-
bbox: {
|
|
8758
|
-
x: 0,
|
|
8759
|
-
y: 0,
|
|
8760
|
-
w: frameWidth,
|
|
8761
|
-
h: frameHeight
|
|
8762
|
-
},
|
|
8763
|
-
padding: 0,
|
|
8764
|
-
maxWidth: KEYFRAME_NATIVE_MAX_WIDTH
|
|
8765
|
-
};
|
|
8814
|
+
function keyFrameAcceptsTier(tier) {
|
|
8815
|
+
return tier !== "ram-fullframe";
|
|
8766
8816
|
}
|
|
8767
8817
|
/**
|
|
8768
|
-
*
|
|
8769
|
-
*
|
|
8770
|
-
*
|
|
8771
|
-
*
|
|
8818
|
+
* Encode BOTH key-frame variants from a single decoded native full frame. No box
|
|
8819
|
+
* is drawn (the `keyFrame` is the clean best full frame); `drawBoxedFrame([])`
|
|
8820
|
+
* is used purely as the raw-rgb → JPEG (+ optional downscale) encoder so the two
|
|
8821
|
+
* variants share one code path. The caller has ALREADY fetched the native frame
|
|
8822
|
+
* at {@link KEYFRAME_NATIVE_MAX_WIDTH}, so `keyFrame` re-encodes it verbatim
|
|
8823
|
+
* (no resize) and `keyFrameSmall` downscales the same buffer to ≤960 —
|
|
8824
|
+
* `withoutEnlargement` never upscales a sub-960 native frame.
|
|
8772
8825
|
*/
|
|
8773
|
-
function
|
|
8774
|
-
return
|
|
8826
|
+
async function encodeKeyFrameVariants(native) {
|
|
8827
|
+
return {
|
|
8828
|
+
keyFrame: await drawBoxedFrame(native.data, native.width, native.height, [], { quality: 88 }),
|
|
8829
|
+
keyFrameSmall: await drawBoxedFrame(native.data, native.width, native.height, [], {
|
|
8830
|
+
quality: 88,
|
|
8831
|
+
maxWidth: 960
|
|
8832
|
+
})
|
|
8833
|
+
};
|
|
8775
8834
|
}
|
|
8776
8835
|
//#endregion
|
|
8777
8836
|
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
@@ -11534,22 +11593,6 @@ var PlateRecognizer = class {
|
|
|
11534
11593
|
}
|
|
11535
11594
|
};
|
|
11536
11595
|
//#endregion
|
|
11537
|
-
//#region src/shared/frame/shared-frame-resolver.ts
|
|
11538
|
-
function frameHandleKey(h) {
|
|
11539
|
-
return `${h.nodeId}:${h.shmId}:${h.slot}:${h.seq}`;
|
|
11540
|
-
}
|
|
11541
|
-
function createSharedFrameResolver(resolve) {
|
|
11542
|
-
let cache = null;
|
|
11543
|
-
return (handle) => {
|
|
11544
|
-
const key = frameHandleKey(handle);
|
|
11545
|
-
if (cache === null || cache.key !== key) cache = {
|
|
11546
|
-
key,
|
|
11547
|
-
value: resolve(handle)
|
|
11548
|
-
};
|
|
11549
|
-
return cache.value;
|
|
11550
|
-
};
|
|
11551
|
-
}
|
|
11552
|
-
//#endregion
|
|
11553
11596
|
//#region src/shared/frame/encode-crop.ts
|
|
11554
11597
|
/**
|
|
11555
11598
|
* JPEG-encode an already-cropped raw RGB (24-bit) buffer. Used for the
|
|
@@ -11725,6 +11768,26 @@ function padBbox(bbox, padding) {
|
|
|
11725
11768
|
};
|
|
11726
11769
|
}
|
|
11727
11770
|
//#endregion
|
|
11771
|
+
//#region src/pipeline-analytics/pipeline/capture-crop.ts
|
|
11772
|
+
function createCaptureCrop(deps) {
|
|
11773
|
+
return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth) => {
|
|
11774
|
+
const paddedNorm = padBbox({
|
|
11775
|
+
x: bbox.x / frameWidth,
|
|
11776
|
+
y: bbox.y / frameHeight,
|
|
11777
|
+
w: bbox.w / frameWidth,
|
|
11778
|
+
h: bbox.h / frameHeight
|
|
11779
|
+
}, padding);
|
|
11780
|
+
const nativeCrop = await deps.tryNativeCrop(frameHandle, paddedNorm, maxWidth);
|
|
11781
|
+
if (nativeCrop) {
|
|
11782
|
+
deps.bumpCropMetric(true);
|
|
11783
|
+
return nativeCrop;
|
|
11784
|
+
}
|
|
11785
|
+
deps.bumpCropMetric(false);
|
|
11786
|
+
deps.logger.debug("enrichment crop native miss — detail scheduler will re-run", { meta: { nodeId: frameHandle.nodeId } });
|
|
11787
|
+
return null;
|
|
11788
|
+
};
|
|
11789
|
+
}
|
|
11790
|
+
//#endregion
|
|
11728
11791
|
//#region src/pipeline-analytics/pipeline/dropout-skip.ts
|
|
11729
11792
|
function shouldSkipDropoutFrame(activeTrackCount, detectionCount, consecutiveSkips, cfg) {
|
|
11730
11793
|
if (!cfg.enabled) return false;
|
|
@@ -11910,6 +11973,32 @@ async function makeSquareThumb(bytes, size) {
|
|
|
11910
11973
|
}).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
11911
11974
|
}
|
|
11912
11975
|
//#endregion
|
|
11976
|
+
//#region src/pipeline-analytics/event-thumbnail-resolver.ts
|
|
11977
|
+
/** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
|
|
11978
|
+
* `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
|
|
11979
|
+
* for new tracks (served only for old stored rows). Undefined when the event
|
|
11980
|
+
* owns no media. */
|
|
11981
|
+
function pickEventOwnedMedia(files) {
|
|
11982
|
+
return files.find((f) => f.kind === "crop") ?? files.find((f) => f.kind === "fullFrameBoxed") ?? files[0];
|
|
11983
|
+
}
|
|
11984
|
+
/** Pick a track's fallback media in the shared cadence-preference order the
|
|
11985
|
+
* KeyEvent path uses: best `thumbnail` → rolling `lastFrame` → `firstFrame` →
|
|
11986
|
+
* newest `snapshot` → any track blob. Undefined when the track owns no media. */
|
|
11987
|
+
function pickTrackFallbackMedia(files) {
|
|
11988
|
+
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];
|
|
11989
|
+
}
|
|
11990
|
+
/**
|
|
11991
|
+
* Resolve the default-path media for an event id: the event's own media when it
|
|
11992
|
+
* has any, else the owning track's fallback media. Returns `null` when neither
|
|
11993
|
+
* yields a blob (caller 404s → the surface shows an icon).
|
|
11994
|
+
*/
|
|
11995
|
+
async function resolveDefaultEventMedia(deps) {
|
|
11996
|
+
const owned = pickEventOwnedMedia(deps.eventFiles);
|
|
11997
|
+
if (owned) return owned;
|
|
11998
|
+
const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
|
|
11999
|
+
return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
|
|
12000
|
+
}
|
|
12001
|
+
//#endregion
|
|
11913
12002
|
//#region src/pipeline-analytics/index.ts
|
|
11914
12003
|
/**
|
|
11915
12004
|
* Pipeline Analytics addon — subscribes to `PipelineInferenceResult` +
|
|
@@ -11984,12 +12073,20 @@ var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
|
11984
12073
|
* without it a crop-forced request degraded to the `keyFrame` FULL FRAME,
|
|
11985
12074
|
* which is how 65% of the timeline tiles rendered as whole scenes. It sits
|
|
11986
12075
|
* right after a real `crop`, before the full-scene `fullFrame`/`keyFrame`.
|
|
12076
|
+
*
|
|
12077
|
+
* `keyFrameSmall` (the 960px no-box downscale of the best-shot `keyFrame`)
|
|
12078
|
+
* joined the clean set on 2026-07-21 so a crop-forced request can degrade to it
|
|
12079
|
+
* for tracks whose only clean media is the small key frame; it sits last
|
|
12080
|
+
* (fullest-scene, but subject-centered enough to serve as a reasonable fallback
|
|
12081
|
+
* over 404). The retired-but-still-stored kinds (`crop`) stay listed so old rows
|
|
12082
|
+
* keep resolving.
|
|
11987
12083
|
*/
|
|
11988
12084
|
var CLEAN_MEDIA_KINDS = [
|
|
11989
12085
|
"crop",
|
|
11990
12086
|
"thumbnail",
|
|
11991
12087
|
"fullFrame",
|
|
11992
|
-
"keyFrame"
|
|
12088
|
+
"keyFrame",
|
|
12089
|
+
"keyFrameSmall"
|
|
11993
12090
|
];
|
|
11994
12091
|
/**
|
|
11995
12092
|
* Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
|
|
@@ -12252,6 +12349,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12252
12349
|
* optional `maxWidth` caps the native crop width (used for the full-frame key
|
|
12253
12350
|
* frame so a 4K native surface never floods the transport). */
|
|
12254
12351
|
captureCrop = null;
|
|
12352
|
+
/** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
|
|
12353
|
+
* to the owning runner, PLUS the source `tier`. Captured in the async init
|
|
12354
|
+
* block so `persistKeyFrames` can fetch the native full frame for the
|
|
12355
|
+
* `keyFrame` + `keyFrameSmall` writes in the same live-frame window AND reject
|
|
12356
|
+
* a ≤640 RAM fallback (`tier: 'ram-fullframe'`). Null until init completes. */
|
|
12357
|
+
getNativeKeyFrameRgb = null;
|
|
12255
12358
|
shuttingDown = false;
|
|
12256
12359
|
/** True only on the cluster's designated post-processing node. When false the
|
|
12257
12360
|
* addon subscribes to NOTHING — fully inert (no event/media generation). */
|
|
@@ -12298,7 +12401,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12298
12401
|
let storage = this.ctx.kernel.storage;
|
|
12299
12402
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
12300
12403
|
if (mediaRoot) {
|
|
12301
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node
|
|
12404
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node--uX7wh6R.js"));
|
|
12302
12405
|
storage = new FilesystemStorageProvider(mediaRoot);
|
|
12303
12406
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
12304
12407
|
}
|
|
@@ -12466,6 +12569,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12466
12569
|
timestamp: 0
|
|
12467
12570
|
};
|
|
12468
12571
|
};
|
|
12572
|
+
const fetchNativeFullFrameTiered = async (handle, maxWidth) => {
|
|
12573
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
12574
|
+
const full = await pipelineRunnerApi.getNativeCrop.query({
|
|
12575
|
+
handle,
|
|
12576
|
+
bbox: {
|
|
12577
|
+
x: 0,
|
|
12578
|
+
y: 0,
|
|
12579
|
+
w: 1,
|
|
12580
|
+
h: 1
|
|
12581
|
+
},
|
|
12582
|
+
maxWidth
|
|
12583
|
+
}, require_dist.nodePin(handle.nodeId));
|
|
12584
|
+
if (!full || full.width <= 0 || full.height <= 0) return null;
|
|
12585
|
+
const tier = full.tier === "ram-fullframe" ? "ram-fullframe" : "native";
|
|
12586
|
+
return {
|
|
12587
|
+
frame: {
|
|
12588
|
+
data: Buffer.from(full.bytes),
|
|
12589
|
+
width: full.width,
|
|
12590
|
+
height: full.height,
|
|
12591
|
+
format: "rgb",
|
|
12592
|
+
timestamp: 0
|
|
12593
|
+
},
|
|
12594
|
+
tier
|
|
12595
|
+
};
|
|
12596
|
+
};
|
|
12597
|
+
const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
|
|
12598
|
+
this.getNativeKeyFrameRgb = fetchNativeFullFrameTiered;
|
|
12469
12599
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
12470
12600
|
let nativeHits = 0;
|
|
12471
12601
|
let nativeFallbacks = 0;
|
|
@@ -12508,12 +12638,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12508
12638
|
};
|
|
12509
12639
|
const getNativeCropJpeg = async (frameHandle, normalizedBbox, maxWidth) => {
|
|
12510
12640
|
const native = await fetchNativeCropRgb(frameHandle, normalizedBbox, maxWidth);
|
|
12511
|
-
if (!native
|
|
12512
|
-
if (native) cropMetricLogger.debug("native subject crop below floor — local fallback", { meta: {
|
|
12513
|
-
width: native.width,
|
|
12514
|
-
height: native.height,
|
|
12515
|
-
maxWidth
|
|
12516
|
-
} });
|
|
12641
|
+
if (!native) {
|
|
12517
12642
|
bumpCropMetric(false);
|
|
12518
12643
|
return null;
|
|
12519
12644
|
}
|
|
@@ -12524,28 +12649,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
12524
12649
|
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
12525
12650
|
getRemoteFrame,
|
|
12526
12651
|
getNativeCropJpeg,
|
|
12652
|
+
getNativeFullFrameRgb,
|
|
12527
12653
|
mediaStore: this.mediaStore,
|
|
12528
12654
|
logger: logger.child("EventMediaDispatcher")
|
|
12529
12655
|
});
|
|
12530
|
-
const
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
w: bbox.w / frameWidth,
|
|
12536
|
-
h: bbox.h / frameHeight
|
|
12537
|
-
}, padding);
|
|
12538
|
-
const nativeCrop = await tryNativeCrop(frameHandle, paddedNorm, maxWidth);
|
|
12539
|
-
if (nativeCrop) {
|
|
12540
|
-
bumpCropMetric(true);
|
|
12541
|
-
return nativeCrop;
|
|
12542
|
-
}
|
|
12543
|
-
bumpCropMetric(false);
|
|
12544
|
-
const decoded = await resolveFrameShared(frameHandle);
|
|
12545
|
-
if (!decoded || decoded.format !== "rgb") return null;
|
|
12546
|
-
const { crop } = await extractCrop(Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data), decoded.width, decoded.height, paddedNorm);
|
|
12547
|
-
return crop;
|
|
12548
|
-
};
|
|
12656
|
+
const captureCrop = createCaptureCrop({
|
|
12657
|
+
tryNativeCrop,
|
|
12658
|
+
bumpCropMetric,
|
|
12659
|
+
logger: logger.child("CaptureCrop")
|
|
12660
|
+
});
|
|
12549
12661
|
this.captureCrop = captureCrop;
|
|
12550
12662
|
this.faceRecognizer = new FaceRecognizer({
|
|
12551
12663
|
identityStore: this.identityStore,
|
|
@@ -13394,7 +13506,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
13394
13506
|
else plateCrops += 1;
|
|
13395
13507
|
const snapshotTargets = this.buildSnapshotTargets(deviceId, result.tracked, result.timestamp, mediaSettings, result.frameWidth, result.frameHeight);
|
|
13396
13508
|
const keyFrameTrackIds = selectKeyFrameTrackIds(snapshotTargets);
|
|
13397
|
-
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle
|
|
13509
|
+
if (keyFrameTrackIds.length > 0) this.persistKeyFrames(deviceId, result.timestamp, keyFrameTrackIds, frameHandle);
|
|
13398
13510
|
if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
|
|
13399
13511
|
const captureCounts = {
|
|
13400
13512
|
events: eventTargets.length,
|
|
@@ -14005,30 +14117,54 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14005
14117
|
}));
|
|
14006
14118
|
}
|
|
14007
14119
|
/**
|
|
14008
|
-
* Capture
|
|
14009
|
-
*
|
|
14120
|
+
* Capture the CLEAN best full frame per given track at this best-detection
|
|
14121
|
+
* moment and store BOTH variants (`putReplacing` → one row per track, per
|
|
14122
|
+
* kind): the native `keyFrame` (full frame fetched at `KEYFRAME_NATIVE_MAX_WIDTH`
|
|
14123
|
+
* — true native on a 4K cam) plus its 960 companion `keyFrameSmall`, encoded
|
|
14124
|
+
* from the SAME native surface (no second fetch). No box is drawn — these are
|
|
14125
|
+
* the clean hero images (`keyFrameSmall` is the web-friendly one).
|
|
14010
14126
|
*
|
|
14011
|
-
*
|
|
14012
|
-
*
|
|
14013
|
-
* `
|
|
14014
|
-
*
|
|
14015
|
-
*
|
|
14016
|
-
*
|
|
14017
|
-
* Issued in the live-frame window so the native lease is still held. Best-
|
|
14018
|
-
* effort (D8) — a per-track failure is logged and never thrown.
|
|
14127
|
+
* NATIVE-OR-NOTHING: a native miss logs `error` and returns (no upscale, no
|
|
14128
|
+
* ≤640 stand-in) — the next genuine new-best frame retries. The `keyFrame`
|
|
14129
|
+
* key is recorded in `keyFrameKeyByTrackId` so the face / plate /
|
|
14130
|
+
* object-embedding rows LINK the SAME native key frame (Design B). Issued in
|
|
14131
|
+
* the live-frame window so the native lease is still held. Best-effort (D8) —
|
|
14132
|
+
* a per-track failure is logged and never thrown.
|
|
14019
14133
|
*/
|
|
14020
|
-
async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle
|
|
14021
|
-
const
|
|
14134
|
+
async persistKeyFrames(deviceId, timestamp, trackIds, frameHandle) {
|
|
14135
|
+
const getNative = this.getNativeKeyFrameRgb;
|
|
14022
14136
|
const mediaStore = this.mediaStore;
|
|
14023
|
-
if (!
|
|
14024
|
-
const req = buildKeyFrameCaptureRequest(frameWidth, frameHeight);
|
|
14137
|
+
if (!getNative || !mediaStore) return;
|
|
14025
14138
|
const pending = trackIds.filter((id) => !this.keyFrameInFlight.has(id));
|
|
14026
14139
|
if (pending.length === 0) return;
|
|
14027
14140
|
for (const id of pending) this.keyFrameInFlight.add(id);
|
|
14028
14141
|
await Promise.all(pending.map(async (trackId) => {
|
|
14029
14142
|
try {
|
|
14030
|
-
const
|
|
14031
|
-
|
|
14143
|
+
const result = await getNative(frameHandle, KEYFRAME_NATIVE_MAX_WIDTH);
|
|
14144
|
+
const native = result?.frame;
|
|
14145
|
+
if (!native || native.format !== "rgb" || native.width <= 0 || native.height <= 0) {
|
|
14146
|
+
this.ctx.logger.error("key-frame native miss — no upscale, will retry next best", {
|
|
14147
|
+
tags: { deviceId },
|
|
14148
|
+
meta: {
|
|
14149
|
+
trackId,
|
|
14150
|
+
shmId: frameHandle.shmId
|
|
14151
|
+
}
|
|
14152
|
+
});
|
|
14153
|
+
return;
|
|
14154
|
+
}
|
|
14155
|
+
const tier = result.tier;
|
|
14156
|
+
if (!keyFrameAcceptsTier(tier)) {
|
|
14157
|
+
this.ctx.logger.warn("keyFrame native miss — RAM tier rejected, will retry", {
|
|
14158
|
+
tags: { deviceId },
|
|
14159
|
+
meta: {
|
|
14160
|
+
trackId,
|
|
14161
|
+
shmId: frameHandle.shmId,
|
|
14162
|
+
tier
|
|
14163
|
+
}
|
|
14164
|
+
});
|
|
14165
|
+
return;
|
|
14166
|
+
}
|
|
14167
|
+
const { keyFrame, keyFrameSmall } = await encodeKeyFrameVariants(native);
|
|
14032
14168
|
const key = await mediaStore.putReplacing({
|
|
14033
14169
|
deviceId,
|
|
14034
14170
|
ownerKind: "track",
|
|
@@ -14038,6 +14174,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14038
14174
|
data: keyFrame
|
|
14039
14175
|
});
|
|
14040
14176
|
this.keyFrameKeyByTrackId.set(trackId, key);
|
|
14177
|
+
await mediaStore.putReplacing({
|
|
14178
|
+
deviceId,
|
|
14179
|
+
ownerKind: "track",
|
|
14180
|
+
ownerId: trackId,
|
|
14181
|
+
kind: "keyFrameSmall",
|
|
14182
|
+
timestamp,
|
|
14183
|
+
data: keyFrameSmall
|
|
14184
|
+
});
|
|
14041
14185
|
} catch (err) {
|
|
14042
14186
|
this.ctx.logger.debug("key-frame capture failed", {
|
|
14043
14187
|
tags: { deviceId },
|
|
@@ -14985,11 +15129,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
14985
15129
|
if (materializer === null || descriptor === void 0) return;
|
|
14986
15130
|
try {
|
|
14987
15131
|
const cameraIds = await cache.camerasFor(data.deviceId);
|
|
15132
|
+
if (cameraIds.length === 0) return;
|
|
15133
|
+
const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
|
|
14988
15134
|
for (const cameraId of cameraIds) await materializer.materialize({
|
|
14989
15135
|
cameraId,
|
|
14990
15136
|
sourceDeviceId: data.deviceId,
|
|
14991
15137
|
kind: descriptor.kind,
|
|
14992
|
-
timestamp
|
|
15138
|
+
timestamp,
|
|
15139
|
+
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
14993
15140
|
});
|
|
14994
15141
|
} catch (err) {
|
|
14995
15142
|
this.ctx.logger.warn("synthetic sensor-track materialize failed", {
|
|
@@ -15001,6 +15148,25 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
15001
15148
|
});
|
|
15002
15149
|
}
|
|
15003
15150
|
}
|
|
15151
|
+
/**
|
|
15152
|
+
* Resolve the NAME of a device (the linked sensor/control that produced a
|
|
15153
|
+
* synthetic event) via the device-manager cap. Best-effort — returns
|
|
15154
|
+
* undefined on any lookup failure or an unknown device so the synthetic
|
|
15155
|
+
* track still materializes without a label.
|
|
15156
|
+
*/
|
|
15157
|
+
async resolveProducingDeviceName(deviceId) {
|
|
15158
|
+
const api = this.ctx?.api;
|
|
15159
|
+
if (!api) return void 0;
|
|
15160
|
+
try {
|
|
15161
|
+
return (await api.deviceManager.getDevice.query({ deviceId }))?.name;
|
|
15162
|
+
} catch (err) {
|
|
15163
|
+
this.ctx?.logger?.debug("resolveProducingDeviceName failed", {
|
|
15164
|
+
tags: { deviceId },
|
|
15165
|
+
meta: { error: require_dist.errMsg(err) }
|
|
15166
|
+
});
|
|
15167
|
+
return;
|
|
15168
|
+
}
|
|
15169
|
+
}
|
|
15004
15170
|
async clearTracks(input) {
|
|
15005
15171
|
this.trackStore?.clearDevice(input.deviceId);
|
|
15006
15172
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
@@ -15516,17 +15682,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
15516
15682
|
key: clean.key
|
|
15517
15683
|
};
|
|
15518
15684
|
}
|
|
15519
|
-
const
|
|
15520
|
-
|
|
15521
|
-
|
|
15522
|
-
|
|
15523
|
-
|
|
15524
|
-
|
|
15525
|
-
|
|
15526
|
-
if (!chosenTrack) return null;
|
|
15685
|
+
const chosen = await resolveDefaultEventMedia({
|
|
15686
|
+
eventId: id,
|
|
15687
|
+
eventFiles,
|
|
15688
|
+
listTrackMedia: (trackId) => this.mediaStore?.listByOwner("track", trackId) ?? Promise.resolve([]),
|
|
15689
|
+
getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
|
|
15690
|
+
});
|
|
15691
|
+
if (!chosen) return null;
|
|
15527
15692
|
return {
|
|
15528
|
-
bytes: Buffer.from(
|
|
15529
|
-
key:
|
|
15693
|
+
bytes: Buffer.from(chosen.base64, "base64"),
|
|
15694
|
+
key: chosen.key
|
|
15530
15695
|
};
|
|
15531
15696
|
}
|
|
15532
15697
|
/**
|