@camstack/addon-pipeline 1.2.54 → 1.2.57

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
  }));
@@ -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",
@@ -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
  }));
@@ -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",
@@ -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,79 @@ 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
+ const fullLoad = this.loadAus(loc.startMs, this.rate !== 0, this.deps.readGop ? void 0 : (video) => {
22430
+ if (this.disposed || token !== this.seekToken) return;
22431
+ this.feedPrefix(loc, video, landMs);
22432
+ prefixFed = true;
22433
+ firstFrameMs = performance.now() - seekStartedAt;
22434
+ });
22435
+ fullLoad.catch(() => {});
22436
+ if (this.deps.readGop) {
22437
+ const cached = this.scrubCache.covering(this.deps.profile, landMs);
22438
+ let fragment;
22439
+ if (cached) fragment = cached;
22440
+ else {
22441
+ const fragmentLoad = await this.loadScrubFragment(loc, landMs);
22442
+ if (fragmentLoad === null) return;
22443
+ fragment = fragmentLoad.seg;
22444
+ this.scrubCache.put(this.deps.profile, fragment);
22445
+ }
22446
+ if (this.disposed || token !== this.seekToken) return;
22447
+ const fragmentLoc = {
22448
+ kind: "segment",
22449
+ startMs: fragment.startMs,
22450
+ durMs: fragment.durMs,
22451
+ bytes: loc.bytes
22452
+ };
22453
+ this.feedPrefix(fragmentLoc, fragment.aus, landMs);
22454
+ prefixFed = true;
22455
+ gopFirst = true;
22456
+ firstFrameMs = performance.now() - seekStartedAt;
22457
+ }
22458
+ const loaded = await fullLoad;
22459
+ loadMs = performance.now() - loadStartedAt;
22415
22460
  if (this.disposed || token !== this.seekToken || loaded === null) return;
22416
- this.feedPrefix(loc, loaded.video, landMs);
22461
+ if (!prefixFed) this.feedPrefix(loc, loaded.video, landMs);
22462
+ if (gopFirst) {
22463
+ this.currentSegment = {
22464
+ startMs: loc.startMs,
22465
+ durMs: loc.durMs,
22466
+ aus: loaded.video
22467
+ };
22468
+ const targetPtsMs = (loaded.video[0]?.ptsMs ?? 0) + Math.max(0, this.cursorMs - loc.startMs);
22469
+ this.lastFedVideoIdx = -1;
22470
+ for (let i = 0; i < loaded.video.length; i++) {
22471
+ const au = loaded.video[i];
22472
+ if (!au || au.ptsMs > targetPtsMs) break;
22473
+ this.lastFedVideoIdx = i;
22474
+ }
22475
+ if (this.clockHasAdvanced) {
22476
+ this.clock.markSeek();
22477
+ this.audioClock.markSeek();
22478
+ }
22479
+ }
22417
22480
  if (this.rate === 0) {
22418
22481
  this.deps.onState("paused");
22419
22482
  this.deps.onPosition(this.cursorMs);
22420
22483
  this.startPositionReports();
22484
+ this.logSeekReady(epochMs, seekStartedAt, locateMs, travelMs, loadMs, firstFrameMs, "paused");
22421
22485
  return;
22422
22486
  }
22423
22487
  this.deps.onState("playing");
22424
22488
  this.deps.onPosition(this.cursorMs);
22425
22489
  this.startPositionReports();
22426
- const lastFedAu = this.lastFedVideoIdx >= 0 ? loaded.video[this.lastFedVideoIdx] : void 0;
22490
+ this.logSeekReady(epochMs, seekStartedAt, locateMs, travelMs, loadMs, firstFrameMs, "playing");
22491
+ const firstFullAu = loaded.video[0];
22492
+ 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
22493
  this.startAdvance({
22428
22494
  prepared: {
22429
22495
  startMs: loc.startMs,
@@ -22431,7 +22497,7 @@ var RecordedFeeder = class {
22431
22497
  aus: loaded.video,
22432
22498
  audio: loaded.audio
22433
22499
  },
22434
- resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
22500
+ resumeAfterPtsMs
22435
22501
  });
22436
22502
  } catch (err) {
22437
22503
  if (!this.disposed && token === this.seekToken) {
@@ -22450,9 +22516,23 @@ var RecordedFeeder = class {
22450
22516
  this.deps.onState("error");
22451
22517
  }
22452
22518
  } finally {
22453
- if (token === this.seekToken) this.inFlightSeekEpochMs = null;
22519
+ if (token === this.seekToken) {
22520
+ this.inFlightSeekEpochMs = null;
22521
+ this.drainStepQueue();
22522
+ }
22454
22523
  }
22455
22524
  }
22525
+ logSeekReady(epochMs, startedAt, locateMs, travelMs, loadMs, firstFrameMs, state) {
22526
+ this.deps.onLog?.("recorded:seek-ready", {
22527
+ epochMs,
22528
+ state,
22529
+ totalMs: Math.round(performance.now() - startedAt),
22530
+ locateMs: Math.round(locateMs),
22531
+ travelMs: Math.round(travelMs),
22532
+ loadMs: Math.round(loadMs),
22533
+ firstFrameMs: Math.round(firstFrameMs)
22534
+ });
22535
+ }
22456
22536
  /**
22457
22537
  * Set playback rate. 0 pauses in place (the pacing loop aborts on its next
22458
22538
  * guard check); any other value is clamped to [0.25, 4].
@@ -22470,10 +22550,12 @@ var RecordedFeeder = class {
22470
22550
  if (next === this.rate) return;
22471
22551
  this.rate = next;
22472
22552
  if (next === 0) {
22553
+ this.advanceToken++;
22473
22554
  this.stopPositionReports();
22474
22555
  this.deps.onState("paused");
22475
22556
  return;
22476
22557
  }
22558
+ this.pendingStepDirs.length = 0;
22477
22559
  this.clock.setRate(next);
22478
22560
  this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
22479
22561
  }
@@ -23079,10 +23161,19 @@ var RecordedFeeder = class {
23079
23161
  * step is still in flight.
23080
23162
  */
23081
23163
  stepFrame(dir) {
23082
- if (this.disposed || this.rate !== 0 || this.stepInFlight || this.scrubbing) return;
23164
+ if (this.disposed || this.scrubbing) return;
23165
+ if (this.rate !== 0) this.setRate(0);
23166
+ if (this.pendingStepDirs.length < 16) this.pendingStepDirs.push(dir);
23167
+ this.drainStepQueue();
23168
+ }
23169
+ drainStepQueue() {
23170
+ if (this.disposed || this.scrubbing || this.rate !== 0 || this.stepInFlight || this.inFlightSeekEpochMs !== null) return;
23171
+ const dir = this.pendingStepDirs.shift();
23172
+ if (dir === void 0) return;
23083
23173
  this.stepInFlight = true;
23084
23174
  this.doStep(dir).catch(() => {}).finally(() => {
23085
23175
  this.stepInFlight = false;
23176
+ this.drainStepQueue();
23086
23177
  });
23087
23178
  }
23088
23179
  async doStep(dir) {
@@ -23407,7 +23498,7 @@ var RecordedFeeder = class {
23407
23498
  * (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
23408
23499
  * never blocks video). `wantAudio: false` skips the audio transcode entirely
23409
23500
  * (paused scrubs would throw it away). */
23410
- async loadAus(startMs, wantAudio = true) {
23501
+ async loadAus(startMs, wantAudio = true, onVideoReady) {
23411
23502
  const token = this.seekToken;
23412
23503
  const bytes = await this.withTimeout(this.deps.readBytes({
23413
23504
  deviceId: this.deps.deviceId,
@@ -23415,17 +23506,13 @@ var RecordedFeeder = class {
23415
23506
  startMs
23416
23507
  }));
23417
23508
  if (this.disposed || token !== this.seekToken) return null;
23418
- const video = await this.withTimeout(this.deps.demux(bytes));
23509
+ const audioPromise = wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0 ? this.withTimeout(this.deps.demuxAudio(bytes)).catch(() => []) : Promise.resolve([]);
23510
+ const videoPromise = this.withTimeout(this.deps.demux(bytes)).then((video) => {
23511
+ if (!this.disposed && token === this.seekToken) onVideoReady?.(video);
23512
+ return video;
23513
+ });
23514
+ const [video, audio] = await Promise.all([videoPromise, audioPromise]);
23419
23515
  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
23516
  return {
23430
23517
  video,
23431
23518
  audio
@@ -23536,10 +23623,12 @@ var TimelineSession = class {
23536
23623
  }
23537
23624
  async handleControl(msg) {
23538
23625
  switch (msg.t) {
23539
- case "playRecorded":
23626
+ case "playRecorded": {
23627
+ const profileChanged = msg.profile !== this.profile;
23540
23628
  this.profile = msg.profile;
23541
- await this.enterRecorded().seek(msg.epoch, { travelFromMs: this.lastPositionMs });
23629
+ await (this.mode === "recorded" && this.feeder && !profileChanged ? this.feeder : this.enterRecorded()).seek(msg.epoch, { travelFromMs: this.lastPositionMs });
23542
23630
  break;
23631
+ }
23543
23632
  case "seek":
23544
23633
  await (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).seek(msg.epoch, { travelFromMs: this.lastPositionMs });
23545
23634
  break;