@camstack/addon-pipeline 1.2.74 → 1.2.76

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 (36) hide show
  1. package/dist/{addon-utils-CQs-AjTJ.js → addon-utils-CTpQfjSR.js} +4 -4
  2. package/dist/audio-analyzer/index.js +5 -6
  3. package/dist/audio-analyzer/index.mjs +3 -3
  4. package/dist/detection-pipeline/index.js +9 -10
  5. package/dist/detection-pipeline/index.mjs +5 -5
  6. package/dist/{dist-CgJ8MRAy.js → dist-CZLjObZZ.js} +1996 -29
  7. package/dist/{dist-CKY0efl3.mjs → dist-zksfWnEA.mjs} +1961 -29
  8. package/dist/{event-loop-stall-monitor-CIapFIez.mjs → event-loop-stall-monitor-BuZA3loB.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-Dx-zXcNg.js → event-loop-stall-monitor-CcBQAI28.js} +1 -1
  10. package/dist/lazy-sharp-6oymT_yf.mjs +341 -0
  11. package/dist/lazy-sharp-SWR_D1Um.js +360 -0
  12. package/dist/motion-wasm/index.js +3 -3
  13. package/dist/motion-wasm/index.mjs +3 -3
  14. package/dist/pipeline-runner/index.js +280 -18
  15. package/dist/pipeline-runner/index.mjs +278 -15
  16. package/dist/recorder/index.js +138 -32
  17. package/dist/recorder/index.mjs +137 -30
  18. package/dist/session-decode/decode-worker-child.js +311 -126
  19. package/dist/session-decode/decode-worker-child.mjs +311 -123
  20. package/dist/stream-broker/_stub.js +94 -94
  21. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DVJ4gKkk.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dd6XOtVn.mjs} +2 -2
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-RYTOS6B-.mjs +26 -0
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DfsLsn2e.mjs +26 -0
  24. package/dist/stream-broker/{hostInit-5SFdfUyc.mjs → hostInit-COc_Q_xX.mjs} +2 -2
  25. package/dist/stream-broker/index.js +12 -13
  26. package/dist/stream-broker/index.mjs +3 -3
  27. package/dist/stream-broker/remoteEntry.js +1 -1
  28. package/dist/{worker-protocol-D44DCkcs.js → worker-protocol-DAQ1iZFK.js} +1 -1
  29. package/dist/{worker-protocol-BzAQPUR-.mjs → worker-protocol-DyX_HbaJ.mjs} +1 -1
  30. package/package.json +1 -1
  31. package/dist/chunk-emK7D4bc.js +0 -35
  32. package/dist/lazy-sharp-B-mb8uWx.mjs +0 -20
  33. package/dist/lazy-sharp-CcYuXtsa.js +0 -25
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C_plRUtD.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CExO0piw.mjs +0 -26
  36. package/dist/{addon-utils-CZ2xo67g.mjs → addon-utils-A2S9D7pu.mjs} +2 -2
@@ -1,8 +1,8 @@
1
- import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-CKY0efl3.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-CIapFIez.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-BzAQPUR-.mjs";
1
+ import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-zksfWnEA.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-BuZA3loB.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-DyX_HbaJ.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
- import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
5
+ import { t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
6
6
  import { i as withDetectionIntent, n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-Ci7RXNGb.mjs";
7
7
  import sharp from "sharp";
8
8
  import { fork } from "node:child_process";
@@ -2359,13 +2359,36 @@ function summarizeRunnerHardware(hardware, cpuCores = os.cpus().length) {
2359
2359
  * just picked one up — which coincide with motion in the package zone
2360
2360
  * SETTLING (transitioning from moving to still). This governor fires:
2361
2361
  *
2362
- * - on a **settle edge**: motion was observed, then has been absent for
2363
- * `settleMs` (kills flicker between motion frames and the "bag briefly
2364
- * set down then snatched back" case), and
2362
+ * - on a **settle edge**: motion NEAR THE PACKAGE ZONE was observed, then has
2363
+ * been absent for `settleMs` (kills flicker between motion frames and the
2364
+ * "bag briefly set down then snatched back" case). The settle edge opens a
2365
+ * **BURST**, and
2365
2366
  * - on a **low-cadence safety poll** (`pollIntervalMs`, ~30–60s): a
2366
2367
  * backstop so a delivery whose motion was never observed (a session-gap
2367
2368
  * camera that only decodes intermittently) is still eventually caught.
2368
2369
  *
2370
+ * ## The burst, and why the poll alone was not enough
2371
+ *
2372
+ * A parcel appears at the END of a motion episode — the courier walks up, puts
2373
+ * it down, walks away. That is a settle edge, and it is the moment we know
2374
+ * something MIGHT have been left behind. So the settle edge does not fire one
2375
+ * inference, it opens a burst: `burstCount` runs `burstIntervalMs` apart.
2376
+ *
2377
+ * The poll alone made the downstream promotion criterion unreachable in
2378
+ * PRACTICE and the notification useless in LATENCY. One observation per 45s
2379
+ * (and on a session-dispatch camera the measured gap was ~2 minutes) meant a
2380
+ * parcel needed minutes to accumulate enough evidence to be called parked, if
2381
+ * it ever did. The burst supplies the whole evidence chain inside ~20s, so a
2382
+ * delivery is reported while the courier is still in the street.
2383
+ *
2384
+ * The poll stays, unchanged in role: it is the SAFETY NET for the parcel whose
2385
+ * motion was never observed at all — a camera that just came up, an occlusion,
2386
+ * a missed analyzer frame. It is not the primary path any more.
2387
+ *
2388
+ * The two feed the SAME downstream round counter (`PackageStillnessTracker`),
2389
+ * which is what lets one promotion criterion serve both cadences. That is the
2390
+ * relation `scripts/check-package-promotion-satisfiable.ts` enforces.
2391
+ *
2369
2392
  * Pure logic with an injected clock (every method takes `now`), mirroring
2370
2393
  * `FrameGovernor` / `recomputeGovernor(now)` — no timers, no `Date.now()`
2371
2394
  * inside, fully unit-testable. Per-device state lives in a Map; the caller
@@ -2384,14 +2407,37 @@ function summarizeRunnerHardware(hardware, cpuCores = os.cpus().length) {
2384
2407
  */
2385
2408
  /** The step id (`PipelineStepInput.addonId`) the governor gates. */
2386
2409
  var PACKAGE_DETECTION_STEP_ID = "package-detection";
2410
+ /**
2411
+ * Burst: 5 runs, 5s apart — 0s, 5s, 10s, 15s, 20s after the settle edge.
2412
+ *
2413
+ * Sized against the two downstream numbers it has to satisfy, both pinned by
2414
+ * `scripts/check-package-promotion-satisfiable.ts`:
2415
+ *
2416
+ * - `PACKAGE_STILLNESS_ROUND_GAP_MS` (5 000) — the spacing must be ≥ this or
2417
+ * the runs collapse into one round;
2418
+ * - `packageDropDwellSec` (15s default) — the burst SPAN
2419
+ * (`(burstCount - 1) × burstIntervalMs` = 20s) must clear the dwell, or the
2420
+ * burst ends before the parcel has been still long enough to count and the
2421
+ * delivery falls back to the poll. 20s over 15s is one whole run of margin,
2422
+ * which is what absorbs a dropped analyzer frame.
2423
+ *
2424
+ * Cost: 5 extra inferences per motion episode, on cameras with an active
2425
+ * package zone only (the caller consults this governor only while the
2426
+ * `package-detection` step is in the camera's tree, and the resolver appends
2427
+ * that step only for an enabled `package`-stage zone rule).
2428
+ */
2387
2429
  var DEFAULT_MOTION_SETTLE_CONFIG = {
2388
2430
  settleMs: 3e3,
2389
- pollIntervalMs: 45e3
2431
+ pollIntervalMs: 45e3,
2432
+ burstCount: 5,
2433
+ burstIntervalMs: 5e3
2390
2434
  };
2391
2435
  var INITIAL_STATE = {
2392
2436
  lastMotionAt: null,
2393
2437
  armed: false,
2394
- lastRunAt: Number.NEGATIVE_INFINITY
2438
+ lastRunAt: Number.NEGATIVE_INFINITY,
2439
+ burstRemaining: 0,
2440
+ burstNextAt: 0
2395
2441
  };
2396
2442
  var NO_RUN = {
2397
2443
  run: false,
@@ -2417,18 +2463,35 @@ var MotionSettleGovernor = class {
2417
2463
  this.state.set(sample.deviceId, {
2418
2464
  lastMotionAt,
2419
2465
  armed: false,
2420
- lastRunAt: sample.now
2466
+ lastRunAt: sample.now,
2467
+ burstRemaining: Math.max(0, this.config.burstCount - 1),
2468
+ burstNextAt: sample.now + this.config.burstIntervalMs
2421
2469
  });
2422
2470
  return {
2423
2471
  run: true,
2424
2472
  reason: "settle"
2425
2473
  };
2426
2474
  }
2475
+ if (prev.burstRemaining > 0 && sample.now >= prev.burstNextAt) {
2476
+ this.state.set(sample.deviceId, {
2477
+ lastMotionAt,
2478
+ armed,
2479
+ lastRunAt: sample.now,
2480
+ burstRemaining: prev.burstRemaining - 1,
2481
+ burstNextAt: sample.now + this.config.burstIntervalMs
2482
+ });
2483
+ return {
2484
+ run: true,
2485
+ reason: "burst"
2486
+ };
2487
+ }
2427
2488
  if (pollReady) {
2428
2489
  this.state.set(sample.deviceId, {
2429
2490
  lastMotionAt,
2430
2491
  armed,
2431
- lastRunAt: sample.now
2492
+ lastRunAt: sample.now,
2493
+ burstRemaining: prev.burstRemaining,
2494
+ burstNextAt: prev.burstNextAt
2432
2495
  });
2433
2496
  return {
2434
2497
  run: true,
@@ -2438,7 +2501,9 @@ var MotionSettleGovernor = class {
2438
2501
  this.state.set(sample.deviceId, {
2439
2502
  lastMotionAt,
2440
2503
  armed,
2441
- lastRunAt: prev.lastRunAt
2504
+ lastRunAt: prev.lastRunAt,
2505
+ burstRemaining: prev.burstRemaining,
2506
+ burstNextAt: prev.burstNextAt
2442
2507
  });
2443
2508
  return NO_RUN;
2444
2509
  }
@@ -2452,6 +2517,90 @@ var MotionSettleGovernor = class {
2452
2517
  }
2453
2518
  };
2454
2519
  //#endregion
2520
+ //#region src/pipeline-runner/package-zone-proximity.ts
2521
+ /**
2522
+ * How much the zone-union box grows on EVERY side, as a fraction of its own
2523
+ * width / height.
2524
+ *
2525
+ * 0.5 means a doormat 271px wide on a 640px frame is approached through a
2526
+ * ~135px apron on each side — about one adult's width at that distance, which
2527
+ * is the thing being detected: a person standing next to the mat, not on it.
2528
+ * Larger and a passer-by on the pavement opens a burst; smaller and the
2529
+ * courier who never steps on the mat does not.
2530
+ */
2531
+ var PACKAGE_ZONE_DILATION = .5;
2532
+ /** Grow a box by {@link PACKAGE_ZONE_DILATION}, clamped to the frame. */
2533
+ function dilateZoneBox(box, dilation = PACKAGE_ZONE_DILATION) {
2534
+ const dx = box.w * dilation;
2535
+ const dy = box.h * dilation;
2536
+ const x = Math.max(0, box.x - dx);
2537
+ const y = Math.max(0, box.y - dy);
2538
+ return {
2539
+ x,
2540
+ y,
2541
+ w: Math.min(1, box.x + box.w + dx) - x,
2542
+ h: Math.min(1, box.y + box.h + dy) - y
2543
+ };
2544
+ }
2545
+ function intersects(a, b) {
2546
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
2547
+ }
2548
+ /**
2549
+ * Does any motion region reach the dilated package zone?
2550
+ *
2551
+ * **Fail-open on unknown geometry.** `zoneUnion === undefined` means the caller
2552
+ * has not (or could not) resolve the zones — a cold cache, a failed cap read.
2553
+ * It answers "yes, if there is any motion at all", which degrades to exactly
2554
+ * the whole-frame behaviour this replaced. The alternative — answering "no" —
2555
+ * would let one failed read silence package detection on a camera for as long
2556
+ * as the read kept failing, and be indistinguishable from a quiet doorstep.
2557
+ * That is the shape of D49, and it has cost this repo real deliveries.
2558
+ */
2559
+ function motionNearPackageZone(input) {
2560
+ if (input.regions.length === 0) return false;
2561
+ if (input.zoneUnion === void 0) return true;
2562
+ if (input.frameWidth <= 0 || input.frameHeight <= 0) return true;
2563
+ const target = dilateZoneBox(input.zoneUnion);
2564
+ for (const region of input.regions) if (intersects({
2565
+ x: region.bbox.x / input.frameWidth,
2566
+ y: region.bbox.y / input.frameHeight,
2567
+ w: region.bbox.w / input.frameWidth,
2568
+ h: region.bbox.h / input.frameHeight
2569
+ }, target)) return true;
2570
+ return false;
2571
+ }
2572
+ /**
2573
+ * The bounding box of a set of normalised polygons — the package zone union.
2574
+ * Returns `undefined` when there is no usable geometry, which the caller must
2575
+ * pass through as "unknown" rather than as an empty zone.
2576
+ */
2577
+ function packageZoneUnion(polygons) {
2578
+ let minX = Number.POSITIVE_INFINITY;
2579
+ let minY = Number.POSITIVE_INFINITY;
2580
+ let maxX = Number.NEGATIVE_INFINITY;
2581
+ let maxY = Number.NEGATIVE_INFINITY;
2582
+ let sawPoint = false;
2583
+ for (const polygon of polygons) for (const point of polygon) {
2584
+ sawPoint = true;
2585
+ if (point.x < minX) minX = point.x;
2586
+ if (point.y < minY) minY = point.y;
2587
+ if (point.x > maxX) maxX = point.x;
2588
+ if (point.y > maxY) maxY = point.y;
2589
+ }
2590
+ if (!sawPoint) return void 0;
2591
+ const x = Math.max(0, Math.min(1, minX));
2592
+ const y = Math.max(0, Math.min(1, minY));
2593
+ const w = Math.max(0, Math.min(1, maxX)) - x;
2594
+ const h = Math.max(0, Math.min(1, maxY)) - y;
2595
+ if (w <= 0 || h <= 0) return void 0;
2596
+ return {
2597
+ x,
2598
+ y,
2599
+ w,
2600
+ h
2601
+ };
2602
+ }
2603
+ //#endregion
2455
2604
  //#region src/pipeline-runner/native-crop-tier.ts
2456
2605
  /**
2457
2606
  * Pure tier decision for `getNativeCrop`. A native HIT always resolves to the
@@ -3829,6 +3978,13 @@ function isUsable(bbox) {
3829
3978
  //#endregion
3830
3979
  //#region src/pipeline-runner/index.ts
3831
3980
  /**
3981
+ * How often a device's package zone-union geometry is re-read. An operator
3982
+ * redrawing a package zone takes effect within one interval; the read is a
3983
+ * cross-addon cap call reached from the per-frame package gate, so it must be
3984
+ * throttled to something far slower than the frame rate.
3985
+ */
3986
+ var PACKAGE_ZONE_UNION_TTL_MS = 6e4;
3987
+ /**
3832
3988
  * Bound on the frames whose cutter is bound but whose result has not come back.
3833
3989
  * A few per camera in flight; the ceiling only catches results that never
3834
3990
  * arrive (shed, inference timeout, detach mid-flight).
@@ -4199,6 +4355,36 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4199
4355
  */
4200
4356
  packageGovernor = new MotionSettleGovernor();
4201
4357
  /**
4358
+ * Frames on which the governor DROPPED `package-detection` since that
4359
+ * device last ran it. The drop is the single most consequential silent
4360
+ * branch in the package chain — it decides the cadence that the whole
4361
+ * downstream promotion criterion has to be satisfiable at — and it logged
4362
+ * nothing at all, so "the parcel was never detected" and "the step never
4363
+ * ran" were indistinguishable in the logs. Counted here and reported on the
4364
+ * transition (first skip) and on every run, never per frame.
4365
+ */
4366
+ packageGateSkips = /* @__PURE__ */ new Map();
4367
+ /**
4368
+ * Per-device package zone-union box (normalised), the target the motion
4369
+ * proximity test measures against.
4370
+ *
4371
+ * A device MISSING from this map is "unknown", not "no zones" — and unknown
4372
+ * FAILS OPEN in `motionNearPackageZone`, degrading to the whole-frame motion
4373
+ * signal. Resolved off the frame path (on attach, and refreshed on the
4374
+ * package-step gate's own cadence), never inside `runMotionAnalysis`.
4375
+ */
4376
+ packageZoneUnionByDevice = /* @__PURE__ */ new Map();
4377
+ /** In-flight/last zone-union resolution time per device, so the refresh does
4378
+ * not stampede the zone caps from the motion path. */
4379
+ packageZoneUnionAt = /* @__PURE__ */ new Map();
4380
+ /**
4381
+ * Whether the last analyzer frame saw motion NEAR the device's package
4382
+ * zones — the boolean the settle-burst governor consumes. Distinct from
4383
+ * `lastAnalyzerDetected` (whole-frame), which is what the phase machine
4384
+ * uses and which must not change meaning.
4385
+ */
4386
+ lastMotionNearPackageZone = /* @__PURE__ */ new Map();
4387
+ /**
4202
4388
  * Last positive motion timestamp per device — preserved across the
4203
4389
  * OFF transition so the motion runtime-state slice keeps a stable
4204
4390
  * `lastDetectedAt` after the cooldown closes the phase. Cleared on
@@ -4399,6 +4585,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4399
4585
  }
4400
4586
  this.lastAnalyzerDetected.clear();
4401
4587
  this.packageGovernor.reset();
4588
+ this.packageGateSkips.clear();
4589
+ this.packageZoneUnionByDevice.clear();
4590
+ this.packageZoneUnionAt.clear();
4591
+ this.lastMotionNearPackageZone.clear();
4402
4592
  for (const deviceId of [...this.onboardAnalyzerTeardownTimers.keys(), ...this.onboardAnalyzerSubs.keys()]) this.clearOnboardAnalyzer(deviceId);
4403
4593
  if (this.runner) {
4404
4594
  this.runner.stop();
@@ -5661,6 +5851,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5661
5851
  this.lastMotionFrameAt.delete(deviceId);
5662
5852
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
5663
5853
  this.packageGovernor.forget(deviceId);
5854
+ this.packageGateSkips.delete(deviceId);
5855
+ this.packageZoneUnionByDevice.delete(deviceId);
5856
+ this.packageZoneUnionAt.delete(deviceId);
5857
+ this.lastMotionNearPackageZone.delete(deviceId);
5664
5858
  const warnPrefix = `${deviceId}:`;
5665
5859
  for (const key of this.warnedNoJumpDevice) if (key.startsWith(warnPrefix)) this.warnedNoJumpDevice.delete(key);
5666
5860
  this.attached.delete(deviceId);
@@ -6237,13 +6431,31 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6237
6431
  if (steps.length === 0) return null;
6238
6432
  let dispatchSteps = steps;
6239
6433
  if (steps.some((s) => s.addonId === "package-detection" && s.enabled !== false)) {
6240
- if (!this.packageGovernor.evaluate({
6434
+ this.refreshPackageZoneUnion(deviceId);
6435
+ const motionInZone = this.lastMotionNearPackageZone.get(deviceId) ?? this.lastAnalyzerDetected.get(deviceId) ?? false;
6436
+ const decision = this.packageGovernor.evaluate({
6241
6437
  deviceId,
6242
- motionInZone: this.lastAnalyzerDetected.get(deviceId) ?? false,
6438
+ motionInZone,
6243
6439
  now: Date.now()
6244
- }).run) {
6440
+ });
6441
+ const skipped = this.packageGateSkips.get(deviceId) ?? 0;
6442
+ if (!decision.run) {
6245
6443
  dispatchSteps = steps.filter((s) => s.addonId !== PACKAGE_DETECTION_STEP_ID);
6444
+ if (skipped === 0) log.info("package-detection gated off by the motion-settle governor", {
6445
+ tags: { deviceId },
6446
+ meta: { motionInZone: this.lastAnalyzerDetected.get(deviceId) ?? false }
6447
+ });
6448
+ this.packageGateSkips.set(deviceId, skipped + 1);
6246
6449
  if (dispatchSteps.length === 0) return null;
6450
+ } else {
6451
+ this.packageGateSkips.set(deviceId, 0);
6452
+ log.info("package-detection dispatched by the motion-settle governor", {
6453
+ tags: { deviceId },
6454
+ meta: {
6455
+ reason: decision.reason,
6456
+ framesSkippedSinceLastRun: skipped
6457
+ }
6458
+ });
6247
6459
  }
6248
6460
  }
6249
6461
  let image;
@@ -6292,6 +6504,51 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6292
6504
  }
6293
6505
  return false;
6294
6506
  }
6507
+ /**
6508
+ * Refresh a device's package zone-union box, off the caller's path.
6509
+ *
6510
+ * Cross-addon reads go through `ctx.api` (the only cross-addon layer), and
6511
+ * they are RATE-LIMITED per device: this is reached from the per-frame
6512
+ * package gate, and two cap calls per frame would be a self-inflicted load
6513
+ * problem on the exact cameras the feature exists for.
6514
+ *
6515
+ * A FAILED read leaves the mirror untouched (D49): the last-known-good
6516
+ * geometry keeps being used, and a device that has never been read stays
6517
+ * absent from the map, which `motionNearPackageZone` treats as unknown and
6518
+ * fails OPEN on. Nothing here can make package detection quieter.
6519
+ */
6520
+ refreshPackageZoneUnion(deviceId) {
6521
+ const now = Date.now();
6522
+ if (now - (this.packageZoneUnionAt.get(deviceId) ?? 0) < PACKAGE_ZONE_UNION_TTL_MS) return;
6523
+ this.packageZoneUnionAt.set(deviceId, now);
6524
+ const api = this.ctx.api;
6525
+ if (!api) return;
6526
+ (async () => {
6527
+ try {
6528
+ const [zones, rules] = await Promise.all([api.zones.listZones.query({ deviceId }), api.zoneRules.listRules.query({
6529
+ deviceId,
6530
+ stage: "package"
6531
+ })]);
6532
+ const zoneIds = new Set(rules.filter((r) => r.enabled !== false).flatMap((r) => r.zoneIds));
6533
+ const polygons = zones.filter((z) => zoneIds.has(z.id)).map((z) => z.polygon);
6534
+ const union = packageZoneUnion(polygons);
6535
+ const previous = this.packageZoneUnionByDevice.get(deviceId);
6536
+ this.packageZoneUnionByDevice.set(deviceId, union);
6537
+ if (previous === void 0 && union !== void 0) this.ctx.logger.info("package zone geometry resolved — motion proximity armed", {
6538
+ tags: { deviceId },
6539
+ meta: {
6540
+ zoneUnion: union,
6541
+ zones: polygons.length
6542
+ }
6543
+ });
6544
+ } catch (err) {
6545
+ this.ctx.logger.warn("package zone geometry read failed — motion proximity fails open", {
6546
+ tags: { deviceId },
6547
+ meta: { error: err instanceof Error ? err.message : String(err) }
6548
+ });
6549
+ }
6550
+ })();
6551
+ }
6295
6552
  async runMotionAnalysis(deviceId, frame, handle) {
6296
6553
  const ctx = this.ctx;
6297
6554
  const runner = this.runner;
@@ -6312,6 +6569,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6312
6569
  });
6313
6570
  if (!result) return;
6314
6571
  const detected = result.regions.length > 0;
6572
+ this.lastMotionNearPackageZone.set(deviceId, motionNearPackageZone({
6573
+ regions: result.regions,
6574
+ frameWidth: result.frameWidth,
6575
+ frameHeight: result.frameHeight,
6576
+ zoneUnion: this.packageZoneUnionByDevice.get(deviceId)
6577
+ }));
6315
6578
  if (detected !== (this.lastAnalyzerDetected.get(deviceId) ?? false)) {
6316
6579
  this.lastAnalyzerDetected.set(deviceId, detected);
6317
6580
  if (this.ctx.eventBus) this.ctx.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, {
@@ -1,12 +1,11 @@
1
- const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-CgJ8MRAy.js");
1
+ const require_dist = require("../dist-CZLjObZZ.js");
3
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
- const require_addon_utils = require("../addon-utils-CQs-AjTJ.js");
3
+ const require_addon_utils = require("../addon-utils-CTpQfjSR.js");
5
4
  let node_crypto = require("node:crypto");
6
5
  let node_child_process = require("node:child_process");
7
- let node_fs = require("node:fs");
8
6
  let node_path = require("node:path");
9
- node_path = require_chunk.__toESM(node_path);
7
+ node_path = require_dist.__toESM(node_path);
8
+ let node_fs = require("node:fs");
10
9
  let node_fs_promises = require("node:fs/promises");
11
10
  //#region src/recorder/event-capture.ts
12
11
  function markerFromMotion(data, pad) {
@@ -2251,7 +2250,7 @@ var DEFAULT_DEVICE_CONFIG = {
2251
2250
  enabled: false,
2252
2251
  bands: []
2253
2252
  };
2254
- function serialize(map) {
2253
+ function serialize$1(map) {
2255
2254
  const out = {};
2256
2255
  for (const [id, config] of map) out[String(id)] = config;
2257
2256
  return out;
@@ -2306,7 +2305,7 @@ async function saveDeviceConfig(state, deviceId, config) {
2306
2305
  };
2307
2306
  const map = await readDeviceConfigs(state);
2308
2307
  map.set(deviceId, validated);
2309
- await state.set(serialize(map));
2308
+ await state.set(serialize$1(map));
2310
2309
  return validated;
2311
2310
  }
2312
2311
  /**
@@ -2644,6 +2643,33 @@ function buildExportArgs(input) {
2644
2643
  var RECORDING_EXPORTS_KEY = "recordingExports";
2645
2644
  /** Persisted shape: export id → full ExportRecord. */
2646
2645
  var RecordingExportsBlobSchema = require_dist.record(require_dist.string(), require_dist.ExportRecordSchema);
2646
+ /**
2647
+ * One in-flight mutation chain per durable handle.
2648
+ *
2649
+ * EVERY mutation here is a read-modify-write of the SAME blob, and both halves
2650
+ * are async — so two overlapping cycles read the same snapshot and the second
2651
+ * `set` discards whatever the first one added. Last writer wins, silently.
2652
+ *
2653
+ * That is not a hypothetical. The engine patches `progressPct` roughly once a
2654
+ * second for an in-flight render, unawaited; on 2026-08-14 a `createExport` for
2655
+ * camera 615 landed between one such patch's read and its write, the row was
2656
+ * erased, and five seconds later the poll answered `unknown export
2657
+ * "547ea182-…"`. The camera's whole night was lost to it — the scheduler read a
2658
+ * vanished row as a failed render and re-queued, forever.
2659
+ *
2660
+ * Serialising per handle is the smallest fix that makes the loss impossible:
2661
+ * the cycles queue instead of interleaving, and the queue is per state object
2662
+ * so two recorders in one process never contend. A `WeakMap` keeps a disposed
2663
+ * handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
2664
+ * atomic, so a reader always sees one consistent generation.
2665
+ */
2666
+ var mutationChains = /* @__PURE__ */ new WeakMap();
2667
+ /** Run `work` after every mutation already queued for `state`, never before. */
2668
+ async function serialize(state, work) {
2669
+ const next = (mutationChains.get(state) ?? Promise.resolve()).then(work, work);
2670
+ mutationChains.set(state, next.catch(() => void 0));
2671
+ return next;
2672
+ }
2647
2673
  /** Read every persisted export row, keyed by export id. */
2648
2674
  async function readExports(state) {
2649
2675
  const blob = await state.get();
@@ -2652,26 +2678,30 @@ async function readExports(state) {
2652
2678
  /** Insert or replace one export row. Validates at the boundary. Returns it. */
2653
2679
  async function upsertExport(state, record) {
2654
2680
  const validated = require_dist.ExportRecordSchema.parse(record);
2655
- const map = await readExports(state);
2656
- map.set(validated.id, validated);
2657
- await state.set(Object.fromEntries(map));
2658
- return validated;
2681
+ return serialize(state, async () => {
2682
+ const map = await readExports(state);
2683
+ map.set(validated.id, validated);
2684
+ await state.set(Object.fromEntries(map));
2685
+ return validated;
2686
+ });
2659
2687
  }
2660
2688
  /**
2661
2689
  * Shallow-merge a patch into one export row and persist. Returns the merged
2662
2690
  * record, or null when the id is unknown.
2663
2691
  */
2664
2692
  async function patchExport(state, id, patch) {
2665
- const map = await readExports(state);
2666
- const existing = map.get(id);
2667
- if (!existing) return null;
2668
- const merged = require_dist.ExportRecordSchema.parse({
2669
- ...existing,
2670
- ...patch
2693
+ return serialize(state, async () => {
2694
+ const map = await readExports(state);
2695
+ const existing = map.get(id);
2696
+ if (!existing) return null;
2697
+ const merged = require_dist.ExportRecordSchema.parse({
2698
+ ...existing,
2699
+ ...patch
2700
+ });
2701
+ map.set(id, merged);
2702
+ await state.set(Object.fromEntries(map));
2703
+ return merged;
2671
2704
  });
2672
- map.set(id, merged);
2673
- await state.set(Object.fromEntries(map));
2674
- return merged;
2675
2705
  }
2676
2706
  /** Read one export row by id, or null. */
2677
2707
  async function getExportRecord(state, id) {
@@ -3002,6 +3032,80 @@ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
3002
3032
  }, graceMs);
3003
3033
  }
3004
3034
  //#endregion
3035
+ //#region src/recorder/addon/export-reconcile.ts
3036
+ /**
3037
+ * States that can only mean "a render was owed and the process went away".
3038
+ *
3039
+ * There is no live render at reconcile time by construction: the engine is
3040
+ * built fresh with an empty queue, and this runs before anything is enqueued.
3041
+ */
3042
+ function isInterrupted(record) {
3043
+ return record.state === "queued" || record.state === "rendering";
3044
+ }
3045
+ /**
3046
+ * Hand every interrupted row back to the engine, once, at boot.
3047
+ *
3048
+ * A row past its `expiresAt` is FAILED rather than re-rendered: hours of
3049
+ * footage re-encoded for a file the janitor is about to drop costs a recording
3050
+ * node an ffmpeg for nothing. Failing it is also what makes the outcome
3051
+ * visible — a row that silently stays `queued` forever is the defect this
3052
+ * module exists to remove, and swapping it for a quieter one would not be a
3053
+ * fix.
3054
+ */
3055
+ async function reconcileInterruptedExports(deps) {
3056
+ const now = deps.now();
3057
+ const interrupted = (await listExportsFor(deps.state)).filter(isInterrupted);
3058
+ if (interrupted.length === 0) return {
3059
+ requeued: 0,
3060
+ expired: 0
3061
+ };
3062
+ let requeued = 0;
3063
+ let expired = 0;
3064
+ for (const row of interrupted) {
3065
+ const tags = { deviceId: row.deviceId };
3066
+ const meta = {
3067
+ exportId: row.id,
3068
+ wasState: row.state,
3069
+ fromMs: row.fromMs,
3070
+ toMs: row.toMs,
3071
+ createdAt: row.createdAt
3072
+ };
3073
+ if (row.expiresAt <= now) {
3074
+ expired += 1;
3075
+ await patchExport(deps.state, row.id, {
3076
+ state: "failed",
3077
+ progressPct: null,
3078
+ error: "interrupted by a restart and expired before it could be rendered"
3079
+ });
3080
+ deps.logger.warn("export abandoned: interrupted by a restart, and already expired", {
3081
+ tags,
3082
+ meta
3083
+ });
3084
+ continue;
3085
+ }
3086
+ requeued += 1;
3087
+ await patchExport(deps.state, row.id, {
3088
+ state: "queued",
3089
+ progressPct: null,
3090
+ error: null
3091
+ });
3092
+ deps.logger.warn("export re-queued: it was interrupted by a restart", {
3093
+ tags,
3094
+ meta
3095
+ });
3096
+ deps.engine.enqueue(row.id);
3097
+ }
3098
+ deps.logger.info("recording exports reconciled after restart", { meta: {
3099
+ interrupted: interrupted.length,
3100
+ requeued,
3101
+ expired
3102
+ } });
3103
+ return {
3104
+ requeued,
3105
+ expired
3106
+ };
3107
+ }
3108
+ //#endregion
3005
3109
  //#region src/recorder/playlist.ts
3006
3110
  /** A VOD variant playlist with an absolute wall-clock anchor per segment. */
3007
3111
  function buildVariantPlaylist(segments) {
@@ -7479,23 +7583,25 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7479
7583
  this.retentionSweeper?.start();
7480
7584
  }
7481
7585
  /**
7482
- * Resume interrupted exports and arm the expiry sweep. A row left `rendering`
7483
- * by a crash/restart has no live ffmpeg, so demote it to `queued` and re-enqueue
7484
- * it alongside any still-`queued` rows. The sweep runs immediately then on an
7485
- * interval; both are best-effort (a failure never blocks boot).
7586
+ * Resume interrupted exports and arm the expiry sweep.
7587
+ *
7588
+ * The resume itself lives in `export-reconcile.ts` pure, tested, and loud
7589
+ * about every row it moves. It used to be an untested loop inlined here that
7590
+ * said nothing at all, so "why is this export still queued an hour later" had
7591
+ * no answer in the log, and a row whose life had already run out was
7592
+ * re-rendered anyway. Both halves are best-effort: a failure never blocks
7593
+ * boot.
7486
7594
  */
7487
7595
  async bootstrapExports() {
7488
7596
  const engine = this.exportEngine;
7489
7597
  if (!engine) return;
7490
- const state = this.exportsStore();
7491
7598
  try {
7492
- for (const rec of await listExportsFor(state)) {
7493
- if (rec.state === "rendering") await patchExport(state, rec.id, {
7494
- state: "queued",
7495
- progressPct: null
7496
- });
7497
- if (rec.state === "queued" || rec.state === "rendering") engine.enqueue(rec.id);
7498
- }
7599
+ await reconcileInterruptedExports({
7600
+ state: this.exportsStore(),
7601
+ engine,
7602
+ now: () => Date.now(),
7603
+ logger: this.ctx.logger.child("export")
7604
+ });
7499
7605
  } catch (err) {
7500
7606
  this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: require_dist.errMsg(err) } });
7501
7607
  }