@camstack/addon-post-analysis 1.2.91 → 1.2.93
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/{dist-CquggoRC.mjs → dist-3QRyOemL.mjs} +353 -20
- package/dist/{dist-CTIUuGRB.js → dist-DYoWeRRt.js} +370 -19
- package/dist/embedding-encoder/index.js +171 -4
- package/dist/embedding-encoder/index.mjs +171 -4
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Q3EjHtct.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C4-ljPpt.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-D2EZ3nOf.mjs → hostInit-BoUejl7E.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +389 -160
- package/dist/pipeline-analytics/index.mjs +352 -123
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +3 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-3QRyOemL.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -8282,6 +8282,8 @@ var en_default = {
|
|
|
8282
8282
|
"system.node-offline.body.worker": "Worker {{nodeId}} is offline",
|
|
8283
8283
|
"system.node-inference-unavailable.title": "Node has no usable inference device",
|
|
8284
8284
|
"system.node-inference-unavailable.body": "Node {{nodeId}} can no longer run inference — every enabled device is unavailable ({{devices}}). It will not receive cameras until one returns.",
|
|
8285
|
+
"system.addon-crash-loop.title": "Addon stopped after repeated crashes",
|
|
8286
|
+
"system.addon-crash-loop.body": "The {{addons}} addon on node {{nodeId}} crashed {{count}} times in a row and will NOT be restarted. Everything it provides is offline until it is repaired.",
|
|
8285
8287
|
"system.detection-blind.title": "Camera detecting nothing",
|
|
8286
8288
|
"system.detection-blind.body": {
|
|
8287
8289
|
"one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
|
|
@@ -8494,6 +8496,8 @@ var it_default = {
|
|
|
8494
8496
|
"system.node-offline.body.worker": "Il worker {{nodeId}} è offline",
|
|
8495
8497
|
"system.node-inference-unavailable.title": "Nodo senza dispositivi di inferenza utilizzabili",
|
|
8496
8498
|
"system.node-inference-unavailable.body": "Il nodo {{nodeId}} non può più eseguire inferenza — tutti i dispositivi abilitati non sono disponibili ({{devices}}). Non riceverà camere finché uno non torna disponibile.",
|
|
8499
|
+
"system.addon-crash-loop.title": "Addon fermato dopo ripetuti crash",
|
|
8500
|
+
"system.addon-crash-loop.body": "L'addon {{addons}} sul nodo {{nodeId}} è andato in crash {{count}} volte di seguito e NON verrà riavviato. Tutto ciò che fornisce resta offline finché non viene riparato.",
|
|
8497
8501
|
"system.detection-blind.title": "Camera che non rileva nulla",
|
|
8498
8502
|
"system.detection-blind.body": {
|
|
8499
8503
|
"one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
|
|
@@ -10266,6 +10270,27 @@ function incomingFromSystemEvent(event, lookup) {
|
|
|
10266
10270
|
nodeId
|
|
10267
10271
|
});
|
|
10268
10272
|
}
|
|
10273
|
+
case "addon.runner-failed": {
|
|
10274
|
+
const nodeId = stringField(data, "nodeId");
|
|
10275
|
+
const runnerId = stringField(data, "runnerId");
|
|
10276
|
+
const crashes = numberField(data, "crashesInWindow");
|
|
10277
|
+
if (nodeId === void 0 || runnerId === void 0 || crashes === void 0) return null;
|
|
10278
|
+
const ids = data["addonIds"];
|
|
10279
|
+
const addons = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
10280
|
+
return systemSubject({
|
|
10281
|
+
facts,
|
|
10282
|
+
kind: "addon-crash-loop",
|
|
10283
|
+
subject: `addon-crash:${nodeId}:${runnerId}`,
|
|
10284
|
+
titleKey: "system.addon-crash-loop.title",
|
|
10285
|
+
bodyKey: "system.addon-crash-loop.body",
|
|
10286
|
+
textParams: {
|
|
10287
|
+
nodeId,
|
|
10288
|
+
addons: addons.length > 0 ? addons.join(", ") : runnerId,
|
|
10289
|
+
count: String(crashes)
|
|
10290
|
+
},
|
|
10291
|
+
nodeId
|
|
10292
|
+
});
|
|
10293
|
+
}
|
|
10269
10294
|
case "pipeline.detection-blind": {
|
|
10270
10295
|
const deviceId = numberField(data, "deviceId");
|
|
10271
10296
|
const blindForMs = numberField(data, "blindForMs");
|
|
@@ -21918,6 +21943,9 @@ function systemEventKeys(kind) {
|
|
|
21918
21943
|
}
|
|
21919
21944
|
/** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
|
|
21920
21945
|
var TEST_ALARM_MODE = "away";
|
|
21946
|
+
/** The runner a synthetic `addon-crash-loop` stands in for. A test fire has no
|
|
21947
|
+
* real crashed runner, and a blank one would render "The addon on node hub". */
|
|
21948
|
+
var TEST_CRASHED_RUNNER = "recorder";
|
|
21921
21949
|
/** The dedup subject the live intake stamps for this kind — same families. */
|
|
21922
21950
|
function systemEventSubject(kind, scope) {
|
|
21923
21951
|
if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
|
|
@@ -21926,6 +21954,7 @@ function systemEventSubject(kind, scope) {
|
|
|
21926
21954
|
if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
|
|
21927
21955
|
if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
|
|
21928
21956
|
if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
|
|
21957
|
+
if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
|
|
21929
21958
|
if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
|
|
21930
21959
|
if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
|
|
21931
21960
|
if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
|
|
@@ -22042,6 +22071,7 @@ function planRuleTest(input) {
|
|
|
22042
22071
|
seconds: "30",
|
|
22043
22072
|
devices: "cuda:0",
|
|
22044
22073
|
minutes: "15",
|
|
22074
|
+
addons: TEST_CRASHED_RUNNER,
|
|
22045
22075
|
list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
|
|
22046
22076
|
nodes: "hub",
|
|
22047
22077
|
fromVersion: "0.0.0",
|
|
@@ -28833,51 +28863,6 @@ var PlateGalleryProvider = class {
|
|
|
28833
28863
|
};
|
|
28834
28864
|
}
|
|
28835
28865
|
};
|
|
28836
|
-
var OrphanAuditActionInputSchema = object({
|
|
28837
|
-
mode: _enum(["report", "reclaim"]).default("report"),
|
|
28838
|
-
/**
|
|
28839
|
-
* Start every scope from the top instead of resuming its persisted cursor.
|
|
28840
|
-
*
|
|
28841
|
-
* What draining a backlog wants, and never the default — the cursor exists
|
|
28842
|
-
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
28843
|
-
*/
|
|
28844
|
-
restart: boolean().optional(),
|
|
28845
|
-
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
28846
|
-
pageSize: number().int().min(50).max(5e3).optional(),
|
|
28847
|
-
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
28848
|
-
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
28849
|
-
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
28850
|
-
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
28851
|
-
/**
|
|
28852
|
-
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
28853
|
-
* exclusively save rows, never select them, which is what keeps this off
|
|
28854
|
-
* the one-age-keyed-sweep guard's books.
|
|
28855
|
-
*/
|
|
28856
|
-
graceMinutes: number().int().min(1).max(10080).optional()
|
|
28857
|
-
});
|
|
28858
|
-
var OrphanAuditScopeReportSchema = object({
|
|
28859
|
-
collection: string(),
|
|
28860
|
-
examined: number(),
|
|
28861
|
-
orphans: number(),
|
|
28862
|
-
reclaimed: number(),
|
|
28863
|
-
bytesReclaimed: number(),
|
|
28864
|
-
exempt: number(),
|
|
28865
|
-
withinGrace: number(),
|
|
28866
|
-
unresolvable: number(),
|
|
28867
|
-
stoppedBy: string(),
|
|
28868
|
-
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
28869
|
-
nextCursor: number()
|
|
28870
|
-
});
|
|
28871
|
-
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
28872
|
-
/** `false` when a pass was already in flight — nothing was done. */
|
|
28873
|
-
ran: boolean(),
|
|
28874
|
-
mode: _enum(["report", "reclaim"]),
|
|
28875
|
-
scopes: array(OrphanAuditScopeReportSchema),
|
|
28876
|
-
totalOrphans: number(),
|
|
28877
|
-
totalReclaimed: number(),
|
|
28878
|
-
totalBytesReclaimed: number(),
|
|
28879
|
-
durationMs: number()
|
|
28880
|
-
}), { auth: "admin" }) });
|
|
28881
28866
|
//#endregion
|
|
28882
28867
|
//#region src/pipeline-analytics/store/storage-pressure.ts
|
|
28883
28868
|
/**
|
|
@@ -29045,6 +29030,11 @@ var FACE_MEDIA_OWNER_PREFIX = "face-";
|
|
|
29045
29030
|
* `plate-<trackId>`. Used by `deleteByTracks` to derive the plate crop owners
|
|
29046
29031
|
* of a set of tracks without a `trackId` column on media rows. */
|
|
29047
29032
|
var PLATE_MEDIA_OWNER_PREFIX = "plate-";
|
|
29033
|
+
/** Index rows per page inside one presence chunk. Must stay under
|
|
29034
|
+
* `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000) so the engine's backstop can never
|
|
29035
|
+
* silently truncate a page — an owner reported absent because a cap bit would
|
|
29036
|
+
* DELETE a live thumbnail from the surface. */
|
|
29037
|
+
var MEDIA_PRESENCE_PAGE_SIZE = 1e3;
|
|
29048
29038
|
/**
|
|
29049
29039
|
* Kinds that hold exactly ONE row per `(ownerKind, ownerId)` — the current
|
|
29050
29040
|
* "best"/rolling/first artefact, never a filmstrip. Their row id + blob path are
|
|
@@ -29472,6 +29462,59 @@ var MediaStore = class {
|
|
|
29472
29462
|
});
|
|
29473
29463
|
}
|
|
29474
29464
|
/**
|
|
29465
|
+
* Which of `ownerIds` hold at least ONE media row — index only, no blob is
|
|
29466
|
+
* opened, no base64 is allocated.
|
|
29467
|
+
*
|
|
29468
|
+
* This is the licence `videoclips` needs to publish a `thumbnail`. The
|
|
29469
|
+
* event-media URL is stamped from a template for every event in the system,
|
|
29470
|
+
* so "the plane is up" was being served as "there are bytes": 64 of one
|
|
29471
|
+
* camera's 179 clips pointed at a 404 on 2026-08-17.
|
|
29472
|
+
*
|
|
29473
|
+
* Batched by `whereIn` and paged by offset. NOT `listInfoByOwner` in a loop:
|
|
29474
|
+
* that is one query per owner, which is the N+1 this exists to prevent (and
|
|
29475
|
+
* `queryObjectByTrackIds` already records what believing `whereIn` was
|
|
29476
|
+
* unavailable cost the search path).
|
|
29477
|
+
*
|
|
29478
|
+
* `deviceId` is the AUTHORIZATION filter, exactly as in `listByOwner`.
|
|
29479
|
+
*
|
|
29480
|
+
* THROWS on a query failure rather than answering partially — a probe that
|
|
29481
|
+
* quietly under-reports reads as "these owners have nothing", and the caller
|
|
29482
|
+
* drops thumbnails that exist.
|
|
29483
|
+
*/
|
|
29484
|
+
async ownersWithMedia(ownerKind, ownerIds, deviceId) {
|
|
29485
|
+
const found = /* @__PURE__ */ new Set();
|
|
29486
|
+
const unique = [...new Set(ownerIds)];
|
|
29487
|
+
for (let i = 0; i < unique.length; i += 200) {
|
|
29488
|
+
const chunk = unique.slice(i, i + 200);
|
|
29489
|
+
const answered = /* @__PURE__ */ new Set();
|
|
29490
|
+
for (let offset = 0;; offset += MEDIA_PRESENCE_PAGE_SIZE) {
|
|
29491
|
+
const rows = await this.store.query.query({
|
|
29492
|
+
collection: MEDIA_COLLECTION,
|
|
29493
|
+
filter: {
|
|
29494
|
+
where: {
|
|
29495
|
+
ownerKind,
|
|
29496
|
+
deviceId
|
|
29497
|
+
},
|
|
29498
|
+
whereIn: { ownerId: chunk },
|
|
29499
|
+
orderBy: {
|
|
29500
|
+
field: "ownerId",
|
|
29501
|
+
direction: "asc"
|
|
29502
|
+
},
|
|
29503
|
+
limit: MEDIA_PRESENCE_PAGE_SIZE,
|
|
29504
|
+
offset
|
|
29505
|
+
}
|
|
29506
|
+
});
|
|
29507
|
+
for (const row of rows) {
|
|
29508
|
+
const ownerId = row.data["ownerId"];
|
|
29509
|
+
if (typeof ownerId === "string") answered.add(ownerId);
|
|
29510
|
+
}
|
|
29511
|
+
if (rows.length < 1e3 || answered.size >= chunk.length) break;
|
|
29512
|
+
}
|
|
29513
|
+
for (const ownerId of answered) found.add(ownerId);
|
|
29514
|
+
}
|
|
29515
|
+
return found;
|
|
29516
|
+
}
|
|
29517
|
+
/**
|
|
29475
29518
|
* Delete all media (blobs + index rows) for the given owners of one kind.
|
|
29476
29519
|
* Best-effort per row; returns the number of index rows removed.
|
|
29477
29520
|
*/
|
|
@@ -29818,6 +29861,51 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
|
|
|
29818
29861
|
events: doomedEvents.length
|
|
29819
29862
|
};
|
|
29820
29863
|
}
|
|
29864
|
+
var OrphanAuditActionInputSchema = object({
|
|
29865
|
+
mode: _enum(["report", "reclaim"]).default("report"),
|
|
29866
|
+
/**
|
|
29867
|
+
* Start every scope from the top instead of resuming its persisted cursor.
|
|
29868
|
+
*
|
|
29869
|
+
* What draining a backlog wants, and never the default — the cursor exists
|
|
29870
|
+
* so a 6-hourly pass makes progress instead of re-walking the same head.
|
|
29871
|
+
*/
|
|
29872
|
+
restart: boolean().optional(),
|
|
29873
|
+
/** Rows read per statement. Smaller = more yields = a gentler pass. */
|
|
29874
|
+
pageSize: number().int().min(50).max(5e3).optional(),
|
|
29875
|
+
/** Rows EXAMINED per collection in this pass — the pacing bound. */
|
|
29876
|
+
maxRowsPerScope: number().int().min(1).max(2e5).optional(),
|
|
29877
|
+
/** Rows DELETED per collection in this pass — the blast-radius bound. */
|
|
29878
|
+
maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
|
|
29879
|
+
/**
|
|
29880
|
+
* Minutes of immunity for a recent row. Protective ONLY — raising it can
|
|
29881
|
+
* exclusively save rows, never select them, which is what keeps this off
|
|
29882
|
+
* the one-age-keyed-sweep guard's books.
|
|
29883
|
+
*/
|
|
29884
|
+
graceMinutes: number().int().min(1).max(10080).optional()
|
|
29885
|
+
});
|
|
29886
|
+
var OrphanAuditScopeReportSchema = object({
|
|
29887
|
+
collection: string(),
|
|
29888
|
+
examined: number(),
|
|
29889
|
+
orphans: number(),
|
|
29890
|
+
reclaimed: number(),
|
|
29891
|
+
bytesReclaimed: number(),
|
|
29892
|
+
exempt: number(),
|
|
29893
|
+
withinGrace: number(),
|
|
29894
|
+
unresolvable: number(),
|
|
29895
|
+
stoppedBy: string(),
|
|
29896
|
+
/** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
|
|
29897
|
+
nextCursor: number()
|
|
29898
|
+
});
|
|
29899
|
+
var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
|
|
29900
|
+
/** `false` when a pass was already in flight — nothing was done. */
|
|
29901
|
+
ran: boolean(),
|
|
29902
|
+
mode: _enum(["report", "reclaim"]),
|
|
29903
|
+
scopes: array(OrphanAuditScopeReportSchema),
|
|
29904
|
+
totalOrphans: number(),
|
|
29905
|
+
totalReclaimed: number(),
|
|
29906
|
+
totalBytesReclaimed: number(),
|
|
29907
|
+
durationMs: number()
|
|
29908
|
+
}), { auth: "admin" }) });
|
|
29821
29909
|
//#endregion
|
|
29822
29910
|
//#region src/pipeline-analytics/videoclip-visits.ts
|
|
29823
29911
|
/**
|
|
@@ -29885,7 +29973,22 @@ function dominantKind(attached) {
|
|
|
29885
29973
|
for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
|
|
29886
29974
|
return "motion";
|
|
29887
29975
|
}
|
|
29888
|
-
|
|
29976
|
+
/**
|
|
29977
|
+
* The instant a recorded still can actually be decoded inside a visit: the
|
|
29978
|
+
* midpoint of its LONGEST contiguous range.
|
|
29979
|
+
*
|
|
29980
|
+
* NOT the midpoint of the visit. A visit spans its holes by construction (it is
|
|
29981
|
+
* ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
|
|
29982
|
+
* lands in dead air roughly as often as not and the recorder's still route
|
|
29983
|
+
* answers 204 for a visit with plenty of footage.
|
|
29984
|
+
*/
|
|
29985
|
+
function stillInstantMs(ranges) {
|
|
29986
|
+
let best;
|
|
29987
|
+
for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
|
|
29988
|
+
if (best === void 0) return void 0;
|
|
29989
|
+
return Math.floor((best.startMs + best.endMs) / 2);
|
|
29990
|
+
}
|
|
29991
|
+
function visitsToClips(visits, events, pad, vouched) {
|
|
29889
29992
|
const tagged = [
|
|
29890
29993
|
...events.object.map((event) => ({
|
|
29891
29994
|
kind: "object",
|
|
@@ -29903,7 +30006,8 @@ function visitsToClips(visits, events, pad) {
|
|
|
29903
30006
|
return visits.map((visit) => {
|
|
29904
30007
|
const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
|
|
29905
30008
|
const kind = dominantKind(attached);
|
|
29906
|
-
const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
|
|
30009
|
+
const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
|
|
30010
|
+
const stillAtMs = stillInstantMs(visit.ranges);
|
|
29907
30011
|
return {
|
|
29908
30012
|
id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
|
|
29909
30013
|
source: "analytics",
|
|
@@ -29912,8 +30016,10 @@ function visitsToClips(visits, events, pad) {
|
|
|
29912
30016
|
startMs: visit.startMs,
|
|
29913
30017
|
endMs: visit.endMs
|
|
29914
30018
|
},
|
|
29915
|
-
thumbnail: withThumb
|
|
29916
|
-
|
|
30019
|
+
...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
|
|
30020
|
+
...stillAtMs !== void 0 ? { stillAtMs } : {},
|
|
30021
|
+
eventIds: attached.slice(0, 24).map((a) => a.event.id),
|
|
30022
|
+
eventCount: attached.length,
|
|
29917
30023
|
holes: [...visit.holes]
|
|
29918
30024
|
};
|
|
29919
30025
|
}).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
@@ -29937,6 +30043,17 @@ function visitsToClips(visits, events, pad) {
|
|
|
29937
30043
|
* injected fetch/resolve deps so the compose logic is testable without I/O.
|
|
29938
30044
|
*/
|
|
29939
30045
|
var SOURCE = "analytics";
|
|
30046
|
+
/**
|
|
30047
|
+
* Backstop on the ANNOTATION read — never on the clip list.
|
|
30048
|
+
*
|
|
30049
|
+
* A visit is annotated by every event inside it, so the clip `limit` must not
|
|
30050
|
+
* reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
|
|
30051
|
+
* bound exists only so a pathological camera-day cannot pull an unbounded page:
|
|
30052
|
+
* the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
|
|
30053
|
+
* the EARLIEST visits silently lose their annotations — which is why hitting it
|
|
30054
|
+
* is logged, per camera, rather than absorbed.
|
|
30055
|
+
*/
|
|
30056
|
+
var VISIT_EVENT_FETCH_LIMIT = 5e3;
|
|
29940
30057
|
function clipIdFor(eventId, startMs, endMs) {
|
|
29941
30058
|
return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
|
|
29942
30059
|
}
|
|
@@ -29957,7 +30074,7 @@ function parseClipId(clipId) {
|
|
|
29957
30074
|
};
|
|
29958
30075
|
}
|
|
29959
30076
|
/** Map per-kind events into time-ranged clips, newest-first. Pure. */
|
|
29960
|
-
function eventsToClips(events, pad) {
|
|
30077
|
+
function eventsToClips(events, pad, vouched) {
|
|
29961
30078
|
const make = (e, kind) => {
|
|
29962
30079
|
const startMs = e.timestamp - pad.preMs;
|
|
29963
30080
|
const endMs = e.timestamp + pad.postMs;
|
|
@@ -29969,7 +30086,7 @@ function eventsToClips(events, pad) {
|
|
|
29969
30086
|
startMs,
|
|
29970
30087
|
endMs
|
|
29971
30088
|
},
|
|
29972
|
-
thumbnail: e.mediaUrl
|
|
30089
|
+
...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
|
|
29973
30090
|
};
|
|
29974
30091
|
};
|
|
29975
30092
|
return [
|
|
@@ -29978,29 +30095,106 @@ function eventsToClips(events, pad) {
|
|
|
29978
30095
|
...events.audio.map((e) => make(e, "audio"))
|
|
29979
30096
|
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
29980
30097
|
}
|
|
30098
|
+
/** Every id in the three buckets, in the order the composers tag them. */
|
|
30099
|
+
function allEventIds(events) {
|
|
30100
|
+
return [
|
|
30101
|
+
...events.motion,
|
|
30102
|
+
...events.object,
|
|
30103
|
+
...events.audio
|
|
30104
|
+
].map((e) => e.id);
|
|
30105
|
+
}
|
|
30106
|
+
/**
|
|
30107
|
+
* Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
|
|
30108
|
+
* `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
|
|
30109
|
+
* owning TRACK's. Two batched index reads at most, and the second only over the
|
|
30110
|
+
* object events the first did not already answer for — motion and audio are
|
|
30111
|
+
* trackless roots, so they never need it.
|
|
30112
|
+
*
|
|
30113
|
+
* THROWS on a failed read. The caller fails closed on the CLAIM and open on the
|
|
30114
|
+
* LIST; swallowing here would collapse those two opposite directions into one.
|
|
30115
|
+
*/
|
|
30116
|
+
async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
|
|
30117
|
+
const eventIds = allEventIds(events);
|
|
30118
|
+
if (eventIds.length === 0) return /* @__PURE__ */ new Set();
|
|
30119
|
+
const ownMedia = await fetchMediaOwners({
|
|
30120
|
+
deviceId,
|
|
30121
|
+
ownerKind: "event",
|
|
30122
|
+
ownerIds: eventIds
|
|
30123
|
+
});
|
|
30124
|
+
const residualTracks = [];
|
|
30125
|
+
const seenTracks = /* @__PURE__ */ new Set();
|
|
30126
|
+
for (const event of events.object) {
|
|
30127
|
+
const trackId = event.trackId;
|
|
30128
|
+
if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
|
|
30129
|
+
seenTracks.add(trackId);
|
|
30130
|
+
residualTracks.push(trackId);
|
|
30131
|
+
}
|
|
30132
|
+
const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
|
|
30133
|
+
deviceId,
|
|
30134
|
+
ownerKind: "track",
|
|
30135
|
+
ownerIds: residualTracks
|
|
30136
|
+
});
|
|
30137
|
+
const vouched = new Set(ownMedia);
|
|
30138
|
+
for (const event of events.object) {
|
|
30139
|
+
const trackId = event.trackId;
|
|
30140
|
+
if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
|
|
30141
|
+
}
|
|
30142
|
+
return vouched;
|
|
30143
|
+
}
|
|
30144
|
+
/** Keep only clips that actually touch the caller's window. The reads are
|
|
30145
|
+
* widened by {@link leftOverhangMs}; the ANSWER is not. */
|
|
30146
|
+
function intersectsWindow(clip, since, until) {
|
|
30147
|
+
return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
|
|
30148
|
+
}
|
|
30149
|
+
/**
|
|
30150
|
+
* How far BEFORE `since` the event + availability reads must reach.
|
|
30151
|
+
*
|
|
30152
|
+
* A visit straddling `since` keeps its heartbeat rows and loses the transition
|
|
30153
|
+
* row — the only one that ever carries a snapshot — so the same visit shows a
|
|
30154
|
+
* picture or doesn't depending on where the operator scrolled. One wider range
|
|
30155
|
+
* scan on an indexed column buys a list that stops changing under the cursor.
|
|
30156
|
+
*/
|
|
30157
|
+
function leftOverhangMs(pad) {
|
|
30158
|
+
return VISIT_MERGE_GAP_MS + pad.preMs;
|
|
30159
|
+
}
|
|
29981
30160
|
function createVideoclipsProvider(deps) {
|
|
29982
30161
|
return {
|
|
29983
30162
|
listClips: async ({ deviceId, since, until, limit }) => {
|
|
30163
|
+
const readSince = since - leftOverhangMs(deps.defaultPad);
|
|
29984
30164
|
const events = await deps.fetchEvents({
|
|
29985
30165
|
deviceId,
|
|
29986
|
-
since,
|
|
30166
|
+
since: readSince,
|
|
29987
30167
|
until,
|
|
29988
|
-
|
|
30168
|
+
limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
|
|
29989
30169
|
});
|
|
29990
|
-
|
|
30170
|
+
warnOnTruncatedFetch(deps, deviceId, events);
|
|
30171
|
+
let vouched = /* @__PURE__ */ new Set();
|
|
30172
|
+
try {
|
|
30173
|
+
vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
|
|
30174
|
+
} catch (err) {
|
|
30175
|
+
deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
|
|
30176
|
+
deviceId,
|
|
30177
|
+
events: allEventIds(events).length,
|
|
30178
|
+
error: err instanceof Error ? err.message : String(err)
|
|
30179
|
+
});
|
|
30180
|
+
}
|
|
30181
|
+
let clips = eventsToClips(events, deps.defaultPad, vouched);
|
|
29991
30182
|
if (deps.fetchAvailability) try {
|
|
29992
30183
|
clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
|
|
29993
30184
|
deviceId,
|
|
29994
|
-
since,
|
|
30185
|
+
since: readSince,
|
|
29995
30186
|
until
|
|
29996
|
-
}), VISIT_MERGE_GAP_MS), events, deps.defaultPad);
|
|
30187
|
+
}), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
|
|
29997
30188
|
} catch (err) {
|
|
29998
30189
|
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
29999
30190
|
deviceId,
|
|
30000
30191
|
error: err instanceof Error ? err.message : String(err)
|
|
30001
30192
|
});
|
|
30002
30193
|
}
|
|
30003
|
-
|
|
30194
|
+
const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
|
|
30195
|
+
const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
|
|
30196
|
+
reportUnvouchedClips(deps, deviceId, answer);
|
|
30197
|
+
return answer;
|
|
30004
30198
|
},
|
|
30005
30199
|
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
30006
30200
|
const parsed = parseClipId(clipId);
|
|
@@ -30013,6 +30207,33 @@ function createVideoclipsProvider(deps) {
|
|
|
30013
30207
|
}
|
|
30014
30208
|
};
|
|
30015
30209
|
}
|
|
30210
|
+
/** The annotation backstop biting is a SILENT loss of the earliest visits'
|
|
30211
|
+
* events — say so, per camera, instead of shipping a thinner list. */
|
|
30212
|
+
function warnOnTruncatedFetch(deps, deviceId, events) {
|
|
30213
|
+
if (!deps.fetchAvailability) return;
|
|
30214
|
+
const truncated = [
|
|
30215
|
+
"motion",
|
|
30216
|
+
"object",
|
|
30217
|
+
"audio"
|
|
30218
|
+
].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
|
|
30219
|
+
if (truncated.length === 0) return;
|
|
30220
|
+
deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
|
|
30221
|
+
deviceId,
|
|
30222
|
+
kinds: truncated,
|
|
30223
|
+
limit: VISIT_EVENT_FETCH_LIMIT
|
|
30224
|
+
});
|
|
30225
|
+
}
|
|
30226
|
+
/** The operator's acceptance criterion as a number: a rising count on one
|
|
30227
|
+
* camera is its motion-snapshot capturer failing, not a quiet day. */
|
|
30228
|
+
function reportUnvouchedClips(deps, deviceId, clips) {
|
|
30229
|
+
const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
|
|
30230
|
+
if (clipsWithoutStill === 0) return;
|
|
30231
|
+
deps.logInfo?.("videoclips: visit has no vouched still", {
|
|
30232
|
+
deviceId,
|
|
30233
|
+
clipsWithoutStill,
|
|
30234
|
+
clips: clips.length
|
|
30235
|
+
});
|
|
30236
|
+
}
|
|
30016
30237
|
//#endregion
|
|
30017
30238
|
//#region src/pipeline-analytics/viewer-settings-actions.ts
|
|
30018
30239
|
/**
|
|
@@ -45858,6 +46079,7 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
|
|
|
45858
46079
|
EventCategory.WorkerOffline,
|
|
45859
46080
|
EventCategory.PipelineNodeInferenceUnavailable,
|
|
45860
46081
|
EventCategory.PipelineDetectionBlind,
|
|
46082
|
+
EventCategory.AddonRunnerFailed,
|
|
45861
46083
|
EventCategory.UpdateAvailable,
|
|
45862
46084
|
EventCategory.AddonUpdated,
|
|
45863
46085
|
EventCategory.SystemRestartCompleted,
|
|
@@ -47202,6 +47424,66 @@ var EventMediaDispatcher = class {
|
|
|
47202
47424
|
}
|
|
47203
47425
|
}
|
|
47204
47426
|
};
|
|
47427
|
+
var SnapshotTimeoutError$1 = class extends Error {
|
|
47428
|
+
constructor(ms) {
|
|
47429
|
+
super(`snapshot cap timed out after ${ms}ms`);
|
|
47430
|
+
this.name = "SnapshotTimeoutError";
|
|
47431
|
+
}
|
|
47432
|
+
};
|
|
47433
|
+
function withTimeout$1(promise, ms) {
|
|
47434
|
+
return new Promise((resolve, reject) => {
|
|
47435
|
+
const timer = setTimeout(() => reject(new SnapshotTimeoutError$1(ms)), ms);
|
|
47436
|
+
promise.then((value) => {
|
|
47437
|
+
clearTimeout(timer);
|
|
47438
|
+
resolve(value);
|
|
47439
|
+
}, (err) => {
|
|
47440
|
+
clearTimeout(timer);
|
|
47441
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
47442
|
+
});
|
|
47443
|
+
});
|
|
47444
|
+
}
|
|
47445
|
+
var MotionEventSnapshotCapturer = class {
|
|
47446
|
+
deps;
|
|
47447
|
+
snapshotTimeoutMs;
|
|
47448
|
+
constructor(deps) {
|
|
47449
|
+
this.deps = deps;
|
|
47450
|
+
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
|
|
47451
|
+
}
|
|
47452
|
+
/**
|
|
47453
|
+
* Fetch a still and persist it as the motion event's snapshot. Never
|
|
47454
|
+
* throws — the durable record is the motion row, already inserted by
|
|
47455
|
+
* the caller.
|
|
47456
|
+
*/
|
|
47457
|
+
async capture(input) {
|
|
47458
|
+
let snap;
|
|
47459
|
+
try {
|
|
47460
|
+
snap = await withTimeout$1(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
|
|
47461
|
+
} catch (err) {
|
|
47462
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError$1 ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
|
|
47463
|
+
return;
|
|
47464
|
+
}
|
|
47465
|
+
if (snap === null) return;
|
|
47466
|
+
const raw = Buffer.from(snap.base64, "base64");
|
|
47467
|
+
let data = raw;
|
|
47468
|
+
try {
|
|
47469
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
47470
|
+
} catch (err) {
|
|
47471
|
+
this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
|
|
47472
|
+
}
|
|
47473
|
+
try {
|
|
47474
|
+
await this.deps.media.put({
|
|
47475
|
+
deviceId: input.deviceId,
|
|
47476
|
+
ownerKind: "event",
|
|
47477
|
+
ownerId: input.eventId,
|
|
47478
|
+
kind: "snapshot",
|
|
47479
|
+
timestamp: input.timestamp,
|
|
47480
|
+
data
|
|
47481
|
+
});
|
|
47482
|
+
} catch (err) {
|
|
47483
|
+
this.deps.onError?.("snapshotMedia", err, input.deviceId);
|
|
47484
|
+
}
|
|
47485
|
+
}
|
|
47486
|
+
};
|
|
47205
47487
|
/**
|
|
47206
47488
|
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
47207
47489
|
* synthetic track per camera the event belongs to. Returns the number of
|
|
@@ -47298,16 +47580,16 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
|
|
|
47298
47580
|
/** A hanging snapshot cap must not stall the sensor state-change path. */
|
|
47299
47581
|
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
47300
47582
|
/** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
|
|
47301
|
-
var SnapshotTimeoutError
|
|
47583
|
+
var SnapshotTimeoutError = class extends Error {
|
|
47302
47584
|
constructor(ms) {
|
|
47303
47585
|
super(`snapshot cap timed out after ${ms}ms`);
|
|
47304
47586
|
this.name = "SnapshotTimeoutError";
|
|
47305
47587
|
}
|
|
47306
47588
|
};
|
|
47307
47589
|
/** Reject with `SnapshotTimeoutError` if `promise` doesn't settle within `ms`. */
|
|
47308
|
-
function withTimeout
|
|
47590
|
+
function withTimeout(promise, ms) {
|
|
47309
47591
|
return new Promise((resolve, reject) => {
|
|
47310
|
-
const timer = setTimeout(() => reject(new SnapshotTimeoutError
|
|
47592
|
+
const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
|
|
47311
47593
|
promise.then((value) => {
|
|
47312
47594
|
clearTimeout(timer);
|
|
47313
47595
|
resolve(value);
|
|
@@ -47362,11 +47644,11 @@ var SyntheticTrackMaterializer = class {
|
|
|
47362
47644
|
*/
|
|
47363
47645
|
async fetchSnapshot(cameraId, timeoutMs) {
|
|
47364
47646
|
try {
|
|
47365
|
-
const snap = await withTimeout
|
|
47647
|
+
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
|
|
47366
47648
|
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
|
|
47367
47649
|
return snap;
|
|
47368
47650
|
} catch (err) {
|
|
47369
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError
|
|
47651
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
|
|
47370
47652
|
return null;
|
|
47371
47653
|
}
|
|
47372
47654
|
}
|
|
@@ -47432,66 +47714,6 @@ var SyntheticTrackMaterializer = class {
|
|
|
47432
47714
|
return track;
|
|
47433
47715
|
}
|
|
47434
47716
|
};
|
|
47435
|
-
var SnapshotTimeoutError = class extends Error {
|
|
47436
|
-
constructor(ms) {
|
|
47437
|
-
super(`snapshot cap timed out after ${ms}ms`);
|
|
47438
|
-
this.name = "SnapshotTimeoutError";
|
|
47439
|
-
}
|
|
47440
|
-
};
|
|
47441
|
-
function withTimeout(promise, ms) {
|
|
47442
|
-
return new Promise((resolve, reject) => {
|
|
47443
|
-
const timer = setTimeout(() => reject(new SnapshotTimeoutError(ms)), ms);
|
|
47444
|
-
promise.then((value) => {
|
|
47445
|
-
clearTimeout(timer);
|
|
47446
|
-
resolve(value);
|
|
47447
|
-
}, (err) => {
|
|
47448
|
-
clearTimeout(timer);
|
|
47449
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
47450
|
-
});
|
|
47451
|
-
});
|
|
47452
|
-
}
|
|
47453
|
-
var MotionEventSnapshotCapturer = class {
|
|
47454
|
-
deps;
|
|
47455
|
-
snapshotTimeoutMs;
|
|
47456
|
-
constructor(deps) {
|
|
47457
|
-
this.deps = deps;
|
|
47458
|
-
this.snapshotTimeoutMs = deps.snapshotTimeoutMs ?? 2500;
|
|
47459
|
-
}
|
|
47460
|
-
/**
|
|
47461
|
-
* Fetch a still and persist it as the motion event's snapshot. Never
|
|
47462
|
-
* throws — the durable record is the motion row, already inserted by
|
|
47463
|
-
* the caller.
|
|
47464
|
-
*/
|
|
47465
|
-
async capture(input) {
|
|
47466
|
-
let snap;
|
|
47467
|
-
try {
|
|
47468
|
-
snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: input.deviceId }), this.snapshotTimeoutMs);
|
|
47469
|
-
} catch (err) {
|
|
47470
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotFetch", err, input.deviceId);
|
|
47471
|
-
return;
|
|
47472
|
-
}
|
|
47473
|
-
if (snap === null) return;
|
|
47474
|
-
const raw = Buffer.from(snap.base64, "base64");
|
|
47475
|
-
let data = raw;
|
|
47476
|
-
try {
|
|
47477
|
-
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
47478
|
-
} catch (err) {
|
|
47479
|
-
this.deps.onError?.("downscaleSnapshot", err, input.deviceId);
|
|
47480
|
-
}
|
|
47481
|
-
try {
|
|
47482
|
-
await this.deps.media.put({
|
|
47483
|
-
deviceId: input.deviceId,
|
|
47484
|
-
ownerKind: "event",
|
|
47485
|
-
ownerId: input.eventId,
|
|
47486
|
-
kind: "snapshot",
|
|
47487
|
-
timestamp: input.timestamp,
|
|
47488
|
-
data
|
|
47489
|
-
});
|
|
47490
|
-
} catch (err) {
|
|
47491
|
-
this.deps.onError?.("snapshotMedia", err, input.deviceId);
|
|
47492
|
-
}
|
|
47493
|
-
}
|
|
47494
|
-
};
|
|
47495
47717
|
var TrackingSettingsSchema = object({
|
|
47496
47718
|
/**
|
|
47497
47719
|
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
@@ -53031,6 +53253,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53031
53253
|
audio
|
|
53032
53254
|
};
|
|
53033
53255
|
},
|
|
53256
|
+
fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
|
|
53257
|
+
return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
|
|
53258
|
+
},
|
|
53034
53259
|
fetchAvailability: async ({ deviceId, since, until }) => {
|
|
53035
53260
|
return (await api.recording.getAvailability.query({
|
|
53036
53261
|
deviceId,
|
|
@@ -53042,6 +53267,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
53042
53267
|
tags: { deviceId: Number(meta["deviceId"]) },
|
|
53043
53268
|
meta
|
|
53044
53269
|
}),
|
|
53270
|
+
logInfo: (message, meta) => this.ctx.logger.info(message, {
|
|
53271
|
+
tags: { deviceId: Number(meta["deviceId"]) },
|
|
53272
|
+
meta
|
|
53273
|
+
}),
|
|
53045
53274
|
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
53046
53275
|
const manifest = await api.recording.getPlaybackManifest.query({
|
|
53047
53276
|
deviceId,
|