@camstack/addon-pipeline 1.2.140 → 1.2.142

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.
@@ -848,6 +848,23 @@ function upperBoundByStart(rows, startMs) {
848
848
  }
849
849
  return lo;
850
850
  }
851
+ /**
852
+ * Index of the FIRST element whose `startMs` is at-or-after `startMs` (lower
853
+ * bound). On a start-sorted view this is exactly where a linear
854
+ * `find(s => s.startMs === x)` and a linear `find(s => s.startMs >= x)` would
855
+ * have stopped — which is what makes the binary-search replacements of those
856
+ * two scans row-for-row identical, duplicate starts included.
857
+ */
858
+ function lowerBoundByStart(rows, startMs) {
859
+ let lo = 0;
860
+ let hi = rows.length;
861
+ while (lo < hi) {
862
+ const mid = lo + hi >> 1;
863
+ if (rows[mid].startMs < startMs) lo = mid + 1;
864
+ else hi = mid;
865
+ }
866
+ return lo;
867
+ }
851
868
  var RecordingIndex = class {
852
869
  byDevice = /* @__PURE__ */ new Map();
853
870
  /** Start-sorted views. Built lazily on first read (one copy+sort), then kept
@@ -866,6 +883,27 @@ var RecordingIndex = class {
866
883
  * hydrate. Empty = nothing has been looked at, which is NOT the same as
867
884
  * "there is nothing" — see {@link hydrationOf}. */
868
885
  walked = /* @__PURE__ */ new Map();
886
+ /**
887
+ * Upper bound on the `durMs` of any row the index holds — the ONLY thing that
888
+ * turns "walk the whole sorted view" into "walk backwards a bounded number of
889
+ * rows".
890
+ *
891
+ * A start-sorted view answers "which row STARTS at/before X" by binary
892
+ * search, but every backward question here ("which row still COVERS X",
893
+ * "which footage ENDS at/before X") depends on `startMs + durMs`, which is
894
+ * not sorted. This scalar closes that: a row at `startMs` can never end later
895
+ * than `startMs + maxDurMs`, so once a candidate's ceiling can no longer beat
896
+ * the best answer found, no earlier row can either — and the walk stops.
897
+ *
898
+ * Monotonically non-decreasing on purpose. It is only ever an OVER-estimate
899
+ * (a longer walk, never a wrong answer); shrinking it on eviction would buy
900
+ * nothing and could only make it wrong.
901
+ */
902
+ maxDurMs = 0;
903
+ /** Widen {@link maxDurMs} for a row entering the index. */
904
+ noteDuration(durMs) {
905
+ if (durMs > this.maxDurMs) this.maxDurMs = durMs;
906
+ }
869
907
  mapFor(deviceId) {
870
908
  let m = this.byDevice.get(deviceId);
871
909
  if (!m) {
@@ -954,11 +992,13 @@ var RecordingIndex = class {
954
992
  const prev = held.get(path);
955
993
  if (prev !== void 0 && prev.locationId === locationId) {
956
994
  this.rowsReused += 1;
995
+ this.noteDuration(prev.durMs);
957
996
  return prev;
958
997
  }
959
998
  const p = parseSegmentPath(path);
960
999
  if (!p || p.deviceId !== deviceId) return null;
961
1000
  this.rowsAllocated += 1;
1001
+ this.noteDuration(p.durMs);
962
1002
  return {
963
1003
  deviceId: p.deviceId,
964
1004
  profile: p.profile,
@@ -1056,6 +1096,7 @@ var RecordingIndex = class {
1056
1096
  */
1057
1097
  addSegment(s) {
1058
1098
  const m = this.mapFor(s.deviceId);
1099
+ this.noteDuration(s.durMs);
1059
1100
  const replacing = m.has(s.path);
1060
1101
  m.set(s.path, s);
1061
1102
  if (replacing) {
@@ -1093,6 +1134,55 @@ var RecordingIndex = class {
1093
1134
  while (end < segs.length && segs[end].startMs < toMs) end += 1;
1094
1135
  return lo === 0 && end === segs.length ? segs : segs.slice(lo, end);
1095
1136
  }
1137
+ /**
1138
+ * The row whose `startMs` is EXACTLY `startMs`, or null.
1139
+ *
1140
+ * The point lookup every byte read is: `readSegmentBytes` / `readGopBytes` /
1141
+ * `readWindowBytes` all address a segment by its exact start, which is what
1142
+ * the playback manifest and the locate answer handed the client. It used to
1143
+ * be `segments(deviceId, profile).find(...)` — a full COPY of the
1144
+ * device-profile archive plus a linear scan, per scrub tick and per playback
1145
+ * fetch (~10 k rows copied to reach one of them on the live hub).
1146
+ *
1147
+ * Returns the FIRST row with that start, which is what `.find` returned on
1148
+ * the same start-sorted view: a restart can leave two rows sharing a start,
1149
+ * and the byte reader must keep resolving to the same one it always did.
1150
+ */
1151
+ segmentStartingAt(deviceId, profile, startMs) {
1152
+ const segs = this.sortedSegments(deviceId, profile);
1153
+ const at = segs[lowerBoundByStart(segs, startMs)];
1154
+ return at !== void 0 && at.startMs === startMs ? at : null;
1155
+ }
1156
+ /**
1157
+ * Start-sorted rows whose SPAN `[startMs, startMs+durMs)` overlaps
1158
+ * `[fromMs, toMs)` — i.e. `startMs < toMs && startMs + durMs > fromMs`.
1159
+ *
1160
+ * The overlap-inclusive twin of {@link segmentsStartingIn}, and the reason it
1161
+ * cannot just slice from the `fromMs` lower bound: the segment an operator is
1162
+ * actually watching at `fromMs` STARTED before it. Dropping it truncates the
1163
+ * first segment of every playback window — silently, because the window still
1164
+ * renders, just short. The boundary convention is the caller's, preserved
1165
+ * from the whole-archive filter this replaces: a segment ending EXACTLY at
1166
+ * `fromMs` is excluded (`end > fromMs`), a segment starting exactly at `toMs`
1167
+ * is excluded (`start < toMs`), and a segment straddling `fromMs` is IN.
1168
+ *
1169
+ * The backward reach is {@link maxDurMs}, not "one element": with overlapping
1170
+ * rows (a restart re-recording an hour) more than one predecessor can still
1171
+ * cover `fromMs`, and the ceiling is the only bound that cannot miss one.
1172
+ */
1173
+ segmentsOverlapping(deviceId, profile, fromMs, toMs) {
1174
+ const segs = this.sortedSegments(deviceId, profile);
1175
+ const end = lowerBoundByStart(segs, toMs);
1176
+ let head = lowerBoundByStart(segs, fromMs);
1177
+ const reach = fromMs - this.maxDurMs;
1178
+ while (head > 0 && segs[head - 1].startMs > reach) head -= 1;
1179
+ const out = [];
1180
+ for (let i = head; i < end; i++) {
1181
+ const s = segs[i];
1182
+ if (s.startMs + s.durMs > fromMs) out.push(s);
1183
+ }
1184
+ return out;
1185
+ }
1096
1186
  /** True when `relPath` is currently indexed for `deviceId`. O(1). */
1097
1187
  hasSegment(deviceId, relPath) {
1098
1188
  return this.byDevice.get(deviceId)?.has(relPath) === true;
@@ -1211,6 +1301,26 @@ var RecordingIndex = class {
1211
1301
  }
1212
1302
  return [...out.values()];
1213
1303
  }
1304
+ /**
1305
+ * WHICH devices hold footage on any of `locationIds` — the census, without
1306
+ * the archive.
1307
+ *
1308
+ * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1309
+ * location: a full multi-device COPY of every row on the disk plus a sort of
1310
+ * it (294 k rows on the live hub), to read one number off each row and throw
1311
+ * the array away. This visits each device only until its first matching row.
1312
+ * Same membership rule as before — a device is listed when at least one of
1313
+ * its rows sits on one of the given locations, so footage stranded on a
1314
+ * location id that no longer resolves stays invisible here exactly as it was.
1315
+ */
1316
+ deviceIdsOnLocations(locationIds) {
1317
+ const out = /* @__PURE__ */ new Set();
1318
+ for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1319
+ out.add(deviceId);
1320
+ break;
1321
+ }
1322
+ return out;
1323
+ }
1214
1324
  /** All segments on a storage location across every device, oldest-first. */
1215
1325
  segmentsOnLocation(locationId) {
1216
1326
  const out = [];
@@ -1247,20 +1357,59 @@ var RecordingIndex = class {
1247
1357
  const segs = this.sortedSegments(deviceId, profile);
1248
1358
  if (segs.length === 0) return null;
1249
1359
  if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
1250
- const next = segs.find((s) => s.startMs >= epochMs);
1360
+ const next = segs[lowerBoundByStart(segs, epochMs)];
1251
1361
  return next ? next.startMs : null;
1252
1362
  }
1253
1363
  /**
1364
+ * The end of the newest footage for a (device, profile), or null when there
1365
+ * is none — "how far can an export reach?", asked once per render.
1366
+ *
1367
+ * NOT simply the last row's end: the view is sorted by START, and an earlier,
1368
+ * longer segment can end later. Walks backwards from the newest row only
1369
+ * while an earlier start could still, at {@link maxDurMs}, beat the best end
1370
+ * seen — which on a uniform-length archive is one comparison.
1371
+ */
1372
+ latestEnd(deviceId, profile) {
1373
+ const segs = this.sortedSegments(deviceId, profile);
1374
+ let best = null;
1375
+ for (let i = segs.length - 1; i >= 0; i--) {
1376
+ const s = segs[i];
1377
+ if (best !== null && s.startMs + this.maxDurMs <= best) break;
1378
+ const end = s.startMs + s.durMs;
1379
+ if (best === null || end > best) best = end;
1380
+ }
1381
+ return best;
1382
+ }
1383
+ /**
1254
1384
  * The exclusive END of the nearest footage ending at-or-before `epochMs`
1255
1385
  * (the backward covered edge), or null when no footage ends at or before it.
1256
1386
  * Backward counterpart of `nearestCoveredEdge`: consecutive segments leave
1257
1387
  * small cracks (`startMs + durMs` < next `startMs` by ~11-17 ms), so a
1258
1388
  * backward frame-step probing `startMs − 1` lands in a crack — this edge
1259
1389
  * lets the caller hop to the true previous segment.
1390
+ *
1391
+ * ## Why this is a search and not a scan
1392
+ *
1393
+ * Called on EVERY locate that lands in a gap — during a drag across sparse
1394
+ * footage, every tick — and it used to walk the device-profile's whole sorted
1395
+ * view to do it. Two bounds replace the walk, and the second is the one that
1396
+ * has to be right:
1397
+ *
1398
+ * 1. A row starting AFTER `epochMs` ends after it too (`durMs >= 0`), so
1399
+ * nothing at-or-past the upper bound of `epochMs` can qualify.
1400
+ * 2. Below that bound the answer is NOT the predecessor. A row starting
1401
+ * before the cursor can still COVER it (disqualified: its end is past
1402
+ * `epochMs`), while an even earlier row ends cleanly before it and is the
1403
+ * true edge. So the walk continues backwards, and only {@link maxDurMs}
1404
+ * says when to stop: earlier rows start no later than this one, so none of
1405
+ * them can end past `startMs + maxDurMs`.
1260
1406
  */
1261
1407
  coveredEdgeBefore(deviceId, profile, epochMs) {
1408
+ const segs = this.sortedSegments(deviceId, profile);
1262
1409
  let best = null;
1263
- for (const s of this.sortedSegments(deviceId, profile)) {
1410
+ for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
1411
+ const s = segs[i];
1412
+ if (best !== null && s.startMs + this.maxDurMs <= best) break;
1264
1413
  const end = s.startMs + s.durMs;
1265
1414
  if (end <= epochMs && (best === null || end > best)) best = end;
1266
1415
  }
@@ -1902,6 +2051,11 @@ var macrotask$2 = () => new Promise((resolve) => {
1902
2051
  * segment's start. Returns the LAST segment whose half-open span
1903
2052
  * `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
1904
2053
  * restart; the newest wins). Null when no segment covers the instant.
2054
+ *
2055
+ * `segments` may be a CANDIDATE WINDOW rather than the device's whole archive —
2056
+ * the caller narrows it with an index search. The membership test below stays
2057
+ * regardless: narrowing may only remove rows that could not have won, never
2058
+ * decide which of the survivors does.
1905
2059
  */
1906
2060
  function resolveStillSource(segments, epochMs) {
1907
2061
  let match = null;
@@ -1969,16 +2123,22 @@ var StillFrameService = class {
1969
2123
  * caller replies 204 → the dialog shows its placeholder).
1970
2124
  */
1971
2125
  async getStill(deviceId, epochMs) {
1972
- const source = resolveStillSource(this.deps.listLowSegments(deviceId), epochMs);
1973
- if (source === null) return null;
2126
+ const source = resolveStillSource(this.deps.lowSegmentsAt(deviceId, epochMs), epochMs);
2127
+ if (source === null) {
2128
+ this.deps.logger.debug("still: no low segment covers the instant", {
2129
+ tags: { deviceId },
2130
+ meta: { epochMs }
2131
+ });
2132
+ return null;
2133
+ }
1974
2134
  const abs = this.deps.resolveAbsPath(source.row);
1975
2135
  const now = (this.deps.now ?? Date.now)();
1976
2136
  const immutable = source.row.startMs + source.row.durMs < now;
1977
2137
  if (!await this.acquireSlot()) {
1978
- this.deps.logger.debug("still: decode queue saturated, dropping request", { meta: {
1979
- deviceId,
1980
- epochMs
1981
- } });
2138
+ this.deps.logger.debug("still: decode queue saturated, dropping request", {
2139
+ tags: { deviceId },
2140
+ meta: { epochMs }
2141
+ });
1982
2142
  return null;
1983
2143
  }
1984
2144
  try {
@@ -5079,7 +5239,7 @@ async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMo
5079
5239
  if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
5080
5240
  const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
5081
5241
  const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
5082
- const candidates = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs).flatMap((s) => {
5242
+ const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
5083
5243
  const root = rootById.get(s.locationId);
5084
5244
  if (root === void 0) return [];
5085
5245
  const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
@@ -5245,7 +5405,7 @@ async function evictVictims(deps, deviceId, victims) {
5245
5405
  * and GOP byte-range reads so the two paths cannot drift on resolution.
5246
5406
  */
5247
5407
  function resolveSegmentFile(deps, deviceId, profile, startMs) {
5248
- const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
5408
+ const seg = deps.index.segmentStartingAt(deviceId, profile, startMs);
5249
5409
  if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
5250
5410
  const loc = deps.locations().find((l) => l.id === seg.locationId);
5251
5411
  if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
@@ -5414,8 +5574,7 @@ function buildRecordingProvider(deps) {
5414
5574
  getStorageUsage: async () => {
5415
5575
  const locations = deps.locations();
5416
5576
  const devices = [];
5417
- const seenDevices = /* @__PURE__ */ new Set();
5418
- for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
5577
+ const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
5419
5578
  let totalUsedBytes = 0;
5420
5579
  for (const deviceId of seenDevices) {
5421
5580
  const accounting = deps.index.accounting(deviceId);
@@ -5919,15 +6078,14 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
5919
6078
  await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
5920
6079
  }
5921
6080
  }
5922
- /** The end of the newest FINALIZED `low` segment, or null when none exists. */
6081
+ /**
6082
+ * The end of the newest FINALIZED `low` segment, or null when none exists.
6083
+ *
6084
+ * One scalar off the tail of the live sorted view — it used to COPY the
6085
+ * device's whole `low` archive to fold a maximum out of it, per render.
6086
+ */
5923
6087
  function latestFinalizedEnd(renderDeps, deviceId) {
5924
- const rows = renderDeps.deps.index.segments(deviceId, "low");
5925
- let end = null;
5926
- for (const s of rows) {
5927
- const candidate = s.startMs + s.durMs;
5928
- if (end === null || candidate > end) end = candidate;
5929
- }
5930
- return end;
6088
+ return renderDeps.deps.index.latestEnd(deviceId, "low");
5931
6089
  }
5932
6090
  /**
5933
6091
  * Run one ffmpeg and GUARANTEE the child is gone before the promise settles.
@@ -11234,10 +11392,12 @@ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
11234
11392
  /**
11235
11393
  * The four aggregate columns exist so "how much footage is on this disk" is a
11236
11394
  * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
11237
- * segment rows. They are DERIVED from `paths` — never a second authority — and
11238
- * {@link hourAggregate} is the only place that derives them, so a row loaded
11239
- * from a database that predates these columns is corrected in the mirror the
11240
- * moment it is read and repaired on disk by {@link SegmentHourLedger.repairAggregates}.
11395
+ * segment rows. They are DERIVED from `paths` — never a second authority — by
11396
+ * {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
11397
+ * over every path and {@link appendSegmentPath} is that step over one, so there
11398
+ * is no second derivation to drift. A row loaded from a database that predates
11399
+ * these columns is corrected in the mirror the moment it is read and repaired
11400
+ * on disk by {@link SegmentHourLedger.repairAggregates}.
11241
11401
  */
11242
11402
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
11243
11403
  {
@@ -11314,33 +11474,57 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
11314
11474
  columns: ["locationId", "hourStartMs"]
11315
11475
  })
11316
11476
  ];
11477
+ /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
11478
+ var EMPTY_HOUR_AGGREGATE = {
11479
+ bytes: 0,
11480
+ segments: 0,
11481
+ minStartMs: 0,
11482
+ maxStartMs: 0
11483
+ };
11317
11484
  /**
11318
- * Bytes / count / oldest / newest for one hour's paths.
11485
+ * ONE path folded into an aggregate the whole derivation, expressed as its
11486
+ * single step.
11487
+ *
11488
+ * {@link hourAggregate} is this step over every path, and {@link
11489
+ * SegmentHourLedger.recordSegment} is this step over the one path that just
11490
+ * arrived. That is not two implementations that happen to agree: the full
11491
+ * derivation is *defined* as this function repeated, so an append is the fold
11492
+ * resumed from the state the held row already carries — a `SegmentHourRow`'s
11493
+ * aggregate half IS the fold state over its `paths`.
11494
+ *
11495
+ * Why it had to become a step: `recordSegment` re-derived the whole hour on
11496
+ * every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
11497
+ * segments, Σ1..1708 ≈ 1.46 M in the restart-storm hours of 2026-08-29, at
11498
+ * ~3.4 finalizes/s fleet-wide, forever.
11499
+ *
11500
+ * A path the parser refuses contributes nothing — it is not a segment, and
11501
+ * counting it would inflate a disk report. `segments === 0` is the first-path
11502
+ * sentinel for min/max, so an aggregate with no segments carries 0/0 rather
11503
+ * than a sentinel nobody can compare against.
11504
+ */
11505
+ function withSegmentPath(agg, path) {
11506
+ const p = parseSegmentPath(path);
11507
+ if (p === null) return agg;
11508
+ return {
11509
+ bytes: agg.bytes + p.bytes,
11510
+ segments: agg.segments + 1,
11511
+ minStartMs: agg.segments === 0 || p.startMs < agg.minStartMs ? p.startMs : agg.minStartMs,
11512
+ maxStartMs: agg.segments === 0 || p.startMs > agg.maxStartMs ? p.startMs : agg.maxStartMs
11513
+ };
11514
+ }
11515
+ /**
11516
+ * Bytes / count / oldest / newest for one hour's paths — the sole FULL
11517
+ * derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
11518
+ * by the append path.
11319
11519
  *
11320
11520
  * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
11321
11521
  * this is a derivation, not a measurement: it can never disagree with the paths
11322
- * it was computed from. A path the parser refuses contributes nothing — it is
11323
- * not a segment, and counting it would inflate a disk report.
11522
+ * it was computed from.
11324
11523
  */
11325
11524
  function hourAggregate(paths) {
11326
- let bytes = 0;
11327
- let segments = 0;
11328
- let minStartMs = 0;
11329
- let maxStartMs = 0;
11330
- for (const path of paths) {
11331
- const p = parseSegmentPath(path);
11332
- if (p === null) continue;
11333
- bytes += p.bytes;
11334
- if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
11335
- if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
11336
- segments += 1;
11337
- }
11338
- return {
11339
- bytes,
11340
- segments,
11341
- minStartMs,
11342
- maxStartMs
11343
- };
11525
+ let agg = EMPTY_HOUR_AGGREGATE;
11526
+ for (const path of paths) agg = withSegmentPath(agg, path);
11527
+ return agg;
11344
11528
  }
11345
11529
  /** Build the persisted row for an hour from its paths — the ONE constructor. */
11346
11530
  function segmentHourRow(base) {
@@ -11350,6 +11534,33 @@ function segmentHourRow(base) {
11350
11534
  };
11351
11535
  }
11352
11536
  /**
11537
+ * The row an hour becomes when ONE more path joins it.
11538
+ *
11539
+ * Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
11540
+ * strictly cheaper: the aggregate half of `held` is already the fold over
11541
+ * `held.paths`, so this resumes that fold instead of restarting it. The
11542
+ * equality is not an assumption — `segment-hour-append.spec.ts` pins it on
11543
+ * generated hours including out-of-order arrival, duplicate paths, unparseable
11544
+ * paths and storm-sized hours.
11545
+ *
11546
+ * The four fields are named rather than spread on purpose. {@link
11547
+ * withSegmentPath} hands its argument straight back for a path the parser
11548
+ * refuses, and `held` is a whole row — spreading that return would put the
11549
+ * PREVIOUS `paths` array back over the appended one. Which is exactly what it
11550
+ * did until the generated-hours test caught it.
11551
+ */
11552
+ function appendSegmentPath(held, path) {
11553
+ const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
11554
+ return {
11555
+ ...held,
11556
+ paths: [...held.paths, path],
11557
+ bytes,
11558
+ segments,
11559
+ minStartMs,
11560
+ maxStartMs
11561
+ };
11562
+ }
11563
+ /**
11353
11564
  * The largest share of a device's held hours a single walk may delete.
11354
11565
  *
11355
11566
  * On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
@@ -11682,19 +11893,31 @@ var SegmentHourLedger = class {
11682
11893
  } });
11683
11894
  return repaired;
11684
11895
  }
11685
- /** Write-behind: a just-finalized segment joins its hour row. */
11896
+ /**
11897
+ * Write-behind: a just-finalized segment joins its hour row.
11898
+ *
11899
+ * THE hot path — every profile-writer lands here every `segmentSeconds`,
11900
+ * ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
11901
+ * one `parseSegmentPath` and four scalar updates, whatever the hour already
11902
+ * holds. It used to re-derive the whole hour, which made one hour
11903
+ * O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
11904
+ */
11686
11905
  async recordSegment(row) {
11687
11906
  const { key, hourStartMs: hour } = hourOf(row);
11688
11907
  const held = this.ledger.get(key);
11689
- if (held !== void 0 && held.paths.includes(row.path)) return;
11690
- await this.ledger.put(segmentHourRow({
11691
- key,
11692
- deviceId: row.deviceId,
11693
- profile: row.profile,
11694
- locationId: row.locationId,
11695
- hourStartMs: hour,
11696
- paths: held === void 0 ? [row.path] : [...held.paths, row.path]
11697
- }));
11908
+ if (held === void 0) {
11909
+ await this.ledger.put(segmentHourRow({
11910
+ key,
11911
+ deviceId: row.deviceId,
11912
+ profile: row.profile,
11913
+ locationId: row.locationId,
11914
+ hourStartMs: hour,
11915
+ paths: [row.path]
11916
+ }));
11917
+ return;
11918
+ }
11919
+ if (held.paths.includes(row.path)) return;
11920
+ await this.ledger.put(appendSegmentPath(held, row.path));
11698
11921
  }
11699
11922
  /**
11700
11923
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -11758,10 +11981,7 @@ var SegmentHourLedger = class {
11758
11981
  hourStartMs: hour,
11759
11982
  paths: [seg.path]
11760
11983
  }));
11761
- else fromIndex.set(key, segmentHourRow({
11762
- ...existing,
11763
- paths: [...existing.paths, seg.path]
11764
- }));
11984
+ else fromIndex.set(key, appendSegmentPath(existing, seg.path));
11765
11985
  }
11766
11986
  const refused = this.refusedPrunes(walked, fromIndex);
11767
11987
  for (const row of this.ledger.snapshot()) {
@@ -12991,7 +13211,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12991
13211
  }
12992
13212
  try {
12993
13213
  const stillService = new StillFrameService({
12994
- listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
13214
+ lowSegmentsAt: (deviceId, epochMs) => this.index.segmentsOverlapping(deviceId, "low", epochMs, epochMs + 1),
12995
13215
  resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
12996
13216
  logger: this.ctx.logger.child("Still")
12997
13217
  });