@camstack/addon-pipeline 1.2.97 → 1.2.98

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.
@@ -5575,13 +5575,20 @@ async function probeLocationVolume(location, rememberedVolumeId, deps) {
5575
5575
  }
5576
5576
  /**
5577
5577
  * Where to write instead when a location fails its volume probe: the normal
5578
- * placement chain over every OTHER location. With N locations this is a
5579
- * sibling of the same type; with one it is the unusable location itself —
5580
- * a shouting recorder beats a stopped camera, and refusing to record is the
5581
- * one outcome no operator asked for.
5582
- */
5583
- function failoverForUnusableLocation(unusable, locations, profile) {
5584
- const siblings = locations.filter((l) => l.id !== unusable.id);
5578
+ * placement chain over every location NOT already known unusable. With N
5579
+ * locations this is a healthy sibling of the same type; with none left it is
5580
+ * the unusable location itself — a shouting recorder beats a stopped camera,
5581
+ * and refusing to record is the one outcome no operator asked for.
5582
+ *
5583
+ * `alreadyUnusable` is not an optimisation. Without it the search happily
5584
+ * returned a sibling that had ALSO failed its probe, so with two broken disks
5585
+ * every attach handed back the other one and the stored assignment flipped
5586
+ * A→B→A→B forever — one durable write and one audit row per attach, per camera,
5587
+ * per profile. Observed live on 2026-08-19 (the UD disk did not come back after
5588
+ * a reboot): 716 EACCES/min, with the ops-log ring flooded by the flapping.
5589
+ */
5590
+ function failoverForUnusableLocation(unusable, locations, profile, alreadyUnusable = /* @__PURE__ */ new Set()) {
5591
+ const siblings = locations.filter((l) => l.id !== unusable.id && !alreadyUnusable.has(l.id));
5585
5592
  try {
5586
5593
  return resolvePlacement(siblings, profile);
5587
5594
  } catch {
@@ -5632,6 +5639,13 @@ var PlacementService = class {
5632
5639
  verified = /* @__PURE__ */ new Set();
5633
5640
  /** Locations the probe declared unusable — re-probed after a change. */
5634
5641
  unusable = /* @__PURE__ */ new Set();
5642
+ /** Cameras that have already announced "every location is unusable". One line
5643
+ * per camera per episode: the fault is asked per-camera, and repeating it per
5644
+ * attach is the noise this state exists to end. */
5645
+ allUnusableReported = /* @__PURE__ */ new Set();
5646
+ /** When the current all-unusable episode last (re-)probed. `null` ⇒ no
5647
+ * episode in progress. */
5648
+ allUnusableProbedAt = null;
5635
5649
  /** The fair tie-break the PURE planner defers to (D37). Advisory state, and
5636
5650
  * consulted only for a tie the capacity signal cannot separate. */
5637
5651
  rotation = new PlacementRotation();
@@ -5641,6 +5655,12 @@ var PlacementService = class {
5641
5655
  get probeDeps() {
5642
5656
  return this.deps.probe ?? fsVolumeProbeDeps;
5643
5657
  }
5658
+ get now() {
5659
+ return this.deps.now?.() ?? Date.now();
5660
+ }
5661
+ get reprobeMs() {
5662
+ return this.deps.reprobeMs ?? 6e4;
5663
+ }
5644
5664
  /**
5645
5665
  * Forget every probe verdict. Called whenever the location set is re-resolved
5646
5666
  * (rescan, storage migration) — a remount is exactly the event the probe
@@ -5649,6 +5669,8 @@ var PlacementService = class {
5649
5669
  onLocationsChanged() {
5650
5670
  this.verified.clear();
5651
5671
  this.unusable.clear();
5672
+ this.allUnusableReported.clear();
5673
+ this.allUnusableProbedAt = null;
5652
5674
  }
5653
5675
  /**
5654
5676
  * Resolve the location for one (camera, profile) at an attach boundary.
@@ -5657,12 +5679,92 @@ var PlacementService = class {
5657
5679
  */
5658
5680
  async place(deviceId, profile) {
5659
5681
  const pool = this.pool();
5682
+ this.reprobeAfterBackoff(pool);
5660
5683
  const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(pool, profile);
5661
5684
  const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
5685
+ if (this.everyLocationUnusable(pool)) {
5686
+ this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
5687
+ return resolved;
5688
+ }
5689
+ this.allUnusableReported.delete(deviceId);
5662
5690
  await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
5663
5691
  return usable;
5664
5692
  }
5665
5693
  /**
5694
+ * Has the marker probe declared this location's volume NOT the one we last
5695
+ * wrote to?
5696
+ *
5697
+ * Read by the segment-hour ledger: a walk over a root whose volume failed its
5698
+ * probe found nothing because nothing is mounted there, and that must never be
5699
+ * allowed to delete durable rows (D49). Unprobed ⇒ usable, which is honest —
5700
+ * the probe runs at the first writer attach, so at boot it has usually not run
5701
+ * at all, and the ledger's own magnitude guard covers that window.
5702
+ */
5703
+ isLocationUsable(locationId) {
5704
+ return !this.unusable.has(locationId);
5705
+ }
5706
+ /** True when the pool is non-empty and the probe has failed EVERY member. */
5707
+ everyLocationUnusable(pool) {
5708
+ return pool.length > 0 && pool.every((location) => this.unusable.has(location.id));
5709
+ }
5710
+ /**
5711
+ * Announce the terminal state — ONCE per camera per episode.
5712
+ *
5713
+ * Before this, two unusable locations produced an infinite failover
5714
+ * ping-pong: `failoverForUnusableLocation` did not consult the unusable set,
5715
+ * so each attach handed back the other broken disk, `recordDecision` saw a
5716
+ * changed assignment and wrote the store plus an audit row, and the writer
5717
+ * hit EACCES on a path that was never going to work. Measured on the live hub
5718
+ * on 2026-08-19 with the UD disk unmounted: 716 EACCES/min.
5719
+ *
5720
+ * Recording is NOT refused. With a single location this state is reached the
5721
+ * moment that one location fails, and the recorder keeps attaching to it on
5722
+ * purpose — a shouting recorder beats a stopped camera. What changes is that
5723
+ * it shouts once instead of once per attach, and writes nothing durable while
5724
+ * shouting.
5725
+ */
5726
+ reportEveryLocationUnusable(deviceId, profile, target, pool) {
5727
+ this.allUnusableProbedAt ??= this.now;
5728
+ if (this.allUnusableReported.has(deviceId)) return;
5729
+ this.allUnusableReported.add(deviceId);
5730
+ this.deps.logger.warn("recorder placement: EVERY recordings location failed its volume probe — the assignment is FROZEN on its current target and nothing more is written until a disk comes back", {
5731
+ tags: { deviceId },
5732
+ meta: {
5733
+ profile,
5734
+ targetLocationId: target.id,
5735
+ root: target.root,
5736
+ locationIds: pool.map((location) => location.id),
5737
+ reprobeMs: this.reprobeMs
5738
+ }
5739
+ });
5740
+ }
5741
+ /**
5742
+ * While every location is unusable, re-probe them all — at most once per
5743
+ * {@link ALL_UNUSABLE_REPROBE_MS}. This is the backoff: without it the probe
5744
+ * memo would hold for the life of the process (only `onLocationsChanged`
5745
+ * clears it) and a disk remounted by an operator would need an addon restart
5746
+ * to be noticed.
5747
+ */
5748
+ reprobeAfterBackoff(pool) {
5749
+ if (!this.everyLocationUnusable(pool)) {
5750
+ this.allUnusableProbedAt = null;
5751
+ return;
5752
+ }
5753
+ const now = this.now;
5754
+ if (this.allUnusableProbedAt === null) {
5755
+ this.allUnusableProbedAt = now;
5756
+ return;
5757
+ }
5758
+ if (now - this.allUnusableProbedAt < this.reprobeMs) return;
5759
+ this.allUnusableProbedAt = now;
5760
+ this.deps.logger.info("recorder placement: re-probing every storage location after backoff — a disk that came back is picked up here", { meta: {
5761
+ locationIds: pool.map((location) => location.id),
5762
+ backoffMs: this.reprobeMs
5763
+ } });
5764
+ this.unusable.clear();
5765
+ this.verified.clear();
5766
+ }
5767
+ /**
5666
5768
  * The locations placement may write to: operator-ENABLED and alias-guarded.
5667
5769
  * A location the operator has not opted in is still read, swept and drained —
5668
5770
  * it is simply never a write target. Creating a disk must not start writing
@@ -5811,13 +5913,13 @@ var PlacementService = class {
5811
5913
  * ONCE per location per location-set generation — it is a claim, not a poll.
5812
5914
  */
5813
5915
  async ensureUsable(chosen, locations, profile, deviceId) {
5814
- if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile);
5916
+ if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
5815
5917
  if (this.verified.has(chosen.id)) return chosen;
5816
5918
  const result = await probeLocationVolume(chosen, (await readPlacementState(this.deps.state)).volumeIds[chosen.id] ?? null, this.probeDeps);
5817
5919
  if (result.outcome === "mismatch") {
5818
5920
  this.unusable.add(chosen.id);
5819
5921
  this.reportMountFault(result, deviceId);
5820
- return failoverForUnusableLocation(chosen, locations, profile);
5922
+ return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
5821
5923
  }
5822
5924
  if (result.outcome === "unreadable") {
5823
5925
  this.deps.logger.warn("recorder placement: volume probe unreadable — assignment unchanged", {
@@ -8246,6 +8348,17 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
8246
8348
  name: "idx_recorder_segment_hours_hour",
8247
8349
  columns: ["hourStartMs"]
8248
8350
  }];
8351
+ /**
8352
+ * The largest share of a device's held hours a single walk may delete.
8353
+ *
8354
+ * On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
8355
+ * the UD disk had not mounted — and one walk took the ledger from 873 hours to
8356
+ * 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
8357
+ * ~400 s recursive walk, so a single bad walk taxed every boot after it. A
8358
+ * retention sweep does not delete half a camera's archive in one pass; a
8359
+ * `readdir` over an unmounted root deletes all of it.
8360
+ */
8361
+ var LEDGER_PRUNE_MAX_SHARE = .5;
8249
8362
  function hourStartMs(startMs) {
8250
8363
  return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
8251
8364
  }
@@ -8319,8 +8432,10 @@ var SPEC = {
8319
8432
  var SegmentHourLedger = class {
8320
8433
  ledger;
8321
8434
  logger;
8435
+ isLocationUsable;
8322
8436
  constructor(deps) {
8323
8437
  this.logger = deps.logger;
8438
+ this.isLocationUsable = deps.isLocationUsable ?? (() => true);
8324
8439
  this.ledger = new DurableLedger({
8325
8440
  spec: SPEC,
8326
8441
  store: deps.store,
@@ -8410,6 +8525,10 @@ var SegmentHourLedger = class {
8410
8525
  *
8411
8526
  * Devices not in `deviceIds` are left untouched (a partial walk must not
8412
8527
  * prune cameras it did not look at).
8528
+ *
8529
+ * And a walk this ledger does not TRUST prunes nothing at all — see
8530
+ * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
8531
+ * are unioned in; what it did not find decides nothing (D49).
8413
8532
  */
8414
8533
  async reconcileFromIndex(index, deviceIds, nowMs) {
8415
8534
  const walked = new Set(deviceIds);
@@ -8431,15 +8550,17 @@ var SegmentHourLedger = class {
8431
8550
  paths: [...existing.paths, seg.path]
8432
8551
  });
8433
8552
  }
8553
+ const refused = this.refusedPrunes(walked, fromIndex);
8434
8554
  for (const row of this.ledger.snapshot()) {
8435
8555
  if (!walked.has(row.deviceId)) continue;
8436
8556
  const disk = fromIndex.get(row.key);
8557
+ const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
8437
8558
  if (disk === void 0) {
8438
- if (row.hourStartMs === currentHour) continue;
8559
+ if (additiveOnly) continue;
8439
8560
  await this.ledger.forget(row.key);
8440
8561
  continue;
8441
8562
  }
8442
- if (row.hourStartMs === currentHour) {
8563
+ if (additiveOnly) {
8443
8564
  const union = uniquePaths([...disk.paths, ...row.paths]);
8444
8565
  if (!samePaths(union, row.paths)) await this.ledger.put({
8445
8566
  ...row,
@@ -8454,7 +8575,85 @@ var SegmentHourLedger = class {
8454
8575
  await this.ledger.put(disk);
8455
8576
  }
8456
8577
  }
8578
+ /**
8579
+ * Which of this walk's deletions are REFUSED, and why.
8580
+ *
8581
+ * Two independent reasons, because the live fault of 2026-08-19 would have
8582
+ * escaped either one alone:
8583
+ *
8584
+ * - **the volume said no.** `PlacementService`'s marker probe knows the bytes
8585
+ * under a root are not the bytes we wrote. A walk of such a root found
8586
+ * nothing because nothing is mounted there, not because the footage is
8587
+ * gone.
8588
+ * - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
8589
+ * the first writer attach — so on the morning the recorder respawned onto an
8590
+ * empty tmpfs the volume was not flagged and the prune went through: 873
8591
+ * hours to 32. A walk that deletes more than
8592
+ * {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
8593
+ * size alone.
8594
+ *
8595
+ * A refusal is one line per device (or per device+location), carrying the
8596
+ * delta, tagged with `deviceId` — a ledger that silently declined to prune and
8597
+ * a ledger with nothing to prune must never read the same.
8598
+ */
8599
+ refusedPrunes(walked, fromIndex) {
8600
+ const heldByDevice = /* @__PURE__ */ new Map();
8601
+ const unusable = /* @__PURE__ */ new Map();
8602
+ for (const row of this.ledger.snapshot()) {
8603
+ if (!walked.has(row.deviceId)) continue;
8604
+ heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
8605
+ if (this.isLocationUsable(row.locationId)) continue;
8606
+ const key = locationKey(row.deviceId, row.locationId);
8607
+ const seen = unusable.get(key);
8608
+ unusable.set(key, seen === void 0 ? {
8609
+ deviceId: row.deviceId,
8610
+ locationId: row.locationId,
8611
+ held: 1
8612
+ } : {
8613
+ ...seen,
8614
+ held: seen.held + 1
8615
+ });
8616
+ }
8617
+ const onDiskByDevice = /* @__PURE__ */ new Map();
8618
+ for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
8619
+ const devices = /* @__PURE__ */ new Set();
8620
+ for (const [deviceId, held] of heldByDevice) {
8621
+ if (held < 8) continue;
8622
+ const onDisk = onDiskByDevice.get(deviceId) ?? 0;
8623
+ if (onDisk >= held * .5) continue;
8624
+ devices.add(deviceId);
8625
+ this.logger.warn("recorder: segment-hour ledger prune REFUSED — this walk would delete most of the camera archive, which is what an unmounted root looks like; keeping the previous ledger", {
8626
+ tags: { deviceId },
8627
+ meta: {
8628
+ held,
8629
+ onDisk,
8630
+ dropped: held - onDisk,
8631
+ maxShare: LEDGER_PRUNE_MAX_SHARE
8632
+ }
8633
+ });
8634
+ }
8635
+ const locations = /* @__PURE__ */ new Set();
8636
+ for (const [key, entry] of unusable) {
8637
+ if (devices.has(entry.deviceId)) continue;
8638
+ locations.add(key);
8639
+ this.logger.warn("recorder: segment-hour ledger prune REFUSED — the volume under this location failed its probe, so the walk found nothing because nothing is mounted there", {
8640
+ tags: { deviceId: entry.deviceId },
8641
+ meta: {
8642
+ locationId: entry.locationId,
8643
+ held: entry.held,
8644
+ onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
8645
+ }
8646
+ });
8647
+ }
8648
+ return {
8649
+ devices,
8650
+ locations
8651
+ };
8652
+ }
8457
8653
  };
8654
+ function locationKey(deviceId, locationId) {
8655
+ return `${deviceId}|${locationId}`;
8656
+ }
8458
8657
  //#endregion
8459
8658
  //#region src/recorder/addon/recording-export-provider.ts
8460
8659
  /** Content type of every rendered export — the engine only ever writes MP4. */
@@ -8944,7 +9143,9 @@ var macrotask = () => new Promise((resolve) => {
8944
9143
  * unnoticed for six weeks precisely because nothing said anything.
8945
9144
  */
8946
9145
  async function recoverStagingOrphans(dir, input, deps) {
8947
- let names;
9146
+ const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
9147
+ const yieldBetween = deps.yieldBetween ?? macrotask;
9148
+ let names = null;
8948
9149
  try {
8949
9150
  names = await deps.listDir(dir);
8950
9151
  } catch (err) {
@@ -8952,13 +9153,14 @@ async function recoverStagingOrphans(dir, input, deps) {
8952
9153
  dir,
8953
9154
  error: err instanceof Error ? err.message : String(err)
8954
9155
  } });
8955
- return {
8956
- found: 0,
8957
- recovered: 0,
8958
- failed: 0,
8959
- aborted: false
8960
- };
8961
9156
  }
9157
+ await yieldBetween();
9158
+ if (names === null) return {
9159
+ found: 0,
9160
+ recovered: 0,
9161
+ failed: 0,
9162
+ aborted: false
9163
+ };
8962
9164
  let playlistNames;
8963
9165
  const body = await deps.readPlaylist(dir).catch(() => null);
8964
9166
  if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
@@ -8980,8 +9182,6 @@ async function recoverStagingOrphans(dir, input, deps) {
8980
9182
  oldestMs: plan[0]?.startMs,
8981
9183
  newestMs: plan[plan.length - 1]?.startMs
8982
9184
  } });
8983
- const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
8984
- const yieldBetween = deps.yieldBetween ?? macrotask;
8985
9185
  let recovered = 0;
8986
9186
  let failed = 0;
8987
9187
  let aborted = false;
@@ -9081,24 +9281,41 @@ async function recoverAllStagedOrphans(deps) {
9081
9281
  locations: locations.map((l) => l.id),
9082
9282
  bootMs: deps.bootMs
9083
9283
  } });
9084
- const work = [];
9085
- for (const location of locations) {
9086
- const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
9087
- let deviceDirs;
9284
+ /**
9285
+ * One directory listing on the walk, paced through the pass's own pacer.
9286
+ *
9287
+ * "A handful of listings" is what this walk was costed as, and on a WARM
9288
+ * share it is — 4.1 s for the whole thing. Cold, after a power-cycle, each
9289
+ * one is a ~9.4 s FUSE round-trip and 58 of them summed to 547 s with the
9290
+ * loop to themselves, while the controller re-pinned writers 118 times and
9291
+ * the first recording landed at t+714 s.
9292
+ *
9293
+ * Paced per LISTING and not per entry, because that is where the latency
9294
+ * is; and through the SAME `yieldBetween` the recovery uses, so there is one
9295
+ * pacer, one back-off state and one set of counters for the whole pass —
9296
+ * never a second scheduling authority (D167).
9297
+ */
9298
+ const listPaced = async (dir) => {
9299
+ let entries = null;
9088
9300
  try {
9089
- deviceDirs = await deps.listDir(stagingRoot);
9301
+ entries = await deps.listDir(dir);
9090
9302
  } catch {
9091
- continue;
9303
+ entries = null;
9092
9304
  }
9305
+ await yieldBetween();
9306
+ return entries;
9307
+ };
9308
+ const walkStartedMs = Date.now();
9309
+ const work = [];
9310
+ for (const location of locations) {
9311
+ const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
9312
+ const deviceDirs = await listPaced(stagingRoot);
9313
+ if (deviceDirs === null) continue;
9093
9314
  for (const deviceDir of deviceDirs) {
9094
9315
  const deviceId = Number(deviceDir);
9095
9316
  if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
9096
- let profileDirs;
9097
- try {
9098
- profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
9099
- } catch {
9100
- continue;
9101
- }
9317
+ const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
9318
+ if (profileDirs === null) continue;
9102
9319
  const segmentSeconds = await deps.segmentSecondsFor(deviceId);
9103
9320
  for (const profile of profileDirs) {
9104
9321
  if (!KNOWN_PROFILES.has(profile)) continue;
@@ -9112,6 +9329,7 @@ async function recoverAllStagedOrphans(deps) {
9112
9329
  }
9113
9330
  }
9114
9331
  }
9332
+ const walkMs = Date.now() - walkStartedMs;
9115
9333
  const queue = [...work];
9116
9334
  const lane = async () => {
9117
9335
  for (;;) {
@@ -9154,6 +9372,7 @@ async function recoverAllStagedOrphans(deps) {
9154
9372
  failed,
9155
9373
  aborted,
9156
9374
  ms: Date.now() - startedMs,
9375
+ walkMs,
9157
9376
  ...pacer === null ? {} : { pacing: pacer.stats() }
9158
9377
  } });
9159
9378
  return {
@@ -9406,7 +9625,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
9406
9625
  }
9407
9626
  this.segmentHours = new SegmentHourLedger({
9408
9627
  store: this.ctx.api.settingsStore,
9409
- logger: this.ctx.logger
9628
+ logger: this.ctx.logger,
9629
+ isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
9410
9630
  });
9411
9631
  try {
9412
9632
  await this.segmentHours.declare();
@@ -5574,13 +5574,20 @@ async function probeLocationVolume(location, rememberedVolumeId, deps) {
5574
5574
  }
5575
5575
  /**
5576
5576
  * Where to write instead when a location fails its volume probe: the normal
5577
- * placement chain over every OTHER location. With N locations this is a
5578
- * sibling of the same type; with one it is the unusable location itself —
5579
- * a shouting recorder beats a stopped camera, and refusing to record is the
5580
- * one outcome no operator asked for.
5581
- */
5582
- function failoverForUnusableLocation(unusable, locations, profile) {
5583
- const siblings = locations.filter((l) => l.id !== unusable.id);
5577
+ * placement chain over every location NOT already known unusable. With N
5578
+ * locations this is a healthy sibling of the same type; with none left it is
5579
+ * the unusable location itself — a shouting recorder beats a stopped camera,
5580
+ * and refusing to record is the one outcome no operator asked for.
5581
+ *
5582
+ * `alreadyUnusable` is not an optimisation. Without it the search happily
5583
+ * returned a sibling that had ALSO failed its probe, so with two broken disks
5584
+ * every attach handed back the other one and the stored assignment flipped
5585
+ * A→B→A→B forever — one durable write and one audit row per attach, per camera,
5586
+ * per profile. Observed live on 2026-08-19 (the UD disk did not come back after
5587
+ * a reboot): 716 EACCES/min, with the ops-log ring flooded by the flapping.
5588
+ */
5589
+ function failoverForUnusableLocation(unusable, locations, profile, alreadyUnusable = /* @__PURE__ */ new Set()) {
5590
+ const siblings = locations.filter((l) => l.id !== unusable.id && !alreadyUnusable.has(l.id));
5584
5591
  try {
5585
5592
  return resolvePlacement(siblings, profile);
5586
5593
  } catch {
@@ -5631,6 +5638,13 @@ var PlacementService = class {
5631
5638
  verified = /* @__PURE__ */ new Set();
5632
5639
  /** Locations the probe declared unusable — re-probed after a change. */
5633
5640
  unusable = /* @__PURE__ */ new Set();
5641
+ /** Cameras that have already announced "every location is unusable". One line
5642
+ * per camera per episode: the fault is asked per-camera, and repeating it per
5643
+ * attach is the noise this state exists to end. */
5644
+ allUnusableReported = /* @__PURE__ */ new Set();
5645
+ /** When the current all-unusable episode last (re-)probed. `null` ⇒ no
5646
+ * episode in progress. */
5647
+ allUnusableProbedAt = null;
5634
5648
  /** The fair tie-break the PURE planner defers to (D37). Advisory state, and
5635
5649
  * consulted only for a tie the capacity signal cannot separate. */
5636
5650
  rotation = new PlacementRotation();
@@ -5640,6 +5654,12 @@ var PlacementService = class {
5640
5654
  get probeDeps() {
5641
5655
  return this.deps.probe ?? fsVolumeProbeDeps;
5642
5656
  }
5657
+ get now() {
5658
+ return this.deps.now?.() ?? Date.now();
5659
+ }
5660
+ get reprobeMs() {
5661
+ return this.deps.reprobeMs ?? 6e4;
5662
+ }
5643
5663
  /**
5644
5664
  * Forget every probe verdict. Called whenever the location set is re-resolved
5645
5665
  * (rescan, storage migration) — a remount is exactly the event the probe
@@ -5648,6 +5668,8 @@ var PlacementService = class {
5648
5668
  onLocationsChanged() {
5649
5669
  this.verified.clear();
5650
5670
  this.unusable.clear();
5671
+ this.allUnusableReported.clear();
5672
+ this.allUnusableProbedAt = null;
5651
5673
  }
5652
5674
  /**
5653
5675
  * Resolve the location for one (camera, profile) at an attach boundary.
@@ -5656,12 +5678,92 @@ var PlacementService = class {
5656
5678
  */
5657
5679
  async place(deviceId, profile) {
5658
5680
  const pool = this.pool();
5681
+ this.reprobeAfterBackoff(pool);
5659
5682
  const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(pool, profile);
5660
5683
  const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
5684
+ if (this.everyLocationUnusable(pool)) {
5685
+ this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
5686
+ return resolved;
5687
+ }
5688
+ this.allUnusableReported.delete(deviceId);
5661
5689
  await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
5662
5690
  return usable;
5663
5691
  }
5664
5692
  /**
5693
+ * Has the marker probe declared this location's volume NOT the one we last
5694
+ * wrote to?
5695
+ *
5696
+ * Read by the segment-hour ledger: a walk over a root whose volume failed its
5697
+ * probe found nothing because nothing is mounted there, and that must never be
5698
+ * allowed to delete durable rows (D49). Unprobed ⇒ usable, which is honest —
5699
+ * the probe runs at the first writer attach, so at boot it has usually not run
5700
+ * at all, and the ledger's own magnitude guard covers that window.
5701
+ */
5702
+ isLocationUsable(locationId) {
5703
+ return !this.unusable.has(locationId);
5704
+ }
5705
+ /** True when the pool is non-empty and the probe has failed EVERY member. */
5706
+ everyLocationUnusable(pool) {
5707
+ return pool.length > 0 && pool.every((location) => this.unusable.has(location.id));
5708
+ }
5709
+ /**
5710
+ * Announce the terminal state — ONCE per camera per episode.
5711
+ *
5712
+ * Before this, two unusable locations produced an infinite failover
5713
+ * ping-pong: `failoverForUnusableLocation` did not consult the unusable set,
5714
+ * so each attach handed back the other broken disk, `recordDecision` saw a
5715
+ * changed assignment and wrote the store plus an audit row, and the writer
5716
+ * hit EACCES on a path that was never going to work. Measured on the live hub
5717
+ * on 2026-08-19 with the UD disk unmounted: 716 EACCES/min.
5718
+ *
5719
+ * Recording is NOT refused. With a single location this state is reached the
5720
+ * moment that one location fails, and the recorder keeps attaching to it on
5721
+ * purpose — a shouting recorder beats a stopped camera. What changes is that
5722
+ * it shouts once instead of once per attach, and writes nothing durable while
5723
+ * shouting.
5724
+ */
5725
+ reportEveryLocationUnusable(deviceId, profile, target, pool) {
5726
+ this.allUnusableProbedAt ??= this.now;
5727
+ if (this.allUnusableReported.has(deviceId)) return;
5728
+ this.allUnusableReported.add(deviceId);
5729
+ this.deps.logger.warn("recorder placement: EVERY recordings location failed its volume probe — the assignment is FROZEN on its current target and nothing more is written until a disk comes back", {
5730
+ tags: { deviceId },
5731
+ meta: {
5732
+ profile,
5733
+ targetLocationId: target.id,
5734
+ root: target.root,
5735
+ locationIds: pool.map((location) => location.id),
5736
+ reprobeMs: this.reprobeMs
5737
+ }
5738
+ });
5739
+ }
5740
+ /**
5741
+ * While every location is unusable, re-probe them all — at most once per
5742
+ * {@link ALL_UNUSABLE_REPROBE_MS}. This is the backoff: without it the probe
5743
+ * memo would hold for the life of the process (only `onLocationsChanged`
5744
+ * clears it) and a disk remounted by an operator would need an addon restart
5745
+ * to be noticed.
5746
+ */
5747
+ reprobeAfterBackoff(pool) {
5748
+ if (!this.everyLocationUnusable(pool)) {
5749
+ this.allUnusableProbedAt = null;
5750
+ return;
5751
+ }
5752
+ const now = this.now;
5753
+ if (this.allUnusableProbedAt === null) {
5754
+ this.allUnusableProbedAt = now;
5755
+ return;
5756
+ }
5757
+ if (now - this.allUnusableProbedAt < this.reprobeMs) return;
5758
+ this.allUnusableProbedAt = now;
5759
+ this.deps.logger.info("recorder placement: re-probing every storage location after backoff — a disk that came back is picked up here", { meta: {
5760
+ locationIds: pool.map((location) => location.id),
5761
+ backoffMs: this.reprobeMs
5762
+ } });
5763
+ this.unusable.clear();
5764
+ this.verified.clear();
5765
+ }
5766
+ /**
5665
5767
  * The locations placement may write to: operator-ENABLED and alias-guarded.
5666
5768
  * A location the operator has not opted in is still read, swept and drained —
5667
5769
  * it is simply never a write target. Creating a disk must not start writing
@@ -5810,13 +5912,13 @@ var PlacementService = class {
5810
5912
  * ONCE per location per location-set generation — it is a claim, not a poll.
5811
5913
  */
5812
5914
  async ensureUsable(chosen, locations, profile, deviceId) {
5813
- if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile);
5915
+ if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
5814
5916
  if (this.verified.has(chosen.id)) return chosen;
5815
5917
  const result = await probeLocationVolume(chosen, (await readPlacementState(this.deps.state)).volumeIds[chosen.id] ?? null, this.probeDeps);
5816
5918
  if (result.outcome === "mismatch") {
5817
5919
  this.unusable.add(chosen.id);
5818
5920
  this.reportMountFault(result, deviceId);
5819
- return failoverForUnusableLocation(chosen, locations, profile);
5921
+ return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
5820
5922
  }
5821
5923
  if (result.outcome === "unreadable") {
5822
5924
  this.deps.logger.warn("recorder placement: volume probe unreadable — assignment unchanged", {
@@ -8245,6 +8347,17 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
8245
8347
  name: "idx_recorder_segment_hours_hour",
8246
8348
  columns: ["hourStartMs"]
8247
8349
  }];
8350
+ /**
8351
+ * The largest share of a device's held hours a single walk may delete.
8352
+ *
8353
+ * On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
8354
+ * the UD disk had not mounted — and one walk took the ledger from 873 hours to
8355
+ * 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
8356
+ * ~400 s recursive walk, so a single bad walk taxed every boot after it. A
8357
+ * retention sweep does not delete half a camera's archive in one pass; a
8358
+ * `readdir` over an unmounted root deletes all of it.
8359
+ */
8360
+ var LEDGER_PRUNE_MAX_SHARE = .5;
8248
8361
  function hourStartMs(startMs) {
8249
8362
  return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
8250
8363
  }
@@ -8318,8 +8431,10 @@ var SPEC = {
8318
8431
  var SegmentHourLedger = class {
8319
8432
  ledger;
8320
8433
  logger;
8434
+ isLocationUsable;
8321
8435
  constructor(deps) {
8322
8436
  this.logger = deps.logger;
8437
+ this.isLocationUsable = deps.isLocationUsable ?? (() => true);
8323
8438
  this.ledger = new DurableLedger({
8324
8439
  spec: SPEC,
8325
8440
  store: deps.store,
@@ -8409,6 +8524,10 @@ var SegmentHourLedger = class {
8409
8524
  *
8410
8525
  * Devices not in `deviceIds` are left untouched (a partial walk must not
8411
8526
  * prune cameras it did not look at).
8527
+ *
8528
+ * And a walk this ledger does not TRUST prunes nothing at all — see
8529
+ * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
8530
+ * are unioned in; what it did not find decides nothing (D49).
8412
8531
  */
8413
8532
  async reconcileFromIndex(index, deviceIds, nowMs) {
8414
8533
  const walked = new Set(deviceIds);
@@ -8430,15 +8549,17 @@ var SegmentHourLedger = class {
8430
8549
  paths: [...existing.paths, seg.path]
8431
8550
  });
8432
8551
  }
8552
+ const refused = this.refusedPrunes(walked, fromIndex);
8433
8553
  for (const row of this.ledger.snapshot()) {
8434
8554
  if (!walked.has(row.deviceId)) continue;
8435
8555
  const disk = fromIndex.get(row.key);
8556
+ const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
8436
8557
  if (disk === void 0) {
8437
- if (row.hourStartMs === currentHour) continue;
8558
+ if (additiveOnly) continue;
8438
8559
  await this.ledger.forget(row.key);
8439
8560
  continue;
8440
8561
  }
8441
- if (row.hourStartMs === currentHour) {
8562
+ if (additiveOnly) {
8442
8563
  const union = uniquePaths([...disk.paths, ...row.paths]);
8443
8564
  if (!samePaths(union, row.paths)) await this.ledger.put({
8444
8565
  ...row,
@@ -8453,7 +8574,85 @@ var SegmentHourLedger = class {
8453
8574
  await this.ledger.put(disk);
8454
8575
  }
8455
8576
  }
8577
+ /**
8578
+ * Which of this walk's deletions are REFUSED, and why.
8579
+ *
8580
+ * Two independent reasons, because the live fault of 2026-08-19 would have
8581
+ * escaped either one alone:
8582
+ *
8583
+ * - **the volume said no.** `PlacementService`'s marker probe knows the bytes
8584
+ * under a root are not the bytes we wrote. A walk of such a root found
8585
+ * nothing because nothing is mounted there, not because the footage is
8586
+ * gone.
8587
+ * - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
8588
+ * the first writer attach — so on the morning the recorder respawned onto an
8589
+ * empty tmpfs the volume was not flagged and the prune went through: 873
8590
+ * hours to 32. A walk that deletes more than
8591
+ * {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
8592
+ * size alone.
8593
+ *
8594
+ * A refusal is one line per device (or per device+location), carrying the
8595
+ * delta, tagged with `deviceId` — a ledger that silently declined to prune and
8596
+ * a ledger with nothing to prune must never read the same.
8597
+ */
8598
+ refusedPrunes(walked, fromIndex) {
8599
+ const heldByDevice = /* @__PURE__ */ new Map();
8600
+ const unusable = /* @__PURE__ */ new Map();
8601
+ for (const row of this.ledger.snapshot()) {
8602
+ if (!walked.has(row.deviceId)) continue;
8603
+ heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
8604
+ if (this.isLocationUsable(row.locationId)) continue;
8605
+ const key = locationKey(row.deviceId, row.locationId);
8606
+ const seen = unusable.get(key);
8607
+ unusable.set(key, seen === void 0 ? {
8608
+ deviceId: row.deviceId,
8609
+ locationId: row.locationId,
8610
+ held: 1
8611
+ } : {
8612
+ ...seen,
8613
+ held: seen.held + 1
8614
+ });
8615
+ }
8616
+ const onDiskByDevice = /* @__PURE__ */ new Map();
8617
+ for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
8618
+ const devices = /* @__PURE__ */ new Set();
8619
+ for (const [deviceId, held] of heldByDevice) {
8620
+ if (held < 8) continue;
8621
+ const onDisk = onDiskByDevice.get(deviceId) ?? 0;
8622
+ if (onDisk >= held * .5) continue;
8623
+ devices.add(deviceId);
8624
+ this.logger.warn("recorder: segment-hour ledger prune REFUSED — this walk would delete most of the camera archive, which is what an unmounted root looks like; keeping the previous ledger", {
8625
+ tags: { deviceId },
8626
+ meta: {
8627
+ held,
8628
+ onDisk,
8629
+ dropped: held - onDisk,
8630
+ maxShare: LEDGER_PRUNE_MAX_SHARE
8631
+ }
8632
+ });
8633
+ }
8634
+ const locations = /* @__PURE__ */ new Set();
8635
+ for (const [key, entry] of unusable) {
8636
+ if (devices.has(entry.deviceId)) continue;
8637
+ locations.add(key);
8638
+ this.logger.warn("recorder: segment-hour ledger prune REFUSED — the volume under this location failed its probe, so the walk found nothing because nothing is mounted there", {
8639
+ tags: { deviceId: entry.deviceId },
8640
+ meta: {
8641
+ locationId: entry.locationId,
8642
+ held: entry.held,
8643
+ onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
8644
+ }
8645
+ });
8646
+ }
8647
+ return {
8648
+ devices,
8649
+ locations
8650
+ };
8651
+ }
8456
8652
  };
8653
+ function locationKey(deviceId, locationId) {
8654
+ return `${deviceId}|${locationId}`;
8655
+ }
8457
8656
  //#endregion
8458
8657
  //#region src/recorder/addon/recording-export-provider.ts
8459
8658
  /** Content type of every rendered export — the engine only ever writes MP4. */
@@ -8943,7 +9142,9 @@ var macrotask = () => new Promise((resolve) => {
8943
9142
  * unnoticed for six weeks precisely because nothing said anything.
8944
9143
  */
8945
9144
  async function recoverStagingOrphans(dir, input, deps) {
8946
- let names;
9145
+ const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
9146
+ const yieldBetween = deps.yieldBetween ?? macrotask;
9147
+ let names = null;
8947
9148
  try {
8948
9149
  names = await deps.listDir(dir);
8949
9150
  } catch (err) {
@@ -8951,13 +9152,14 @@ async function recoverStagingOrphans(dir, input, deps) {
8951
9152
  dir,
8952
9153
  error: err instanceof Error ? err.message : String(err)
8953
9154
  } });
8954
- return {
8955
- found: 0,
8956
- recovered: 0,
8957
- failed: 0,
8958
- aborted: false
8959
- };
8960
9155
  }
9156
+ await yieldBetween();
9157
+ if (names === null) return {
9158
+ found: 0,
9159
+ recovered: 0,
9160
+ failed: 0,
9161
+ aborted: false
9162
+ };
8961
9163
  let playlistNames;
8962
9164
  const body = await deps.readPlaylist(dir).catch(() => null);
8963
9165
  if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
@@ -8979,8 +9181,6 @@ async function recoverStagingOrphans(dir, input, deps) {
8979
9181
  oldestMs: plan[0]?.startMs,
8980
9182
  newestMs: plan[plan.length - 1]?.startMs
8981
9183
  } });
8982
- const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
8983
- const yieldBetween = deps.yieldBetween ?? macrotask;
8984
9184
  let recovered = 0;
8985
9185
  let failed = 0;
8986
9186
  let aborted = false;
@@ -9080,24 +9280,41 @@ async function recoverAllStagedOrphans(deps) {
9080
9280
  locations: locations.map((l) => l.id),
9081
9281
  bootMs: deps.bootMs
9082
9282
  } });
9083
- const work = [];
9084
- for (const location of locations) {
9085
- const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
9086
- let deviceDirs;
9283
+ /**
9284
+ * One directory listing on the walk, paced through the pass's own pacer.
9285
+ *
9286
+ * "A handful of listings" is what this walk was costed as, and on a WARM
9287
+ * share it is — 4.1 s for the whole thing. Cold, after a power-cycle, each
9288
+ * one is a ~9.4 s FUSE round-trip and 58 of them summed to 547 s with the
9289
+ * loop to themselves, while the controller re-pinned writers 118 times and
9290
+ * the first recording landed at t+714 s.
9291
+ *
9292
+ * Paced per LISTING and not per entry, because that is where the latency
9293
+ * is; and through the SAME `yieldBetween` the recovery uses, so there is one
9294
+ * pacer, one back-off state and one set of counters for the whole pass —
9295
+ * never a second scheduling authority (D167).
9296
+ */
9297
+ const listPaced = async (dir) => {
9298
+ let entries = null;
9087
9299
  try {
9088
- deviceDirs = await deps.listDir(stagingRoot);
9300
+ entries = await deps.listDir(dir);
9089
9301
  } catch {
9090
- continue;
9302
+ entries = null;
9091
9303
  }
9304
+ await yieldBetween();
9305
+ return entries;
9306
+ };
9307
+ const walkStartedMs = Date.now();
9308
+ const work = [];
9309
+ for (const location of locations) {
9310
+ const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
9311
+ const deviceDirs = await listPaced(stagingRoot);
9312
+ if (deviceDirs === null) continue;
9092
9313
  for (const deviceDir of deviceDirs) {
9093
9314
  const deviceId = Number(deviceDir);
9094
9315
  if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
9095
- let profileDirs;
9096
- try {
9097
- profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
9098
- } catch {
9099
- continue;
9100
- }
9316
+ const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
9317
+ if (profileDirs === null) continue;
9101
9318
  const segmentSeconds = await deps.segmentSecondsFor(deviceId);
9102
9319
  for (const profile of profileDirs) {
9103
9320
  if (!KNOWN_PROFILES.has(profile)) continue;
@@ -9111,6 +9328,7 @@ async function recoverAllStagedOrphans(deps) {
9111
9328
  }
9112
9329
  }
9113
9330
  }
9331
+ const walkMs = Date.now() - walkStartedMs;
9114
9332
  const queue = [...work];
9115
9333
  const lane = async () => {
9116
9334
  for (;;) {
@@ -9153,6 +9371,7 @@ async function recoverAllStagedOrphans(deps) {
9153
9371
  failed,
9154
9372
  aborted,
9155
9373
  ms: Date.now() - startedMs,
9374
+ walkMs,
9156
9375
  ...pacer === null ? {} : { pacing: pacer.stats() }
9157
9376
  } });
9158
9377
  return {
@@ -9405,7 +9624,8 @@ var RecorderV2Addon = class extends BaseAddon {
9405
9624
  }
9406
9625
  this.segmentHours = new SegmentHourLedger({
9407
9626
  store: this.ctx.api.settingsStore,
9408
- logger: this.ctx.logger
9627
+ logger: this.ctx.logger,
9628
+ isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
9409
9629
  });
9410
9630
  try {
9411
9631
  await this.segmentHours.declare();
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.89",
21
+ version: "1.2.90",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.61",
36
+ version: "1.2.62",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.89",
39
+ version: "1.2.90",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.61",
84
+ version: "1.2.62",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -1225,6 +1225,11 @@ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
1225
1225
  * ffmpeg's native AAC encoder accepts 8 kHz, the ADTS header carries whatever
1226
1226
  * rate came out, and `parseAdtsAudioParams` reads it back for the broker.
1227
1227
  */
1228
+ /**
1229
+ * One second of GOP at the fleet cadence, used when the derived profile names
1230
+ * neither gopFrames nor fps. Matches BASE_LIVE_EGRESS_PROFILE's intent.
1231
+ */
1232
+ var DERIVED_DEFAULT_GOP_FRAMES = 25;
1228
1233
  var DERIVED_AAC_RECODE = {
1229
1234
  codec: "aac",
1230
1235
  channels: 1
@@ -1280,10 +1285,15 @@ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
1280
1285
  */
1281
1286
  function buildDerivedTranscodeArgs(input) {
1282
1287
  const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
1288
+ const video = input.profile.video.gopFrames !== void 0 ? input.profile.video : {
1289
+ ...input.profile.video,
1290
+ gopFrames: input.profile.video.fps ?? DERIVED_DEFAULT_GOP_FRAMES
1291
+ };
1283
1292
  return {
1284
1293
  args: require_dist.buildFfmpegArgs(require_dist.invocationFromEncodeProfile({
1285
1294
  profile: {
1286
1295
  ...input.profile,
1296
+ video,
1287
1297
  audio: audio.audio
1288
1298
  },
1289
1299
  sourceCodec: "h264",
@@ -25254,11 +25264,16 @@ var TimelineSession = class {
25254
25264
  if (!locate) return requested;
25255
25265
  let withFootage = null;
25256
25266
  for (const profile of profileSearchOrder(requested)) {
25257
- const loc = await locate({
25258
- deviceId: this.deps.deviceId,
25259
- profile,
25260
- epochMs
25261
- });
25267
+ let loc;
25268
+ try {
25269
+ loc = await locate({
25270
+ deviceId: this.deps.deviceId,
25271
+ profile,
25272
+ epochMs
25273
+ });
25274
+ } catch {
25275
+ continue;
25276
+ }
25262
25277
  if (loc.kind === "segment") return profile;
25263
25278
  if (withFootage === null && loc.nearestEdgeMs !== null) withFootage = profile;
25264
25279
  }
@@ -1220,6 +1220,11 @@ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
1220
1220
  * ffmpeg's native AAC encoder accepts 8 kHz, the ADTS header carries whatever
1221
1221
  * rate came out, and `parseAdtsAudioParams` reads it back for the broker.
1222
1222
  */
1223
+ /**
1224
+ * One second of GOP at the fleet cadence, used when the derived profile names
1225
+ * neither gopFrames nor fps. Matches BASE_LIVE_EGRESS_PROFILE's intent.
1226
+ */
1227
+ var DERIVED_DEFAULT_GOP_FRAMES = 25;
1223
1228
  var DERIVED_AAC_RECODE = {
1224
1229
  codec: "aac",
1225
1230
  channels: 1
@@ -1275,10 +1280,15 @@ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
1275
1280
  */
1276
1281
  function buildDerivedTranscodeArgs(input) {
1277
1282
  const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
1283
+ const video = input.profile.video.gopFrames !== void 0 ? input.profile.video : {
1284
+ ...input.profile.video,
1285
+ gopFrames: input.profile.video.fps ?? DERIVED_DEFAULT_GOP_FRAMES
1286
+ };
1278
1287
  return {
1279
1288
  args: buildFfmpegArgs$1(invocationFromEncodeProfile({
1280
1289
  profile: {
1281
1290
  ...input.profile,
1291
+ video,
1282
1292
  audio: audio.audio
1283
1293
  },
1284
1294
  sourceCodec: "h264",
@@ -25249,11 +25259,16 @@ var TimelineSession = class {
25249
25259
  if (!locate) return requested;
25250
25260
  let withFootage = null;
25251
25261
  for (const profile of profileSearchOrder(requested)) {
25252
- const loc = await locate({
25253
- deviceId: this.deps.deviceId,
25254
- profile,
25255
- epochMs
25256
- });
25262
+ let loc;
25263
+ try {
25264
+ loc = await locate({
25265
+ deviceId: this.deps.deviceId,
25266
+ profile,
25267
+ epochMs
25268
+ });
25269
+ } catch {
25270
+ continue;
25271
+ }
25257
25272
  if (loc.kind === "segment") return profile;
25258
25273
  if (withFootage === null && loc.nearestEdgeMs !== null) withFootage = profile;
25259
25274
  }
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-tSj8DwCM.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7ERJocD.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.97",
3
+ "version": "1.2.98",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",