@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.
- package/dist/recorder/index.js +45 -13
- package/dist/recorder/index.mjs +45 -13
- package/dist/stream-broker/index.js +125 -27
- package/dist/stream-broker/index.mjs +125 -27
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DY1AX2y_.js → MaskShapeCanvas-DI4BY7W2-Bxm34WFr.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-D46Ft0YB.js → MotionZonesSettings-NcxxQN8r-S8uFj2nP.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BLD9p-W3.js → PrivacyMaskSettings-APgPLF7p-nhHVCSoL.js} +1 -1
- package/embed-dist/assets/{index-CWxqVyhE.js → index-hICPL_hd.js} +4 -4
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
package/dist/recorder/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
22098
|
+
var SEEK_ANIMATION_BUDGET_MS = 250;
|
|
22099
22099
|
var SEEK_ANIMATION_TICK_MS = 60;
|
|
22100
|
-
var SEEK_ANIMATION_MAX_STILLS =
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
22424
|
-
|
|
22425
|
-
this.
|
|
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
|
|
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)
|
|
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.
|
|
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
|
|
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;
|