@camstack/addon-pipeline 1.2.54 → 1.2.58

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.
@@ -1118,6 +1118,10 @@ function* hourBuckets(fromMs, toMs) {
1118
1118
  }
1119
1119
  var RecordingIndex = class {
1120
1120
  byDevice = /* @__PURE__ */ new Map();
1121
+ /** Start-sorted snapshots, rebuilt only after index mutations. Interactive
1122
+ * locate/range calls reuse them instead of filtering+sorting the complete
1123
+ * archive on every seek and scrub miss. */
1124
+ sortedByDevice = /* @__PURE__ */ new Map();
1121
1125
  /** Hour buckets this device has been walked for; `ALL_HOURS` after a full
1122
1126
  * hydrate. Empty = nothing has been looked at, which is NOT the same as
1123
1127
  * "there is nothing" — see {@link hydrationOf}. */
@@ -1130,6 +1134,24 @@ var RecordingIndex = class {
1130
1134
  }
1131
1135
  return m;
1132
1136
  }
1137
+ invalidateSorted(deviceId) {
1138
+ this.sortedByDevice.delete(deviceId);
1139
+ }
1140
+ sortedSegments(deviceId, profile) {
1141
+ let deviceCache = this.sortedByDevice.get(deviceId);
1142
+ if (!deviceCache) {
1143
+ deviceCache = /* @__PURE__ */ new Map();
1144
+ this.sortedByDevice.set(deviceId, deviceCache);
1145
+ }
1146
+ const key = profile ?? "";
1147
+ const cached = deviceCache.get(key);
1148
+ if (cached) return cached;
1149
+ const m = this.byDevice.get(deviceId);
1150
+ if (!m) return [];
1151
+ const sorted = [...m.values()].filter((s) => profile == null || s.profile === profile).toSorted((a, b) => a.startMs - b.startMs);
1152
+ deviceCache.set(key, sorted);
1153
+ return sorted;
1154
+ }
1133
1155
  /**
1134
1156
  * Replace a device's segments FOR ONE LOCATION from a `storage.list` result (paths relative to that
1135
1157
  * location root). Rows on other locations are preserved. Non-segment paths and other devices are ignored.
@@ -1150,6 +1172,7 @@ var RecordingIndex = class {
1150
1172
  locationId
1151
1173
  });
1152
1174
  }
1175
+ this.invalidateSorted(deviceId);
1153
1176
  }
1154
1177
  /**
1155
1178
  * Whether the index has WALKED `[fromMs, toMs)` for a device.
@@ -1206,25 +1229,25 @@ var RecordingIndex = class {
1206
1229
  locationId
1207
1230
  });
1208
1231
  }
1232
+ this.invalidateSorted(deviceId);
1209
1233
  this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$1);
1210
1234
  }
1211
1235
  /** Insert/replace one finalized segment (idempotent by path). */
1212
1236
  addSegment(s) {
1213
1237
  this.mapFor(s.deviceId).set(s.path, s);
1238
+ this.invalidateSorted(s.deviceId);
1214
1239
  }
1215
1240
  /** Remove segments by relative path, routing each path to the correct device map via its encoded deviceId. */
1216
1241
  removeSegments(paths) {
1217
1242
  for (const p of paths) {
1218
1243
  const parsed = parseSegmentPath(p);
1219
1244
  if (!parsed) continue;
1220
- this.byDevice.get(parsed.deviceId)?.delete(p);
1245
+ if (this.byDevice.get(parsed.deviceId)?.delete(p)) this.invalidateSorted(parsed.deviceId);
1221
1246
  }
1222
1247
  }
1223
1248
  /** Rows for a device (optionally one profile), sorted by start. */
1224
1249
  segments(deviceId, profile) {
1225
- const m = this.byDevice.get(deviceId);
1226
- if (!m) return [];
1227
- return [...m.values()].filter((s) => profile == null || s.profile === profile).toSorted((a, b) => a.startMs - b.startMs);
1250
+ return [...this.sortedSegments(deviceId, profile)];
1228
1251
  }
1229
1252
  /** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). */
1230
1253
  accounting(deviceId) {
@@ -1338,7 +1361,7 @@ var RecordingIndex = class {
1338
1361
  }
1339
1362
  /** The segment whose [startMs, startMs+durMs) window contains `epochMs`, or null if `epochMs` falls in a gap / there is no footage. */
1340
1363
  segmentAt(deviceId, profile, epochMs) {
1341
- return this.segmentAtIn(this.segments(deviceId, profile), epochMs);
1364
+ return this.segmentAtIn(this.sortedSegments(deviceId, profile), epochMs);
1342
1365
  }
1343
1366
  /**
1344
1367
  * If epochMs is covered, returns it unchanged. Otherwise returns the start of the
@@ -1346,7 +1369,7 @@ var RecordingIndex = class {
1346
1369
  * (epoch past all footage, or no footage at all). Only snaps forward — never backward.
1347
1370
  */
1348
1371
  nearestCoveredEdge(deviceId, profile, epochMs) {
1349
- const segs = this.segments(deviceId, profile);
1372
+ const segs = this.sortedSegments(deviceId, profile);
1350
1373
  if (segs.length === 0) return null;
1351
1374
  if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
1352
1375
  const next = segs.find((s) => s.startMs >= epochMs);
@@ -1362,14 +1385,14 @@ var RecordingIndex = class {
1362
1385
  */
1363
1386
  coveredEdgeBefore(deviceId, profile, epochMs) {
1364
1387
  let best = null;
1365
- for (const s of this.segments(deviceId, profile)) {
1388
+ for (const s of this.sortedSegments(deviceId, profile)) {
1366
1389
  const end = s.startMs + s.durMs;
1367
1390
  if (end <= epochMs && (best === null || end > best)) best = end;
1368
1391
  }
1369
1392
  return best;
1370
1393
  }
1371
1394
  ranges(deviceId, profile, fromMs, toMs, gapToleranceMs) {
1372
- return mergeRanges(this.segments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
1395
+ return mergeRanges(this.sortedSegments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
1373
1396
  startMs: Math.max(r.startMs, fromMs),
1374
1397
  endMs: Math.min(r.endMs, toMs)
1375
1398
  }));
@@ -1828,15 +1851,15 @@ async function readGopRange(file, args) {
1828
1851
  bytesRead += chunk.length;
1829
1852
  return chunk;
1830
1853
  };
1831
- const parsed = await parseTail(file, read);
1832
- if (parsed === null) return null;
1833
- const { tail, index } = parsed;
1854
+ const parsed = args.seededIndex === void 0 ? await parseTail(file, read) : null;
1855
+ if (args.seededIndex === void 0 && parsed === null) return null;
1856
+ const index = args.seededIndex ?? parsed.index;
1834
1857
  const firstMoof = index.samples[0]?.moofOffset ?? 0;
1835
1858
  if (firstMoof <= 0 || firstMoof >= file.size) return null;
1836
1859
  const head = await read(0, firstMoof);
1837
1860
  const init = parseInitInfo(head);
1838
1861
  if (init === null) return null;
1839
- const videoIndex = index.trackId === init.videoTrackId ? index : reparseTail(tail, file.size, init.videoTrackId);
1862
+ const videoIndex = index.trackId === init.videoTrackId ? index : parsed === null ? null : reparseTail(parsed.tail, file.size, init.videoTrackId);
1840
1863
  if (videoIndex === null) return null;
1841
1864
  const { start, end, sampleIndex } = fragmentRangeFor(videoIndex, Math.max(0, args.epochMs - args.segmentStartMs) / 1e3 * init.videoTimescale);
1842
1865
  if (start <= 0 || end <= start || end > file.size) return null;
@@ -2264,6 +2287,10 @@ var DEFAULT_BANDWIDTH = 1e6;
2264
2287
  * multiple missing segments) splits the ranges so real discontinuities show.
2265
2288
  */
2266
2289
  var RANGE_MERGE_GAP_MS = 5e3;
2290
+ /** Include the neighboring minute so a segment that starts in the previous
2291
+ * hour but covers the requested epoch is hydrated too. Recorder segments are
2292
+ * currently much shorter; the margin keeps this boundary future-safe. */
2293
+ var LOCATE_HYDRATE_MARGIN_MS = 6e4;
2267
2294
  /** Empty "no playback" manifest. */
2268
2295
  function noPlayback(deviceId) {
2269
2296
  return {
@@ -2613,6 +2640,9 @@ function buildRecordingProvider(deps) {
2613
2640
  },
2614
2641
  getDeviceConfig: async ({ deviceId }) => loadDeviceConfig(deps.configStore, deviceId),
2615
2642
  locateSegment: async ({ deviceId, profile, epochMs }) => {
2643
+ const hydrateFromMs = Math.max(0, epochMs - LOCATE_HYDRATE_MARGIN_MS);
2644
+ const hydrateToMs = epochMs + LOCATE_HYDRATE_MARGIN_MS;
2645
+ if (deps.index.hydrationOf(deviceId, hydrateFromMs, hydrateToMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, hydrateFromMs, hydrateToMs);
2616
2646
  const seg = deps.index.segmentAt(deviceId, profile, epochMs);
2617
2647
  if (seg) return {
2618
2648
  kind: "segment",
@@ -2638,7 +2668,8 @@ function buildRecordingProvider(deps) {
2638
2668
  const gop = await readGopRange(file, {
2639
2669
  segmentStartMs: seg.startMs,
2640
2670
  segmentDurMs: seg.durMs,
2641
- epochMs
2671
+ epochMs,
2672
+ seededIndex: deps.mfraFor?.(deviceId, profile, startMs)
2642
2673
  });
2643
2674
  if (gop !== null) return {
2644
2675
  data: gop.data,
@@ -5407,6 +5438,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
5407
5438
  capacity: (root) => this.locationCapacity(root),
5408
5439
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
5409
5440
  calendar: this.calendar,
5441
+ mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
5410
5442
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
5411
5443
  onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
5412
5444
  dataDir: this.ctx.dataDir,
@@ -1116,6 +1116,10 @@ function* hourBuckets(fromMs, toMs) {
1116
1116
  }
1117
1117
  var RecordingIndex = class {
1118
1118
  byDevice = /* @__PURE__ */ new Map();
1119
+ /** Start-sorted snapshots, rebuilt only after index mutations. Interactive
1120
+ * locate/range calls reuse them instead of filtering+sorting the complete
1121
+ * archive on every seek and scrub miss. */
1122
+ sortedByDevice = /* @__PURE__ */ new Map();
1119
1123
  /** Hour buckets this device has been walked for; `ALL_HOURS` after a full
1120
1124
  * hydrate. Empty = nothing has been looked at, which is NOT the same as
1121
1125
  * "there is nothing" — see {@link hydrationOf}. */
@@ -1128,6 +1132,24 @@ var RecordingIndex = class {
1128
1132
  }
1129
1133
  return m;
1130
1134
  }
1135
+ invalidateSorted(deviceId) {
1136
+ this.sortedByDevice.delete(deviceId);
1137
+ }
1138
+ sortedSegments(deviceId, profile) {
1139
+ let deviceCache = this.sortedByDevice.get(deviceId);
1140
+ if (!deviceCache) {
1141
+ deviceCache = /* @__PURE__ */ new Map();
1142
+ this.sortedByDevice.set(deviceId, deviceCache);
1143
+ }
1144
+ const key = profile ?? "";
1145
+ const cached = deviceCache.get(key);
1146
+ if (cached) return cached;
1147
+ const m = this.byDevice.get(deviceId);
1148
+ if (!m) return [];
1149
+ const sorted = [...m.values()].filter((s) => profile == null || s.profile === profile).toSorted((a, b) => a.startMs - b.startMs);
1150
+ deviceCache.set(key, sorted);
1151
+ return sorted;
1152
+ }
1131
1153
  /**
1132
1154
  * Replace a device's segments FOR ONE LOCATION from a `storage.list` result (paths relative to that
1133
1155
  * location root). Rows on other locations are preserved. Non-segment paths and other devices are ignored.
@@ -1148,6 +1170,7 @@ var RecordingIndex = class {
1148
1170
  locationId
1149
1171
  });
1150
1172
  }
1173
+ this.invalidateSorted(deviceId);
1151
1174
  }
1152
1175
  /**
1153
1176
  * Whether the index has WALKED `[fromMs, toMs)` for a device.
@@ -1204,25 +1227,25 @@ var RecordingIndex = class {
1204
1227
  locationId
1205
1228
  });
1206
1229
  }
1230
+ this.invalidateSorted(deviceId);
1207
1231
  this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$1);
1208
1232
  }
1209
1233
  /** Insert/replace one finalized segment (idempotent by path). */
1210
1234
  addSegment(s) {
1211
1235
  this.mapFor(s.deviceId).set(s.path, s);
1236
+ this.invalidateSorted(s.deviceId);
1212
1237
  }
1213
1238
  /** Remove segments by relative path, routing each path to the correct device map via its encoded deviceId. */
1214
1239
  removeSegments(paths) {
1215
1240
  for (const p of paths) {
1216
1241
  const parsed = parseSegmentPath(p);
1217
1242
  if (!parsed) continue;
1218
- this.byDevice.get(parsed.deviceId)?.delete(p);
1243
+ if (this.byDevice.get(parsed.deviceId)?.delete(p)) this.invalidateSorted(parsed.deviceId);
1219
1244
  }
1220
1245
  }
1221
1246
  /** Rows for a device (optionally one profile), sorted by start. */
1222
1247
  segments(deviceId, profile) {
1223
- const m = this.byDevice.get(deviceId);
1224
- if (!m) return [];
1225
- return [...m.values()].filter((s) => profile == null || s.profile === profile).toSorted((a, b) => a.startMs - b.startMs);
1248
+ return [...this.sortedSegments(deviceId, profile)];
1226
1249
  }
1227
1250
  /** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). */
1228
1251
  accounting(deviceId) {
@@ -1336,7 +1359,7 @@ var RecordingIndex = class {
1336
1359
  }
1337
1360
  /** The segment whose [startMs, startMs+durMs) window contains `epochMs`, or null if `epochMs` falls in a gap / there is no footage. */
1338
1361
  segmentAt(deviceId, profile, epochMs) {
1339
- return this.segmentAtIn(this.segments(deviceId, profile), epochMs);
1362
+ return this.segmentAtIn(this.sortedSegments(deviceId, profile), epochMs);
1340
1363
  }
1341
1364
  /**
1342
1365
  * If epochMs is covered, returns it unchanged. Otherwise returns the start of the
@@ -1344,7 +1367,7 @@ var RecordingIndex = class {
1344
1367
  * (epoch past all footage, or no footage at all). Only snaps forward — never backward.
1345
1368
  */
1346
1369
  nearestCoveredEdge(deviceId, profile, epochMs) {
1347
- const segs = this.segments(deviceId, profile);
1370
+ const segs = this.sortedSegments(deviceId, profile);
1348
1371
  if (segs.length === 0) return null;
1349
1372
  if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
1350
1373
  const next = segs.find((s) => s.startMs >= epochMs);
@@ -1360,14 +1383,14 @@ var RecordingIndex = class {
1360
1383
  */
1361
1384
  coveredEdgeBefore(deviceId, profile, epochMs) {
1362
1385
  let best = null;
1363
- for (const s of this.segments(deviceId, profile)) {
1386
+ for (const s of this.sortedSegments(deviceId, profile)) {
1364
1387
  const end = s.startMs + s.durMs;
1365
1388
  if (end <= epochMs && (best === null || end > best)) best = end;
1366
1389
  }
1367
1390
  return best;
1368
1391
  }
1369
1392
  ranges(deviceId, profile, fromMs, toMs, gapToleranceMs) {
1370
- return mergeRanges(this.segments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
1393
+ return mergeRanges(this.sortedSegments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
1371
1394
  startMs: Math.max(r.startMs, fromMs),
1372
1395
  endMs: Math.min(r.endMs, toMs)
1373
1396
  }));
@@ -1826,15 +1849,15 @@ async function readGopRange(file, args) {
1826
1849
  bytesRead += chunk.length;
1827
1850
  return chunk;
1828
1851
  };
1829
- const parsed = await parseTail(file, read);
1830
- if (parsed === null) return null;
1831
- const { tail, index } = parsed;
1852
+ const parsed = args.seededIndex === void 0 ? await parseTail(file, read) : null;
1853
+ if (args.seededIndex === void 0 && parsed === null) return null;
1854
+ const index = args.seededIndex ?? parsed.index;
1832
1855
  const firstMoof = index.samples[0]?.moofOffset ?? 0;
1833
1856
  if (firstMoof <= 0 || firstMoof >= file.size) return null;
1834
1857
  const head = await read(0, firstMoof);
1835
1858
  const init = parseInitInfo(head);
1836
1859
  if (init === null) return null;
1837
- const videoIndex = index.trackId === init.videoTrackId ? index : reparseTail(tail, file.size, init.videoTrackId);
1860
+ const videoIndex = index.trackId === init.videoTrackId ? index : parsed === null ? null : reparseTail(parsed.tail, file.size, init.videoTrackId);
1838
1861
  if (videoIndex === null) return null;
1839
1862
  const { start, end, sampleIndex } = fragmentRangeFor(videoIndex, Math.max(0, args.epochMs - args.segmentStartMs) / 1e3 * init.videoTimescale);
1840
1863
  if (start <= 0 || end <= start || end > file.size) return null;
@@ -2262,6 +2285,10 @@ var DEFAULT_BANDWIDTH = 1e6;
2262
2285
  * multiple missing segments) splits the ranges so real discontinuities show.
2263
2286
  */
2264
2287
  var RANGE_MERGE_GAP_MS = 5e3;
2288
+ /** Include the neighboring minute so a segment that starts in the previous
2289
+ * hour but covers the requested epoch is hydrated too. Recorder segments are
2290
+ * currently much shorter; the margin keeps this boundary future-safe. */
2291
+ var LOCATE_HYDRATE_MARGIN_MS = 6e4;
2265
2292
  /** Empty "no playback" manifest. */
2266
2293
  function noPlayback(deviceId) {
2267
2294
  return {
@@ -2611,6 +2638,9 @@ function buildRecordingProvider(deps) {
2611
2638
  },
2612
2639
  getDeviceConfig: async ({ deviceId }) => loadDeviceConfig(deps.configStore, deviceId),
2613
2640
  locateSegment: async ({ deviceId, profile, epochMs }) => {
2641
+ const hydrateFromMs = Math.max(0, epochMs - LOCATE_HYDRATE_MARGIN_MS);
2642
+ const hydrateToMs = epochMs + LOCATE_HYDRATE_MARGIN_MS;
2643
+ if (deps.index.hydrationOf(deviceId, hydrateFromMs, hydrateToMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, hydrateFromMs, hydrateToMs);
2614
2644
  const seg = deps.index.segmentAt(deviceId, profile, epochMs);
2615
2645
  if (seg) return {
2616
2646
  kind: "segment",
@@ -2636,7 +2666,8 @@ function buildRecordingProvider(deps) {
2636
2666
  const gop = await readGopRange(file, {
2637
2667
  segmentStartMs: seg.startMs,
2638
2668
  segmentDurMs: seg.durMs,
2639
- epochMs
2669
+ epochMs,
2670
+ seededIndex: deps.mfraFor?.(deviceId, profile, startMs)
2640
2671
  });
2641
2672
  if (gop !== null) return {
2642
2673
  data: gop.data,
@@ -5405,6 +5436,7 @@ var RecorderV2Addon = class extends BaseAddon {
5405
5436
  capacity: (root) => this.locationCapacity(root),
5406
5437
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
5407
5438
  calendar: this.calendar,
5439
+ mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
5408
5440
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
5409
5441
  onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
5410
5442
  dataDir: this.ctx.dataDir,
@@ -22095,9 +22095,9 @@ var SCRUB_ADJACENT_TOLERANCE_MS = 50;
22095
22095
  * feeder serves, arriving AFTER the landing. 16 at ~60 ms is ~0.6 MB, still
22096
22096
  * reads as travel, and leaves headroom for the landing GOP behind it.
22097
22097
  */
22098
- var SEEK_ANIMATION_BUDGET_MS = 1e3;
22098
+ var SEEK_ANIMATION_BUDGET_MS = 250;
22099
22099
  var SEEK_ANIMATION_TICK_MS = 60;
22100
- var SEEK_ANIMATION_MAX_STILLS = 16;
22100
+ var SEEK_ANIMATION_MAX_STILLS = 4;
22101
22101
  /**
22102
22102
  * Below this distance there is at most ONE intermediate keyframe (the camera
22103
22103
  * GOP is fixed at 4 s) — the landing burst already paints the target, so
@@ -22212,8 +22212,9 @@ var RecordedFeeder = class {
22212
22212
  currentSegment = null;
22213
22213
  /** Index (into `currentSegment.aus`) of the last video AU fed; -1 = none. */
22214
22214
  lastFedVideoIdx = -1;
22215
- /** Serializes frame steps: a step arriving while one is in flight is dropped. */
22215
+ /** Serializes frame steps without dropping rapid taps. */
22216
22216
  stepInFlight = false;
22217
+ pendingStepDirs = [];
22217
22218
  /**
22218
22219
  * Target epoch of the seek currently in flight (null when none). A rate
22219
22220
  * change re-enters via seek(); while a seek is still resolving, `cursorMs`
@@ -22329,6 +22330,11 @@ var RecordedFeeder = class {
22329
22330
  * seek bumps the token and silently abandons this one.
22330
22331
  */
22331
22332
  async seek(epochMs, opts) {
22333
+ const seekStartedAt = performance.now();
22334
+ let locateMs = 0;
22335
+ let travelMs = 0;
22336
+ let loadMs = 0;
22337
+ let firstFrameMs = 0;
22332
22338
  const token = ++this.seekToken;
22333
22339
  const wasScrubbing = this.scrubbing;
22334
22340
  if (this.scrubbing) {
@@ -22355,11 +22361,13 @@ var RecordedFeeder = class {
22355
22361
  this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
22356
22362
  this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
22357
22363
  }
22364
+ const locateStartedAt = performance.now();
22358
22365
  let loc = await this.withTimeout(this.deps.locate({
22359
22366
  deviceId: this.deps.deviceId,
22360
22367
  profile: this.deps.profile,
22361
22368
  epochMs
22362
22369
  }));
22370
+ locateMs += performance.now() - locateStartedAt;
22363
22371
  if (this.disposed || token !== this.seekToken) return;
22364
22372
  let landMs = epochMs;
22365
22373
  if (loc.kind === "gap") {
@@ -22377,11 +22385,13 @@ var RecordedFeeder = class {
22377
22385
  this.deps.onState("gap");
22378
22386
  return;
22379
22387
  }
22388
+ const snapLocateStartedAt = performance.now();
22380
22389
  const snapped = await this.withTimeout(this.deps.locate({
22381
22390
  deviceId: this.deps.deviceId,
22382
22391
  profile: this.deps.profile,
22383
22392
  epochMs: decision.snapMs
22384
22393
  }));
22394
+ locateMs += performance.now() - snapLocateStartedAt;
22385
22395
  if (this.disposed || token !== this.seekToken) return;
22386
22396
  if (snapped.kind !== "segment") {
22387
22397
  this.deps.onLog?.("recorded:seek-no-footage", {
@@ -22407,23 +22417,88 @@ var RecordedFeeder = class {
22407
22417
  }
22408
22418
  const travelFromMs = opts?.travelFromMs ?? (this.clockHasAdvanced ? this.cursorMs : null);
22409
22419
  if (!wasScrubbing && travelFromMs !== null) {
22420
+ const travelStartedAt = performance.now();
22410
22421
  await this.animateSeekTravel(travelFromMs, landMs, token);
22422
+ travelMs = performance.now() - travelStartedAt;
22411
22423
  if (this.disposed || token !== this.seekToken) return;
22412
22424
  }
22413
22425
  this.cursorMs = loc.startMs;
22414
- const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
22426
+ const loadStartedAt = performance.now();
22427
+ let prefixFed = false;
22428
+ let gopFirst = false;
22429
+ let stateAnnounced = false;
22430
+ if (this.deps.readGop) {
22431
+ const cached = this.scrubCache.covering(this.deps.profile, landMs);
22432
+ let fragment;
22433
+ if (cached) fragment = cached;
22434
+ else {
22435
+ const fragmentLoad = await this.loadScrubFragment(loc, landMs);
22436
+ if (fragmentLoad === null) return;
22437
+ fragment = fragmentLoad.seg;
22438
+ this.scrubCache.put(this.deps.profile, fragment);
22439
+ }
22440
+ if (this.disposed || token !== this.seekToken) return;
22441
+ const fragmentLoc = {
22442
+ kind: "segment",
22443
+ startMs: fragment.startMs,
22444
+ durMs: fragment.durMs,
22445
+ bytes: loc.bytes
22446
+ };
22447
+ this.feedPrefix(fragmentLoc, fragment.aus, landMs);
22448
+ prefixFed = true;
22449
+ gopFirst = true;
22450
+ firstFrameMs = performance.now() - seekStartedAt;
22451
+ this.deps.onState(this.rate === 0 ? "paused" : "playing");
22452
+ this.deps.onPosition(this.cursorMs);
22453
+ this.startPositionReports();
22454
+ stateAnnounced = true;
22455
+ }
22456
+ const fullLoad = this.loadAus(loc.startMs, this.rate !== 0, this.deps.readGop ? void 0 : (video) => {
22457
+ if (this.disposed || token !== this.seekToken) return;
22458
+ this.feedPrefix(loc, video, landMs);
22459
+ prefixFed = true;
22460
+ firstFrameMs = performance.now() - seekStartedAt;
22461
+ });
22462
+ fullLoad.catch(() => {});
22463
+ const loaded = await fullLoad;
22464
+ loadMs = performance.now() - loadStartedAt;
22415
22465
  if (this.disposed || token !== this.seekToken || loaded === null) return;
22416
- this.feedPrefix(loc, loaded.video, landMs);
22466
+ if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
22467
+ if (gopFirst) {
22468
+ this.currentSegment = {
22469
+ startMs: loc.startMs,
22470
+ durMs: loc.durMs,
22471
+ aus: loaded.video
22472
+ };
22473
+ const targetPtsMs = (loaded.video[0]?.ptsMs ?? 0) + Math.max(0, this.cursorMs - loc.startMs);
22474
+ this.lastFedVideoIdx = -1;
22475
+ for (let i = 0; i < loaded.video.length; i++) {
22476
+ const au = loaded.video[i];
22477
+ if (!au || au.ptsMs > targetPtsMs) break;
22478
+ this.lastFedVideoIdx = i;
22479
+ }
22480
+ if (this.clockHasAdvanced) {
22481
+ this.clock.markSeek();
22482
+ this.audioClock.markSeek();
22483
+ }
22484
+ }
22417
22485
  if (this.rate === 0) {
22418
- this.deps.onState("paused");
22486
+ if (!stateAnnounced) {
22487
+ this.deps.onState("paused");
22488
+ this.deps.onPosition(this.cursorMs);
22489
+ this.startPositionReports();
22490
+ }
22491
+ this.logSeekReady(epochMs, seekStartedAt, locateMs, travelMs, loadMs, firstFrameMs, "paused");
22492
+ return;
22493
+ }
22494
+ if (!stateAnnounced) {
22495
+ this.deps.onState("playing");
22419
22496
  this.deps.onPosition(this.cursorMs);
22420
22497
  this.startPositionReports();
22421
- return;
22422
22498
  }
22423
- this.deps.onState("playing");
22424
- this.deps.onPosition(this.cursorMs);
22425
- this.startPositionReports();
22426
- const lastFedAu = this.lastFedVideoIdx >= 0 ? loaded.video[this.lastFedVideoIdx] : void 0;
22499
+ this.logSeekReady(epochMs, seekStartedAt, locateMs, travelMs, loadMs, firstFrameMs, "playing");
22500
+ const firstFullAu = loaded.video[0];
22501
+ const resumeAfterPtsMs = gopFirst ? firstFullAu ? firstFullAu.ptsMs + Math.max(0, this.cursorMs - loc.startMs) : null : this.lastFedVideoIdx >= 0 ? loaded.video[this.lastFedVideoIdx]?.ptsMs ?? null : null;
22427
22502
  this.startAdvance({
22428
22503
  prepared: {
22429
22504
  startMs: loc.startMs,
@@ -22431,7 +22506,7 @@ var RecordedFeeder = class {
22431
22506
  aus: loaded.video,
22432
22507
  audio: loaded.audio
22433
22508
  },
22434
- resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
22509
+ resumeAfterPtsMs
22435
22510
  });
22436
22511
  } catch (err) {
22437
22512
  if (!this.disposed && token === this.seekToken) {
@@ -22450,9 +22525,23 @@ var RecordedFeeder = class {
22450
22525
  this.deps.onState("error");
22451
22526
  }
22452
22527
  } finally {
22453
- if (token === this.seekToken) this.inFlightSeekEpochMs = null;
22528
+ if (token === this.seekToken) {
22529
+ this.inFlightSeekEpochMs = null;
22530
+ this.drainStepQueue();
22531
+ }
22454
22532
  }
22455
22533
  }
22534
+ logSeekReady(epochMs, startedAt, locateMs, travelMs, loadMs, firstFrameMs, state) {
22535
+ this.deps.onLog?.("recorded:seek-ready", {
22536
+ epochMs,
22537
+ state,
22538
+ totalMs: Math.round(performance.now() - startedAt),
22539
+ locateMs: Math.round(locateMs),
22540
+ travelMs: Math.round(travelMs),
22541
+ loadMs: Math.round(loadMs),
22542
+ firstFrameMs: Math.round(firstFrameMs)
22543
+ });
22544
+ }
22456
22545
  /**
22457
22546
  * Set playback rate. 0 pauses in place (the pacing loop aborts on its next
22458
22547
  * guard check); any other value is clamped to [0.25, 4].
@@ -22470,10 +22559,12 @@ var RecordedFeeder = class {
22470
22559
  if (next === this.rate) return;
22471
22560
  this.rate = next;
22472
22561
  if (next === 0) {
22562
+ this.advanceToken++;
22473
22563
  this.stopPositionReports();
22474
22564
  this.deps.onState("paused");
22475
22565
  return;
22476
22566
  }
22567
+ this.pendingStepDirs.length = 0;
22477
22568
  this.clock.setRate(next);
22478
22569
  this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
22479
22570
  }
@@ -23079,10 +23170,19 @@ var RecordedFeeder = class {
23079
23170
  * step is still in flight.
23080
23171
  */
23081
23172
  stepFrame(dir) {
23082
- if (this.disposed || this.rate !== 0 || this.stepInFlight || this.scrubbing) return;
23173
+ if (this.disposed || this.scrubbing) return;
23174
+ if (this.rate !== 0) this.setRate(0);
23175
+ if (this.pendingStepDirs.length < 16) this.pendingStepDirs.push(dir);
23176
+ this.drainStepQueue();
23177
+ }
23178
+ drainStepQueue() {
23179
+ if (this.disposed || this.scrubbing || this.rate !== 0 || this.stepInFlight || this.inFlightSeekEpochMs !== null) return;
23180
+ const dir = this.pendingStepDirs.shift();
23181
+ if (dir === void 0) return;
23083
23182
  this.stepInFlight = true;
23084
23183
  this.doStep(dir).catch(() => {}).finally(() => {
23085
23184
  this.stepInFlight = false;
23185
+ this.drainStepQueue();
23086
23186
  });
23087
23187
  }
23088
23188
  async doStep(dir) {
@@ -23407,7 +23507,7 @@ var RecordedFeeder = class {
23407
23507
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
23408
23508
  * never blocks video). `wantAudio: false` skips the audio transcode entirely
23409
23509
  * (paused scrubs would throw it away). */
23410
- async loadAus(startMs, wantAudio = true) {
23510
+ async loadAus(startMs, wantAudio = true, onVideoReady) {
23411
23511
  const token = this.seekToken;
23412
23512
  const bytes = await this.withTimeout(this.deps.readBytes({
23413
23513
  deviceId: this.deps.deviceId,
@@ -23415,17 +23515,13 @@ var RecordedFeeder = class {
23415
23515
  startMs
23416
23516
  }));
23417
23517
  if (this.disposed || token !== this.seekToken) return null;
23418
- const video = await this.withTimeout(this.deps.demux(bytes));
23518
+ const audioPromise = wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0 ? this.withTimeout(this.deps.demuxAudio(bytes)).catch(() => []) : Promise.resolve([]);
23519
+ const videoPromise = this.withTimeout(this.deps.demux(bytes)).then((video) => {
23520
+ if (!this.disposed && token === this.seekToken) onVideoReady?.(video);
23521
+ return video;
23522
+ });
23523
+ const [video, audio] = await Promise.all([videoPromise, audioPromise]);
23419
23524
  if (this.disposed || token !== this.seekToken) return null;
23420
- let audio = [];
23421
- if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
23422
- try {
23423
- audio = await this.withTimeout(this.deps.demuxAudio(bytes));
23424
- } catch {
23425
- audio = [];
23426
- }
23427
- if (this.disposed || token !== this.seekToken) return null;
23428
- }
23429
23525
  return {
23430
23526
  video,
23431
23527
  audio
@@ -23536,10 +23632,12 @@ var TimelineSession = class {
23536
23632
  }
23537
23633
  async handleControl(msg) {
23538
23634
  switch (msg.t) {
23539
- case "playRecorded":
23635
+ case "playRecorded": {
23636
+ const profileChanged = msg.profile !== this.profile;
23540
23637
  this.profile = msg.profile;
23541
- await this.enterRecorded().seek(msg.epoch, { travelFromMs: this.lastPositionMs });
23638
+ await (this.mode === "recorded" && this.feeder && !profileChanged ? this.feeder : this.enterRecorded()).seek(msg.epoch, { travelFromMs: this.lastPositionMs });
23542
23639
  break;
23640
+ }
23543
23641
  case "seek":
23544
23642
  await (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).seek(msg.epoch, { travelFromMs: this.lastPositionMs });
23545
23643
  break;