@camstack/addon-pipeline 1.1.57 → 1.1.59

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.
Files changed (37) hide show
  1. package/dist/audio-analyzer/index.js +4 -4
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +9 -9
  4. package/dist/detection-pipeline/index.mjs +3 -3
  5. package/dist/{dist-CK8Ur-OS.js → dist-B1u-QN05.js} +4822 -4650
  6. package/dist/{dist-CL531uVA.mjs → dist-DKeNH_5z.mjs} +4823 -4651
  7. package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-Cp9f4dk6-0wh7OK3s.mjs} +2 -2
  8. package/dist/{model-download-service-C-IHWnXx-D326YNnt.js → model-download-service-Cp9f4dk6-Cp_GTKTw.js} +2 -2
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +147 -12
  12. package/dist/pipeline-runner/index.mjs +146 -11
  13. package/dist/recorder/index.js +993 -7
  14. package/dist/recorder/index.mjs +990 -5
  15. package/dist/session-decode/decode-worker-child.js +110 -2
  16. package/dist/session-decode/decode-worker-child.mjs +110 -2
  17. package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BN80zpCa.js} +56 -1
  18. package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-Bk3fR4s7.mjs} +39 -6
  19. package/dist/{step-definitions-DHAxruAQ.js → step-definitions-CPch37vh.js} +1 -1
  20. package/dist/{step-definitions-1d3_vQ6S.mjs → step-definitions-DUB27V8Y.mjs} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdkfKDgw.mjs} +3 -3
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D3h_W9uu.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSXctoaw.mjs +26 -0
  25. package/dist/stream-broker/{hostInit-DEaqJpGA.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
  26. package/dist/stream-broker/index.js +626 -152
  27. package/dist/stream-broker/index.mjs +626 -152
  28. package/dist/stream-broker/remoteEntry.js +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CmZbzLQi.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js → PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js} +1 -1
  32. package/embed-dist/assets/index-W9-H5LTj.js +81 -0
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ba7Eyd47.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TWCUWhCm.mjs +0 -26
  37. package/embed-dist/assets/index-DOsK8CZb.js +0 -81
@@ -3,7 +3,7 @@ import { createReadStream, promises } from "node:fs";
3
3
  import * as path$1 from "node:path";
4
4
  import path from "node:path";
5
5
  import "node:crypto";
6
- //#region ../system/dist/model-download-service-C-IHWnXx.mjs
6
+ //#region ../system/dist/model-download-service-Cp9f4dk6.mjs
7
7
  /**
8
8
  * Map a rel path to one candidate absolute path PER root, keeping only roots the
9
9
  * path stays within (traversal guard). The handler serves the first candidate
@@ -121,7 +121,7 @@ function createFileDataPlaneHandler(opts) {
121
121
  const baseHeaders = {
122
122
  "content-type": contentTypeFor(absPath, opts.contentTypes),
123
123
  "accept-ranges": "bytes",
124
- "cache-control": "no-cache"
124
+ "cache-control": opts.cacheControl?.(rel) ?? "no-cache"
125
125
  };
126
126
  const range = parseRangeHeader(req.headers.range, size);
127
127
  if (range) {
@@ -4,7 +4,7 @@ node_fs = require_chunk.__toESM(node_fs, 1);
4
4
  let node_path = require("node:path");
5
5
  node_path = require_chunk.__toESM(node_path, 1);
6
6
  require("node:crypto");
7
- //#region ../system/dist/model-download-service-C-IHWnXx.mjs
7
+ //#region ../system/dist/model-download-service-Cp9f4dk6.mjs
8
8
  /**
9
9
  * Map a rel path to one candidate absolute path PER root, keeping only roots the
10
10
  * path stays within (traversal guard). The handler serves the first candidate
@@ -122,7 +122,7 @@ function createFileDataPlaneHandler(opts) {
122
122
  const baseHeaders = {
123
123
  "content-type": contentTypeFor(absPath, opts.contentTypes),
124
124
  "accept-ranges": "bytes",
125
- "cache-control": "no-cache"
125
+ "cache-control": opts.cacheControl?.(rel) ?? "no-cache"
126
126
  };
127
127
  const range = parseRangeHeader(req.headers.range, size);
128
128
  if (range) {
@@ -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-CK8Ur-OS.js");
5
+ const require_dist = require("../dist-B1u-QN05.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -1,4 +1,4 @@
1
- import { B as DeviceType, E as motionDetectionCapability, L as BaseAddon, U as hydrateSchema, x as evaluateZoneRules } from "../dist-CL531uVA.mjs";
1
+ import { B as DeviceType, E as motionDetectionCapability, H as hydrateSchema, L as BaseAddon, x as evaluateZoneRules } from "../dist-DKeNH_5z.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  //#region src/motion-wasm/wasm-motion-detector.ts
@@ -3,11 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-D6vf50IK.js");
6
- const require_dist = require("../dist-CK8Ur-OS.js");
6
+ const require_dist = require("../dist-B1u-QN05.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
- const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
+ const require_sheet_geometry = require("../sheet-geometry-BN80zpCa.js");
9
9
  const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
10
- const require_step_definitions = require("../step-definitions-DHAxruAQ.js");
10
+ const require_step_definitions = require("../step-definitions-CPch37vh.js");
11
11
  let node_child_process = require("node:child_process");
12
12
  let node_url = require("node:url");
13
13
  let sharp = require("sharp");
@@ -1279,6 +1279,40 @@ function cropRgb(frame, bbox, maxWidth) {
1279
1279
  height: outH
1280
1280
  };
1281
1281
  }
1282
+ var ThumbSamplerPolicy = class {
1283
+ intervalMs;
1284
+ minGapMs;
1285
+ now;
1286
+ /** Last accepted-sample wall clock per device. */
1287
+ lastAcceptedAt = /* @__PURE__ */ new Map();
1288
+ constructor(options) {
1289
+ this.intervalMs = Math.max(0, options.intervalMs);
1290
+ this.minGapMs = Math.max(0, options.minGapMs ?? 1e3);
1291
+ this.now = options.now ?? (() => Date.now());
1292
+ }
1293
+ /**
1294
+ * Decide whether to sample `deviceId` now. Returns true (and records the
1295
+ * acceptance) when the larger of `intervalMs` / `minGapMs` has elapsed since
1296
+ * the last accepted sample for this device; false otherwise. The FIRST call
1297
+ * for a device always samples (cold start).
1298
+ */
1299
+ shouldSample(deviceId) {
1300
+ const now = this.now();
1301
+ const gate = Math.max(this.intervalMs, this.minGapMs);
1302
+ const last = this.lastAcceptedAt.get(deviceId);
1303
+ if (last !== void 0 && now - last < gate) return false;
1304
+ this.lastAcceptedAt.set(deviceId, now);
1305
+ return true;
1306
+ }
1307
+ /** Forget a device (detach / phase exit) so its next frame cold-starts. */
1308
+ forget(deviceId) {
1309
+ this.lastAcceptedAt.delete(deviceId);
1310
+ }
1311
+ /** Drop all per-device state (shutdown). */
1312
+ clear() {
1313
+ this.lastAcceptedAt.clear();
1314
+ }
1315
+ };
1282
1316
  //#endregion
1283
1317
  //#region src/session-decode/session-decode-coordinator.ts
1284
1318
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
@@ -2029,6 +2063,8 @@ async function runDetailSubtree(deps, input) {
2029
2063
  //#region src/pipeline-runner/index.ts
2030
2064
  var DEFAULT_CONFIG = {
2031
2065
  maxQueueDepth: 30,
2066
+ thumbnailsEnabled: true,
2067
+ thumbnailSampleMs: 5e3,
2032
2068
  maxConcurrentInferences: 16,
2033
2069
  targetLoadPercent: 80,
2034
2070
  minThrottledFps: 1,
@@ -2244,7 +2280,7 @@ function resolveSessionDecodeOwnerNodeId(config) {
2244
2280
  function resolveOwnerRestreamHost(frameSource, hubUrl) {
2245
2281
  const reachable = frameSource.ownerReachableHost?.trim();
2246
2282
  if (reachable !== void 0 && reachable.length > 0) return reachable;
2247
- return require_hub_hostname.resolveHubHostname(frameSource.hubHostnameOverride, hubUrl);
2283
+ return require_sheet_geometry.resolveHubHostname(frameSource.hubHostnameOverride, hubUrl);
2248
2284
  }
2249
2285
  /**
2250
2286
  * Acquire the broker's COMPRESSED passthrough restream for one camStream and
@@ -2350,6 +2386,12 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2350
2386
  * Initialised in {@link onInitialize} once `nodeId` is known.
2351
2387
  */
2352
2388
  retainedFrames = null;
2389
+ /**
2390
+ * Scrub-thumbnail sampler policy (2026-07-16). Gates the per-camera JPEG
2391
+ * encode to ~1 / `thumbnailSampleMs` (with a hard 1 Hz ceiling) so the cost
2392
+ * stays a couple ms of sharp every few seconds. Null until `onInitialize`.
2393
+ */
2394
+ thumbSampler = null;
2353
2395
  attached = /* @__PURE__ */ new Map();
2354
2396
  nodeId = "unknown";
2355
2397
  metricsSnapshotTimer = null;
@@ -2470,6 +2512,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2470
2512
  nodeId: this.nodeId,
2471
2513
  logger: this.ctx.logger.child("retained-frames")
2472
2514
  });
2515
+ this.thumbSampler = new ThumbSamplerPolicy({ intervalMs: this.config.thumbnailSampleMs });
2473
2516
  this.runner = new PipelineRunner({
2474
2517
  maxQueueDepth: this.config.maxQueueDepth,
2475
2518
  maxConcurrentInferences: this.config.maxConcurrentInferences,
@@ -2559,6 +2602,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2559
2602
  this.nativeCropRegistry.clear();
2560
2603
  this.retainedFrames?.clear();
2561
2604
  this.retainedFrames = null;
2605
+ this.thumbSampler?.clear();
2606
+ this.thumbSampler = null;
2562
2607
  }
2563
2608
  async cacheBenchFrame(input) {
2564
2609
  const sharp$2 = (await import("sharp")).default;
@@ -3021,6 +3066,59 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3021
3066
  frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
3022
3067
  };
3023
3068
  }
3069
+ /**
3070
+ * Scrub-thumbnail sampler (2026-07-16). Rides the already-decoded rgb
3071
+ * detection frame: at most once per `thumbnailSampleMs` per camera (policy
3072
+ * enforces a 1 Hz hard ceiling), downscale to a ~320px JPEG and emit it as
3073
+ * `recording.thumb-sampled` telemetry. The recorder (single designated node)
3074
+ * packs 5-minute sprite sheets from these. Fire-and-forget — a failed encode
3075
+ * is logged at debug and never disturbs the decode/detect path. Zero extra
3076
+ * decode (frame exists); one sharp encode per cadence per camera (D9: a
3077
+ * compressed payload at 1/5 Hz, never raw pixels at frame-rate).
3078
+ */
3079
+ maybeSampleThumbnail(deviceId, frame) {
3080
+ if (!this.config.thumbnailsEnabled) return;
3081
+ const sampler = this.thumbSampler;
3082
+ if (!sampler || frame.format !== "rgb") return;
3083
+ if (!sampler.shouldSample(deviceId)) return;
3084
+ const capturedAt = typeof frame.capturedAt === "number" && frame.capturedAt > 0 ? frame.capturedAt : Date.now();
3085
+ const width = frame.width;
3086
+ const height = frame.height;
3087
+ const data = frame.data;
3088
+ (async () => {
3089
+ try {
3090
+ const targetW = Math.min(320, width);
3091
+ const image = (0, sharp.default)(data, { raw: {
3092
+ width,
3093
+ height,
3094
+ channels: 3
3095
+ } });
3096
+ if (targetW < width) image.resize(targetW);
3097
+ const jpeg = await image.jpeg({ quality: 60 }).toBuffer();
3098
+ const meta = {
3099
+ width: targetW,
3100
+ height: Math.round(targetW * height / width)
3101
+ };
3102
+ this.ctxIfReady?.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.RecordingThumbSampled, {
3103
+ type: "pipeline",
3104
+ id: deviceId,
3105
+ nodeId: this.nodeId,
3106
+ deviceId
3107
+ }, {
3108
+ deviceId,
3109
+ capturedAt,
3110
+ width: meta.width,
3111
+ height: meta.height,
3112
+ jpeg: new Uint8Array(jpeg)
3113
+ }));
3114
+ } catch (err) {
3115
+ this.ctx?.logger.debug("thumbnail sample encode failed", {
3116
+ tags: { deviceId },
3117
+ meta: { error: err instanceof Error ? err.message : String(err) }
3118
+ });
3119
+ }
3120
+ })();
3121
+ }
3024
3122
  /** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
3025
3123
  async runDetailPipeline(steps, imageJpeg, deviceId) {
3026
3124
  const api = this.ctxIfReady?.api;
@@ -3098,6 +3196,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3098
3196
  attachment.detectionUnsubscribe?.();
3099
3197
  this.lastMotionFrameAt.delete(deviceId);
3100
3198
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
3199
+ this.thumbSampler?.forget(deviceId);
3101
3200
  this.attached.delete(deviceId);
3102
3201
  this.lastMotionAt.delete(deviceId);
3103
3202
  this.lastEmittedCameraMetrics.delete(deviceId);
@@ -3339,6 +3438,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3339
3438
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
3340
3439
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
3341
3440
  this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
3441
+ this.maybeSampleThumbnail(config.deviceId, frame);
3342
3442
  }
3343
3443
  });
3344
3444
  }
@@ -3516,14 +3616,16 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3516
3616
  }
3517
3617
  if (steps.length === 0) return null;
3518
3618
  let image;
3519
- if (frame.format === "rgb") try {
3520
- image = await (0, sharp.default)(frame.data, { raw: {
3521
- width: frame.width,
3522
- height: frame.height,
3523
- channels: 3
3524
- } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
3525
- } catch (err) {
3526
- log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: require_dist.errMsg(err) } });
3619
+ if (!this.executorCoLocated()) {
3620
+ if (frame.format === "rgb") try {
3621
+ image = await (0, sharp.default)(frame.data, { raw: {
3622
+ width: frame.width,
3623
+ height: frame.height,
3624
+ channels: 3
3625
+ } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
3626
+ } catch (err) {
3627
+ log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: require_dist.errMsg(err) } });
3628
+ }
3527
3629
  }
3528
3630
  try {
3529
3631
  return await api.pipelineExecutor.runPipeline.mutate({
@@ -3538,6 +3640,20 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3538
3640
  return null;
3539
3641
  }
3540
3642
  }
3643
+ /** Sticky true once the executor's provider shows up in THIS process' local
3644
+ * registry (the `detection` execution.group) — from then on the per-frame
3645
+ * call is in-process and raw pixels are zero-copy. Re-checks while false
3646
+ * (a co-located sibling may register after the runner's first frames). */
3647
+ executorCoLocatedSticky = false;
3648
+ executorCoLocated() {
3649
+ if (this.executorCoLocatedSticky) return true;
3650
+ if (this.ctx?.capabilities?.get("pipeline-executor") !== void 0) {
3651
+ this.executorCoLocatedSticky = true;
3652
+ this.ctx?.logger.info("executor co-located in this runner — raw in-process frame transport");
3653
+ return true;
3654
+ }
3655
+ return false;
3656
+ }
3541
3657
  async runMotionAnalysis(deviceId, frame, handle) {
3542
3658
  const ctx = this.ctx;
3543
3659
  const runner = this.runner;
@@ -3977,12 +4093,31 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3977
4093
  step: 1,
3978
4094
  default: DEFAULT_CONFIG.maxConcurrentDecodeSpawns,
3979
4095
  showValue: true
4096
+ },
4097
+ {
4098
+ type: "boolean",
4099
+ key: "thumbnailsEnabled",
4100
+ label: "Scrub thumbnails",
4101
+ description: "Sample the already-decoded detection frame into small JPEG scrub thumbnails (zero extra decode). The recorder packs them into 5-minute sprite sheets the viewer timeline scrubs. Disable to stop emitting thumbnail telemetry from this node.",
4102
+ default: DEFAULT_CONFIG.thumbnailsEnabled
4103
+ },
4104
+ {
4105
+ type: "slider",
4106
+ key: "thumbnailSampleMs",
4107
+ label: "Thumbnail cadence (ms)",
4108
+ description: "How often (ms) each camera contributes a scrub thumbnail. Lower = denser scrub preview + more storage; a hard 1 Hz ceiling always applies. Default 5000.",
4109
+ min: 1e3,
4110
+ max: 3e4,
4111
+ step: 1e3,
4112
+ default: DEFAULT_CONFIG.thumbnailSampleMs,
4113
+ showValue: true
3980
4114
  }
3981
4115
  ]
3982
4116
  }] });
3983
4117
  }
3984
4118
  async onConfigChanged() {
3985
4119
  this.runner?.updateLimits(this.config);
4120
+ this.thumbSampler = new ThumbSamplerPolicy({ intervalMs: this.config.thumbnailSampleMs });
3986
4121
  this.spawnGate?.resize(Math.max(1, this.config.maxConcurrentDecodeSpawns));
3987
4122
  this.ctx.logger.info("pipeline-runner tuning updated", { meta: {
3988
4123
  maxQueueDepth: this.config.maxQueueDepth,
@@ -1,8 +1,8 @@
1
- import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-CL531uVA.mjs";
1
+ import { D as nodePin, I as errMsg, L as BaseAddon, Q as boolean, V as createEvent, X as _enum, Z as array, _ as customAction, at as string, et as lazy, k as pipelineRunnerCapability, nt as number, rt as object, st as EventCategory, y as defineCustomActions } from "../dist-DKeNH_5z.mjs";
2
2
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
3
- import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
+ import { r as resolveHubHostname } from "../sheet-geometry-Bk3fR4s7.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
5
- import { a as getStepDefinition } from "../step-definitions-1d3_vQ6S.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-DUB27V8Y.mjs";
6
6
  import { fork } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import sharp from "sharp";
@@ -1273,6 +1273,40 @@ function cropRgb(frame, bbox, maxWidth) {
1273
1273
  height: outH
1274
1274
  };
1275
1275
  }
1276
+ var ThumbSamplerPolicy = class {
1277
+ intervalMs;
1278
+ minGapMs;
1279
+ now;
1280
+ /** Last accepted-sample wall clock per device. */
1281
+ lastAcceptedAt = /* @__PURE__ */ new Map();
1282
+ constructor(options) {
1283
+ this.intervalMs = Math.max(0, options.intervalMs);
1284
+ this.minGapMs = Math.max(0, options.minGapMs ?? 1e3);
1285
+ this.now = options.now ?? (() => Date.now());
1286
+ }
1287
+ /**
1288
+ * Decide whether to sample `deviceId` now. Returns true (and records the
1289
+ * acceptance) when the larger of `intervalMs` / `minGapMs` has elapsed since
1290
+ * the last accepted sample for this device; false otherwise. The FIRST call
1291
+ * for a device always samples (cold start).
1292
+ */
1293
+ shouldSample(deviceId) {
1294
+ const now = this.now();
1295
+ const gate = Math.max(this.intervalMs, this.minGapMs);
1296
+ const last = this.lastAcceptedAt.get(deviceId);
1297
+ if (last !== void 0 && now - last < gate) return false;
1298
+ this.lastAcceptedAt.set(deviceId, now);
1299
+ return true;
1300
+ }
1301
+ /** Forget a device (detach / phase exit) so its next frame cold-starts. */
1302
+ forget(deviceId) {
1303
+ this.lastAcceptedAt.delete(deviceId);
1304
+ }
1305
+ /** Drop all per-device state (shutdown). */
1306
+ clear() {
1307
+ this.lastAcceptedAt.clear();
1308
+ }
1309
+ };
1276
1310
  //#endregion
1277
1311
  //#region src/session-decode/session-decode-coordinator.ts
1278
1312
  /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
@@ -2023,6 +2057,8 @@ async function runDetailSubtree(deps, input) {
2023
2057
  //#region src/pipeline-runner/index.ts
2024
2058
  var DEFAULT_CONFIG = {
2025
2059
  maxQueueDepth: 30,
2060
+ thumbnailsEnabled: true,
2061
+ thumbnailSampleMs: 5e3,
2026
2062
  maxConcurrentInferences: 16,
2027
2063
  targetLoadPercent: 80,
2028
2064
  minThrottledFps: 1,
@@ -2344,6 +2380,12 @@ var PipelineRunnerAddon = class extends BaseAddon {
2344
2380
  * Initialised in {@link onInitialize} once `nodeId` is known.
2345
2381
  */
2346
2382
  retainedFrames = null;
2383
+ /**
2384
+ * Scrub-thumbnail sampler policy (2026-07-16). Gates the per-camera JPEG
2385
+ * encode to ~1 / `thumbnailSampleMs` (with a hard 1 Hz ceiling) so the cost
2386
+ * stays a couple ms of sharp every few seconds. Null until `onInitialize`.
2387
+ */
2388
+ thumbSampler = null;
2347
2389
  attached = /* @__PURE__ */ new Map();
2348
2390
  nodeId = "unknown";
2349
2391
  metricsSnapshotTimer = null;
@@ -2464,6 +2506,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
2464
2506
  nodeId: this.nodeId,
2465
2507
  logger: this.ctx.logger.child("retained-frames")
2466
2508
  });
2509
+ this.thumbSampler = new ThumbSamplerPolicy({ intervalMs: this.config.thumbnailSampleMs });
2467
2510
  this.runner = new PipelineRunner({
2468
2511
  maxQueueDepth: this.config.maxQueueDepth,
2469
2512
  maxConcurrentInferences: this.config.maxConcurrentInferences,
@@ -2553,6 +2596,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
2553
2596
  this.nativeCropRegistry.clear();
2554
2597
  this.retainedFrames?.clear();
2555
2598
  this.retainedFrames = null;
2599
+ this.thumbSampler?.clear();
2600
+ this.thumbSampler = null;
2556
2601
  }
2557
2602
  async cacheBenchFrame(input) {
2558
2603
  const sharp = (await import("sharp")).default;
@@ -3015,6 +3060,59 @@ var PipelineRunnerAddon = class extends BaseAddon {
3015
3060
  frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
3016
3061
  };
3017
3062
  }
3063
+ /**
3064
+ * Scrub-thumbnail sampler (2026-07-16). Rides the already-decoded rgb
3065
+ * detection frame: at most once per `thumbnailSampleMs` per camera (policy
3066
+ * enforces a 1 Hz hard ceiling), downscale to a ~320px JPEG and emit it as
3067
+ * `recording.thumb-sampled` telemetry. The recorder (single designated node)
3068
+ * packs 5-minute sprite sheets from these. Fire-and-forget — a failed encode
3069
+ * is logged at debug and never disturbs the decode/detect path. Zero extra
3070
+ * decode (frame exists); one sharp encode per cadence per camera (D9: a
3071
+ * compressed payload at 1/5 Hz, never raw pixels at frame-rate).
3072
+ */
3073
+ maybeSampleThumbnail(deviceId, frame) {
3074
+ if (!this.config.thumbnailsEnabled) return;
3075
+ const sampler = this.thumbSampler;
3076
+ if (!sampler || frame.format !== "rgb") return;
3077
+ if (!sampler.shouldSample(deviceId)) return;
3078
+ const capturedAt = typeof frame.capturedAt === "number" && frame.capturedAt > 0 ? frame.capturedAt : Date.now();
3079
+ const width = frame.width;
3080
+ const height = frame.height;
3081
+ const data = frame.data;
3082
+ (async () => {
3083
+ try {
3084
+ const targetW = Math.min(320, width);
3085
+ const image = sharp(data, { raw: {
3086
+ width,
3087
+ height,
3088
+ channels: 3
3089
+ } });
3090
+ if (targetW < width) image.resize(targetW);
3091
+ const jpeg = await image.jpeg({ quality: 60 }).toBuffer();
3092
+ const meta = {
3093
+ width: targetW,
3094
+ height: Math.round(targetW * height / width)
3095
+ };
3096
+ this.ctxIfReady?.eventBus.emit(createEvent(EventCategory.RecordingThumbSampled, {
3097
+ type: "pipeline",
3098
+ id: deviceId,
3099
+ nodeId: this.nodeId,
3100
+ deviceId
3101
+ }, {
3102
+ deviceId,
3103
+ capturedAt,
3104
+ width: meta.width,
3105
+ height: meta.height,
3106
+ jpeg: new Uint8Array(jpeg)
3107
+ }));
3108
+ } catch (err) {
3109
+ this.ctx?.logger.debug("thumbnail sample encode failed", {
3110
+ tags: { deviceId },
3111
+ meta: { error: err instanceof Error ? err.message : String(err) }
3112
+ });
3113
+ }
3114
+ })();
3115
+ }
3018
3116
  /** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
3019
3117
  async runDetailPipeline(steps, imageJpeg, deviceId) {
3020
3118
  const api = this.ctxIfReady?.api;
@@ -3092,6 +3190,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3092
3190
  attachment.detectionUnsubscribe?.();
3093
3191
  this.lastMotionFrameAt.delete(deviceId);
3094
3192
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
3193
+ this.thumbSampler?.forget(deviceId);
3095
3194
  this.attached.delete(deviceId);
3096
3195
  this.lastMotionAt.delete(deviceId);
3097
3196
  this.lastEmittedCameraMetrics.delete(deviceId);
@@ -3333,6 +3432,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3333
3432
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
3334
3433
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop);
3335
3434
  this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
3435
+ this.maybeSampleThumbnail(config.deviceId, frame);
3336
3436
  }
3337
3437
  });
3338
3438
  }
@@ -3510,14 +3610,16 @@ var PipelineRunnerAddon = class extends BaseAddon {
3510
3610
  }
3511
3611
  if (steps.length === 0) return null;
3512
3612
  let image;
3513
- if (frame.format === "rgb") try {
3514
- image = await sharp(frame.data, { raw: {
3515
- width: frame.width,
3516
- height: frame.height,
3517
- channels: 3
3518
- } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
3519
- } catch (err) {
3520
- log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: errMsg(err) } });
3613
+ if (!this.executorCoLocated()) {
3614
+ if (frame.format === "rgb") try {
3615
+ image = await sharp(frame.data, { raw: {
3616
+ width: frame.width,
3617
+ height: frame.height,
3618
+ channels: 3
3619
+ } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
3620
+ } catch (err) {
3621
+ log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: errMsg(err) } });
3622
+ }
3521
3623
  }
3522
3624
  try {
3523
3625
  return await api.pipelineExecutor.runPipeline.mutate({
@@ -3532,6 +3634,20 @@ var PipelineRunnerAddon = class extends BaseAddon {
3532
3634
  return null;
3533
3635
  }
3534
3636
  }
3637
+ /** Sticky true once the executor's provider shows up in THIS process' local
3638
+ * registry (the `detection` execution.group) — from then on the per-frame
3639
+ * call is in-process and raw pixels are zero-copy. Re-checks while false
3640
+ * (a co-located sibling may register after the runner's first frames). */
3641
+ executorCoLocatedSticky = false;
3642
+ executorCoLocated() {
3643
+ if (this.executorCoLocatedSticky) return true;
3644
+ if (this.ctx?.capabilities?.get("pipeline-executor") !== void 0) {
3645
+ this.executorCoLocatedSticky = true;
3646
+ this.ctx?.logger.info("executor co-located in this runner — raw in-process frame transport");
3647
+ return true;
3648
+ }
3649
+ return false;
3650
+ }
3535
3651
  async runMotionAnalysis(deviceId, frame, handle) {
3536
3652
  const ctx = this.ctx;
3537
3653
  const runner = this.runner;
@@ -3971,12 +4087,31 @@ var PipelineRunnerAddon = class extends BaseAddon {
3971
4087
  step: 1,
3972
4088
  default: DEFAULT_CONFIG.maxConcurrentDecodeSpawns,
3973
4089
  showValue: true
4090
+ },
4091
+ {
4092
+ type: "boolean",
4093
+ key: "thumbnailsEnabled",
4094
+ label: "Scrub thumbnails",
4095
+ description: "Sample the already-decoded detection frame into small JPEG scrub thumbnails (zero extra decode). The recorder packs them into 5-minute sprite sheets the viewer timeline scrubs. Disable to stop emitting thumbnail telemetry from this node.",
4096
+ default: DEFAULT_CONFIG.thumbnailsEnabled
4097
+ },
4098
+ {
4099
+ type: "slider",
4100
+ key: "thumbnailSampleMs",
4101
+ label: "Thumbnail cadence (ms)",
4102
+ description: "How often (ms) each camera contributes a scrub thumbnail. Lower = denser scrub preview + more storage; a hard 1 Hz ceiling always applies. Default 5000.",
4103
+ min: 1e3,
4104
+ max: 3e4,
4105
+ step: 1e3,
4106
+ default: DEFAULT_CONFIG.thumbnailSampleMs,
4107
+ showValue: true
3974
4108
  }
3975
4109
  ]
3976
4110
  }] });
3977
4111
  }
3978
4112
  async onConfigChanged() {
3979
4113
  this.runner?.updateLimits(this.config);
4114
+ this.thumbSampler = new ThumbSamplerPolicy({ intervalMs: this.config.thumbnailSampleMs });
3980
4115
  this.spawnGate?.resize(Math.max(1, this.config.maxConcurrentDecodeSpawns));
3981
4116
  this.ctx.logger.info("pipeline-runner tuning updated", { meta: {
3982
4117
  maxQueueDepth: this.config.maxQueueDepth,