@camstack/addon-pipeline 1.2.73 → 1.2.75

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-a_36HH_N.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-BAbvf81B.js → dist-6ZJCV0jY.js} +1901 -28
  7. package/dist/{dist-Dpr92hOt.mjs → dist-T_8jMw7d.mjs} +1866 -28
  8. package/dist/{event-loop-stall-monitor-BhKoybot.js → event-loop-stall-monitor-Cjof8vA1.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-B7Wd2jo-.mjs → event-loop-stall-monitor-XeGmL4xr.mjs} +1 -1
  10. package/dist/lazy-sharp-6oymT_yf.mjs +341 -0
  11. package/dist/lazy-sharp-DvwPafeP.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 +109 -21
  15. package/dist/pipeline-runner/index.mjs +107 -18
  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-Cvh5La_s.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-H2OTmgR6.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-Ci7epN77.mjs → hostInit-4XDN_Nt0.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-BFq9AIDa.js → worker-protocol-B0cbTXyw.js} +1 -1
  29. package/dist/{worker-protocol-C1FtUWvH.mjs → worker-protocol-SqED7Y9i.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-Dpr92hOt.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-B7Wd2jo-.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-C1FtUWvH.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-T_8jMw7d.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-XeGmL4xr.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-SqED7Y9i.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";
@@ -3828,6 +3828,18 @@ function isUsable(bbox) {
3828
3828
  }
3829
3829
  //#endregion
3830
3830
  //#region src/pipeline-runner/index.ts
3831
+ /**
3832
+ * Bound on the frames whose cutter is bound but whose result has not come back.
3833
+ * A few per camera in flight; the ceiling only catches results that never
3834
+ * arrive (shed, inference timeout, detach mid-flight).
3835
+ */
3836
+ var MAX_BOUND_FRAME_CUTTERS = 256;
3837
+ /** How often a camera reports its undispatched cuts. */
3838
+ var TILE_CUT_DROP_LOG_INTERVAL_MS = 6e4;
3839
+ /** The per-frame binding key — the same identity the retention handle carries. */
3840
+ function frameCutterKey(handle) {
3841
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
3842
+ }
3831
3843
  var DEFAULT_CONFIG = {
3832
3844
  maxQueueDepth: 30,
3833
3845
  maxConcurrentInferences: 16,
@@ -4143,6 +4155,20 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4143
4155
  */
4144
4156
  nativeCropRegistry = new NativeCropRegistry();
4145
4157
  /**
4158
+ * Per-frame cutter bindings — handle key → the requester that can cut this
4159
+ * frame's subject tiles and end its hold.
4160
+ *
4161
+ * Deliberately NOT `nativeCropRegistry`. That one is a node-wide LRU capped
4162
+ * at 256 and shared by every camera to serve LATE cross-process crops; on the
4163
+ * live hub it sits pinned at its cap, so an entry's lifetime depends on how
4164
+ * busy the OTHER cameras are. Ending a frame's hold is a per-frame lifecycle
4165
+ * event and cannot be allowed to depend on that.
4166
+ */
4167
+ frameCutters = /* @__PURE__ */ new Map();
4168
+ /** Per-camera tally of cuts that could not be dispatched, and why. */
4169
+ tileCutDrops = /* @__PURE__ */ new Map();
4170
+ tileCutDropLoggedAt = /* @__PURE__ */ new Map();
4171
+ /**
4146
4172
  * Node-level RAM store of retained rgb detection frames, keyed by the
4147
4173
  * `FrameHandle` that rides the inference-result event (shm-ring removal
4148
4174
  * 2026-07-16 — replaces the `csr.` shm retention ring). Backs the
@@ -5150,23 +5176,80 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5150
5176
  * by an encode.
5151
5177
  */
5152
5178
  cutSubjectTiles(deviceId, result, handle) {
5153
- if (!handle) return;
5154
- const found = this.nativeCropRegistry.get(handle);
5155
- if (!found) return;
5179
+ const outcome = this.dispatchSubjectTiles(result, handle);
5180
+ if (outcome === "sent") return;
5181
+ const tally = this.tileCutDrops.get(deviceId) ?? {
5182
+ handleless: 0,
5183
+ unbound: 0,
5184
+ threw: 0
5185
+ };
5186
+ tally[outcome] += 1;
5187
+ this.tileCutDrops.set(deviceId, tally);
5188
+ const now = Date.now();
5189
+ if (now - (this.tileCutDropLoggedAt.get(deviceId) ?? 0) < TILE_CUT_DROP_LOG_INTERVAL_MS) return;
5190
+ this.tileCutDropLoggedAt.set(deviceId, now);
5191
+ this.ctx.logger.warn("subject tile cut NOT dispatched — the frame hold will not be released", {
5192
+ tags: { deviceId },
5193
+ meta: {
5194
+ ...tally,
5195
+ lastReason: outcome,
5196
+ boundFrames: this.frameCutters.size
5197
+ }
5198
+ });
5199
+ }
5200
+ /**
5201
+ * Send the cut for one finished frame. Returns what happened, so the caller
5202
+ * can account for it.
5203
+ *
5204
+ * The cutter is looked up in {@link frameCutters} — the addon's OWN per-frame
5205
+ * binding — and NOT in `nativeCropRegistry`. That distinction is the bug this
5206
+ * method exists to not have again: the registry is a node-wide LRU capped at
5207
+ * 256 entries, shared by every camera, and it exists to serve LATE
5208
+ * cross-process crops. Measured on the live hub during the incident it sits
5209
+ * PINNED at `registrySize=256 registryCap=256`, so an entry's lifetime is a
5210
+ * function of how many other cameras are busy. Ending a frame's hold is a
5211
+ * per-frame lifecycle event and must never be subject to another camera's
5212
+ * eviction pressure.
5213
+ */
5214
+ dispatchSubjectTiles(result, handle) {
5215
+ if (!handle) return "handleless";
5216
+ const key = frameCutterKey(handle);
5217
+ const cutter = this.frameCutters.get(key);
5218
+ this.frameCutters.delete(key);
5219
+ if (!cutter) return "unbound";
5156
5220
  const boxes = planTileCuts(result.detections ?? [], {
5157
5221
  width: result.width,
5158
5222
  height: result.height
5159
5223
  });
5160
5224
  try {
5161
- found.request.cutTiles(boxes);
5162
- } catch (err) {
5163
- this.ctx.logger.debug("subject tile cut could not be dispatched", {
5164
- tags: { deviceId },
5165
- meta: {
5166
- subjects: boxes.length,
5167
- error: err instanceof Error ? err.message : String(err)
5168
- }
5169
- });
5225
+ cutter.cutTiles(boxes);
5226
+ return "sent";
5227
+ } catch {
5228
+ return "threw";
5229
+ }
5230
+ }
5231
+ /**
5232
+ * Bind a frame's cutter at DELIVERY, so its result can end its hold.
5233
+ *
5234
+ * Bounded by {@link MAX_BOUND_FRAME_CUTTERS}: a frame whose result never
5235
+ * comes back (a shed, a timed-out inference, a camera detaching mid-flight)
5236
+ * would otherwise leave its binding forever. The oldest is dropped and its
5237
+ * hold released directly — the worker's own frame bound would have reclaimed
5238
+ * it anyway, but releasing here is what keeps `holdOverflow` meaning "the
5239
+ * runner stopped answering" rather than "the runner forgot".
5240
+ */
5241
+ bindFrameCutter(handle, cutter) {
5242
+ const key = frameCutterKey(handle);
5243
+ this.frameCutters.delete(key);
5244
+ this.frameCutters.set(key, cutter);
5245
+ while (this.frameCutters.size > MAX_BOUND_FRAME_CUTTERS) {
5246
+ const oldest = this.frameCutters.keys().next().value;
5247
+ if (oldest === void 0) break;
5248
+ const stale = this.frameCutters.get(oldest);
5249
+ this.frameCutters.delete(oldest);
5250
+ try {
5251
+ stale?.release();
5252
+ } catch {}
5170
5253
  }
5171
5254
  }
5172
5255
  /**
@@ -5965,7 +6048,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5965
6048
  reservation = null;
5966
6049
  if (!current) return;
5967
6050
  const handle = current.detection ? retainedFrames?.write(config.deviceId, frame) ?? void 0 : void 0;
5968
- if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
6051
+ if (handle && nativeCrop) {
6052
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
6053
+ this.bindFrameCutter(handle, nativeCrop);
6054
+ }
5969
6055
  if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle, current)) nativeCrop?.markInferred();
5970
6056
  }
5971
6057
  });
@@ -6489,7 +6575,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6489
6575
  onDecodedFrame: (frame, nativeCrop) => {
6490
6576
  if (settled) return;
6491
6577
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
6492
- if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
6578
+ if (handle && nativeCrop) {
6579
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
6580
+ this.bindFrameCutter(handle, nativeCrop);
6581
+ }
6493
6582
  if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
6494
6583
  count++;
6495
6584
  if (count >= frames) {
@@ -1,12 +1,11 @@
1
- const require_chunk = require("../chunk-emK7D4bc.js");
2
- const require_dist = require("../dist-BAbvf81B.js");
1
+ const require_dist = require("../dist-6ZJCV0jY.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-a_36HH_N.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
  }
@@ -1,10 +1,10 @@
1
- import { At as string, Dt as number, J as recordingExportCapability, Mt as EventCategory, N as deriveRecordingMode, Ot as object, St as array, Z as storageEvictableCapability, _ as OpsLogEntrySchema, b as RECORDING_EXPORT_MAX_READ_BYTES, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, ft as hydrateSchema, gt as nodePin, kt as record, p as ExportRecordSchema, q as recordingCapability, st as BaseAddon, tt as errMsg, ut as DeviceType, x as RecordingConfigSchema, yt as selectAssignedProfileSlots } from "../dist-Dpr92hOt.mjs";
1
+ import { At as string, Dt as number, J as recordingExportCapability, Mt as EventCategory, N as deriveRecordingMode, Ot as object, St as array, Z as storageEvictableCapability, _ as OpsLogEntrySchema, b as RECORDING_EXPORT_MAX_READ_BYTES, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, ft as hydrateSchema, gt as nodePin, kt as record, p as ExportRecordSchema, q as recordingCapability, st as BaseAddon, tt as errMsg, ut as DeviceType, x as RecordingConfigSchema, yt as selectAssignedProfileSlots } from "../dist-T_8jMw7d.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
- import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-CZ2xo67g.mjs";
3
+ import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { spawn } from "node:child_process";
6
- import { createReadStream, promises, realpathSync } from "node:fs";
7
6
  import path from "node:path";
7
+ import { createReadStream, promises, realpathSync } from "node:fs";
8
8
  import { open } from "node:fs/promises";
9
9
  //#region src/recorder/event-capture.ts
10
10
  function markerFromMotion(data, pad) {
@@ -2249,7 +2249,7 @@ var DEFAULT_DEVICE_CONFIG = {
2249
2249
  enabled: false,
2250
2250
  bands: []
2251
2251
  };
2252
- function serialize(map) {
2252
+ function serialize$1(map) {
2253
2253
  const out = {};
2254
2254
  for (const [id, config] of map) out[String(id)] = config;
2255
2255
  return out;
@@ -2304,7 +2304,7 @@ async function saveDeviceConfig(state, deviceId, config) {
2304
2304
  };
2305
2305
  const map = await readDeviceConfigs(state);
2306
2306
  map.set(deviceId, validated);
2307
- await state.set(serialize(map));
2307
+ await state.set(serialize$1(map));
2308
2308
  return validated;
2309
2309
  }
2310
2310
  /**
@@ -2642,6 +2642,33 @@ function buildExportArgs(input) {
2642
2642
  var RECORDING_EXPORTS_KEY = "recordingExports";
2643
2643
  /** Persisted shape: export id → full ExportRecord. */
2644
2644
  var RecordingExportsBlobSchema = record(string(), ExportRecordSchema);
2645
+ /**
2646
+ * One in-flight mutation chain per durable handle.
2647
+ *
2648
+ * EVERY mutation here is a read-modify-write of the SAME blob, and both halves
2649
+ * are async — so two overlapping cycles read the same snapshot and the second
2650
+ * `set` discards whatever the first one added. Last writer wins, silently.
2651
+ *
2652
+ * That is not a hypothetical. The engine patches `progressPct` roughly once a
2653
+ * second for an in-flight render, unawaited; on 2026-08-14 a `createExport` for
2654
+ * camera 615 landed between one such patch's read and its write, the row was
2655
+ * erased, and five seconds later the poll answered `unknown export
2656
+ * "547ea182-…"`. The camera's whole night was lost to it — the scheduler read a
2657
+ * vanished row as a failed render and re-queued, forever.
2658
+ *
2659
+ * Serialising per handle is the smallest fix that makes the loss impossible:
2660
+ * the cycles queue instead of interleaving, and the queue is per state object
2661
+ * so two recorders in one process never contend. A `WeakMap` keeps a disposed
2662
+ * handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
2663
+ * atomic, so a reader always sees one consistent generation.
2664
+ */
2665
+ var mutationChains = /* @__PURE__ */ new WeakMap();
2666
+ /** Run `work` after every mutation already queued for `state`, never before. */
2667
+ async function serialize(state, work) {
2668
+ const next = (mutationChains.get(state) ?? Promise.resolve()).then(work, work);
2669
+ mutationChains.set(state, next.catch(() => void 0));
2670
+ return next;
2671
+ }
2645
2672
  /** Read every persisted export row, keyed by export id. */
2646
2673
  async function readExports(state) {
2647
2674
  const blob = await state.get();
@@ -2650,26 +2677,30 @@ async function readExports(state) {
2650
2677
  /** Insert or replace one export row. Validates at the boundary. Returns it. */
2651
2678
  async function upsertExport(state, record) {
2652
2679
  const validated = ExportRecordSchema.parse(record);
2653
- const map = await readExports(state);
2654
- map.set(validated.id, validated);
2655
- await state.set(Object.fromEntries(map));
2656
- return validated;
2680
+ return serialize(state, async () => {
2681
+ const map = await readExports(state);
2682
+ map.set(validated.id, validated);
2683
+ await state.set(Object.fromEntries(map));
2684
+ return validated;
2685
+ });
2657
2686
  }
2658
2687
  /**
2659
2688
  * Shallow-merge a patch into one export row and persist. Returns the merged
2660
2689
  * record, or null when the id is unknown.
2661
2690
  */
2662
2691
  async function patchExport(state, id, patch) {
2663
- const map = await readExports(state);
2664
- const existing = map.get(id);
2665
- if (!existing) return null;
2666
- const merged = ExportRecordSchema.parse({
2667
- ...existing,
2668
- ...patch
2692
+ return serialize(state, async () => {
2693
+ const map = await readExports(state);
2694
+ const existing = map.get(id);
2695
+ if (!existing) return null;
2696
+ const merged = ExportRecordSchema.parse({
2697
+ ...existing,
2698
+ ...patch
2699
+ });
2700
+ map.set(id, merged);
2701
+ await state.set(Object.fromEntries(map));
2702
+ return merged;
2669
2703
  });
2670
- map.set(id, merged);
2671
- await state.set(Object.fromEntries(map));
2672
- return merged;
2673
2704
  }
2674
2705
  /** Read one export row by id, or null. */
2675
2706
  async function getExportRecord(state, id) {
@@ -3000,6 +3031,80 @@ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
3000
3031
  }, graceMs);
3001
3032
  }
3002
3033
  //#endregion
3034
+ //#region src/recorder/addon/export-reconcile.ts
3035
+ /**
3036
+ * States that can only mean "a render was owed and the process went away".
3037
+ *
3038
+ * There is no live render at reconcile time by construction: the engine is
3039
+ * built fresh with an empty queue, and this runs before anything is enqueued.
3040
+ */
3041
+ function isInterrupted(record) {
3042
+ return record.state === "queued" || record.state === "rendering";
3043
+ }
3044
+ /**
3045
+ * Hand every interrupted row back to the engine, once, at boot.
3046
+ *
3047
+ * A row past its `expiresAt` is FAILED rather than re-rendered: hours of
3048
+ * footage re-encoded for a file the janitor is about to drop costs a recording
3049
+ * node an ffmpeg for nothing. Failing it is also what makes the outcome
3050
+ * visible — a row that silently stays `queued` forever is the defect this
3051
+ * module exists to remove, and swapping it for a quieter one would not be a
3052
+ * fix.
3053
+ */
3054
+ async function reconcileInterruptedExports(deps) {
3055
+ const now = deps.now();
3056
+ const interrupted = (await listExportsFor(deps.state)).filter(isInterrupted);
3057
+ if (interrupted.length === 0) return {
3058
+ requeued: 0,
3059
+ expired: 0
3060
+ };
3061
+ let requeued = 0;
3062
+ let expired = 0;
3063
+ for (const row of interrupted) {
3064
+ const tags = { deviceId: row.deviceId };
3065
+ const meta = {
3066
+ exportId: row.id,
3067
+ wasState: row.state,
3068
+ fromMs: row.fromMs,
3069
+ toMs: row.toMs,
3070
+ createdAt: row.createdAt
3071
+ };
3072
+ if (row.expiresAt <= now) {
3073
+ expired += 1;
3074
+ await patchExport(deps.state, row.id, {
3075
+ state: "failed",
3076
+ progressPct: null,
3077
+ error: "interrupted by a restart and expired before it could be rendered"
3078
+ });
3079
+ deps.logger.warn("export abandoned: interrupted by a restart, and already expired", {
3080
+ tags,
3081
+ meta
3082
+ });
3083
+ continue;
3084
+ }
3085
+ requeued += 1;
3086
+ await patchExport(deps.state, row.id, {
3087
+ state: "queued",
3088
+ progressPct: null,
3089
+ error: null
3090
+ });
3091
+ deps.logger.warn("export re-queued: it was interrupted by a restart", {
3092
+ tags,
3093
+ meta
3094
+ });
3095
+ deps.engine.enqueue(row.id);
3096
+ }
3097
+ deps.logger.info("recording exports reconciled after restart", { meta: {
3098
+ interrupted: interrupted.length,
3099
+ requeued,
3100
+ expired
3101
+ } });
3102
+ return {
3103
+ requeued,
3104
+ expired
3105
+ };
3106
+ }
3107
+ //#endregion
3003
3108
  //#region src/recorder/playlist.ts
3004
3109
  /** A VOD variant playlist with an absolute wall-clock anchor per segment. */
3005
3110
  function buildVariantPlaylist(segments) {
@@ -7477,23 +7582,25 @@ var RecorderV2Addon = class extends BaseAddon {
7477
7582
  this.retentionSweeper?.start();
7478
7583
  }
7479
7584
  /**
7480
- * Resume interrupted exports and arm the expiry sweep. A row left `rendering`
7481
- * by a crash/restart has no live ffmpeg, so demote it to `queued` and re-enqueue
7482
- * it alongside any still-`queued` rows. The sweep runs immediately then on an
7483
- * interval; both are best-effort (a failure never blocks boot).
7585
+ * Resume interrupted exports and arm the expiry sweep.
7586
+ *
7587
+ * The resume itself lives in `export-reconcile.ts` pure, tested, and loud
7588
+ * about every row it moves. It used to be an untested loop inlined here that
7589
+ * said nothing at all, so "why is this export still queued an hour later" had
7590
+ * no answer in the log, and a row whose life had already run out was
7591
+ * re-rendered anyway. Both halves are best-effort: a failure never blocks
7592
+ * boot.
7484
7593
  */
7485
7594
  async bootstrapExports() {
7486
7595
  const engine = this.exportEngine;
7487
7596
  if (!engine) return;
7488
- const state = this.exportsStore();
7489
7597
  try {
7490
- for (const rec of await listExportsFor(state)) {
7491
- if (rec.state === "rendering") await patchExport(state, rec.id, {
7492
- state: "queued",
7493
- progressPct: null
7494
- });
7495
- if (rec.state === "queued" || rec.state === "rendering") engine.enqueue(rec.id);
7496
- }
7598
+ await reconcileInterruptedExports({
7599
+ state: this.exportsStore(),
7600
+ engine,
7601
+ now: () => Date.now(),
7602
+ logger: this.ctx.logger.child("export")
7603
+ });
7497
7604
  } catch (err) {
7498
7605
  this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: errMsg(err) } });
7499
7606
  }