@camstack/addon-post-analysis 1.2.219 → 1.2.220

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.
@@ -16219,24 +16219,6 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
16219
16219
  targetId: string(),
16220
16220
  enabled: boolean()
16221
16221
  }), _void(), { kind: "mutation" });
16222
- var MACRO_LABELS = [
16223
- {
16224
- id: "person",
16225
- name: "Person"
16226
- },
16227
- {
16228
- id: "vehicle",
16229
- name: "Vehicle"
16230
- },
16231
- {
16232
- id: "animal",
16233
- name: "Animal"
16234
- },
16235
- {
16236
- id: "package",
16237
- name: "Package"
16238
- }
16239
- ];
16240
16222
  /**
16241
16223
  * The class names a per-class COUNT can ever be keyed on — the macro ids, and
16242
16224
  * only those.
@@ -16254,7 +16236,24 @@ var MACRO_LABELS = [
16254
16236
  * does not go through the engine's macro-expanding `expandClassSelector`), two
16255
16237
  * live rules were authored on `car`, and both counters read 0 forever.
16256
16238
  */
16257
- var DETECTION_MACRO_CLASSES = new Set(MACRO_LABELS.map((l) => l.id));
16239
+ var DETECTION_MACRO_CLASSES = new Set([
16240
+ {
16241
+ id: "person",
16242
+ name: "Person"
16243
+ },
16244
+ {
16245
+ id: "vehicle",
16246
+ name: "Vehicle"
16247
+ },
16248
+ {
16249
+ id: "animal",
16250
+ name: "Animal"
16251
+ },
16252
+ {
16253
+ id: "package",
16254
+ name: "Package"
16255
+ }
16256
+ ].map((l) => l.id));
16258
16257
  /** True when `className` is a macro the detector can actually emit (and hence a
16259
16258
  * class a count can be keyed on) — see {@link DETECTION_MACRO_CLASSES}. */
16260
16259
  function isDetectionMacroClass(className) {
@@ -27767,8 +27766,8 @@ includeCrops: boolean().optional() }).optional(), array(IdentitySchema).readonly
27767
27766
  *
27768
27767
  * An **empty array reads NOTHING** — `[]` is an empty page, never
27769
27768
  * "every camera". A request for no devices is a request, not an
27770
- * omission; same contract as `deviceManager.listFleet` and
27771
- * `pipelineAnalytics.listRecentTracks`.
27769
+ * omission; same contract as `deviceManager.listAll`'s `deviceIds`
27770
+ * and `pipelineAnalytics.listRecentTracks`.
27772
27771
  *
27773
27772
  * Absent (`undefined`) is the omission, and keeps the cluster-wide view.
27774
27773
  */
@@ -45570,6 +45569,12 @@ Object.defineProperty(exports, "EventCategory", {
45570
45569
  return EventCategory;
45571
45570
  }
45572
45571
  });
45572
+ Object.defineProperty(exports, "FIRST_LEVEL_MACRO_CLASSES", {
45573
+ enumerable: true,
45574
+ get: function() {
45575
+ return FIRST_LEVEL_MACRO_CLASSES;
45576
+ }
45577
+ });
45573
45578
  Object.defineProperty(exports, "FULL_IMAGE_BBOX", {
45574
45579
  enumerable: true,
45575
45580
  get: function() {
@@ -45588,12 +45593,6 @@ Object.defineProperty(exports, "LabelAttributionSchema", {
45588
45593
  return LabelAttributionSchema;
45589
45594
  }
45590
45595
  });
45591
- Object.defineProperty(exports, "MACRO_LABELS", {
45592
- enumerable: true,
45593
- get: function() {
45594
- return MACRO_LABELS;
45595
- }
45596
- });
45597
45596
  Object.defineProperty(exports, "MAX_ARCHIVED_DEBUG_NOTES", {
45598
45597
  enumerable: true,
45599
45598
  get: function() {
@@ -16188,24 +16188,6 @@ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSche
16188
16188
  targetId: string(),
16189
16189
  enabled: boolean()
16190
16190
  }), _void(), { kind: "mutation" });
16191
- var MACRO_LABELS = [
16192
- {
16193
- id: "person",
16194
- name: "Person"
16195
- },
16196
- {
16197
- id: "vehicle",
16198
- name: "Vehicle"
16199
- },
16200
- {
16201
- id: "animal",
16202
- name: "Animal"
16203
- },
16204
- {
16205
- id: "package",
16206
- name: "Package"
16207
- }
16208
- ];
16209
16191
  /**
16210
16192
  * The class names a per-class COUNT can ever be keyed on — the macro ids, and
16211
16193
  * only those.
@@ -16223,7 +16205,24 @@ var MACRO_LABELS = [
16223
16205
  * does not go through the engine's macro-expanding `expandClassSelector`), two
16224
16206
  * live rules were authored on `car`, and both counters read 0 forever.
16225
16207
  */
16226
- var DETECTION_MACRO_CLASSES = new Set(MACRO_LABELS.map((l) => l.id));
16208
+ var DETECTION_MACRO_CLASSES = new Set([
16209
+ {
16210
+ id: "person",
16211
+ name: "Person"
16212
+ },
16213
+ {
16214
+ id: "vehicle",
16215
+ name: "Vehicle"
16216
+ },
16217
+ {
16218
+ id: "animal",
16219
+ name: "Animal"
16220
+ },
16221
+ {
16222
+ id: "package",
16223
+ name: "Package"
16224
+ }
16225
+ ].map((l) => l.id));
16227
16226
  /** True when `className` is a macro the detector can actually emit (and hence a
16228
16227
  * class a count can be keyed on) — see {@link DETECTION_MACRO_CLASSES}. */
16229
16228
  function isDetectionMacroClass(className) {
@@ -27736,8 +27735,8 @@ includeCrops: boolean().optional() }).optional(), array(IdentitySchema).readonly
27736
27735
  *
27737
27736
  * An **empty array reads NOTHING** — `[]` is an empty page, never
27738
27737
  * "every camera". A request for no devices is a request, not an
27739
- * omission; same contract as `deviceManager.listFleet` and
27740
- * `pipelineAnalytics.listRecentTracks`.
27738
+ * omission; same contract as `deviceManager.listAll`'s `deviceIds`
27739
+ * and `pipelineAnalytics.listRecentTracks`.
27741
27740
  *
27742
27741
  * Absent (`undefined`) is the omission, and keeps the cluster-wide view.
27743
27742
  */
@@ -45431,4 +45430,4 @@ function vectorDimFromBase64(encoded) {
45431
45430
  return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
45432
45431
  }
45433
45432
  //#endregion
45434
- export { VISIT_MERGE_GAP_MS as $, object as $t, NcRulePatchSchema as A, resolvePoolMemoryPolicy as At, PoolMemoryWatchdog as B, CamProfileSchema as Bt, NC_ALARM_SYSTEM_EVENT_KINDS as C, parseProcStatus as Ct, NC_TAXONOMY as D, readDeviceStateFrom as Dt, NC_SNOOZE_MAX_MINUTES as E, plateGalleryCapability as Et, NcSnoozeSchema as F, vectorDimFromBase64 as Ft, SCENE_DEFAULT_UNCOVERED_POLICY as G, nodePin as Gt, RetrainStatusSchema as H, createEvent as Ht, NcSnoozeSuppressedSchema as I, videoclipsCapability as It, TIMELAPSE_DENSE_FLOOR_SEC as J, array as Jt, SCENE_DIVERGED as K, sleep as Kt, NcSystemEventKindSchema as L, zoneAnalyticsCapability as Lt, NcRuleTargetSchema as M, storageOccupancyCapability as Mt, NcScheduleSchema as N, subKindsOf as Nt, NcConditionDescriptorSchema as O, readTimelapseGeneratedAt as Ot, NcSnoozeInputSchema as P, systemEventFilterApplies as Pt, TrackSourceSchema as Q, number as Qt, NcTaxonomySchema as R, errMsg as Rt, MediaFileKindEnum as S, notificationRulesCapability as St, NC_DEFAULT_SNOOZE_MINUTES as T, pipelineAnalyticsCapability as Tt, SCENE_CONFIRM_DEFAULT_TIMEOUT_MS as U, hydrateSchema as Ut, RECORDING_EXPORT_MAX_READ_BYTES as V, DeviceType as Vt, SCENE_DEFAULT_ANCHOR_THRESHOLD as W, isDeviceScopedCap as Wt, TimelapseRulePatchSchema as X, discriminatedUnion as Xt, TimelapseRuleInputSchema as Y, boolean as Yt, TimelapseRuleSchema as Z, literal as Zt, LabelAttributionSchema as _, isDetectionMacroClass as _t, CLUSTER_MODEL_SCOPED_STEPS as a, buildEventKindDescriptor as at, MAX_BIRTH_DECISION_RECORDS as b, kebabToCamel as bt, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as c, defineCustomActions as ct, DETECTION_PIPELINE_CAP_NAME as d, encodeVectorBase64 as dt, partialRecord as en, addonWidgetsSourceCapability as et, DeclaredDevices as f, evaluateSensorEdge as ft, FailureCounters as g, hfModelUrl as gt, FULL_IMAGE_BBOX as h, failureContributionCapability as ht, BirthDecisionRecordSchema as i, EventCategory as in, audioModeOf as it, NcRuleSchema as j, sceneMonitorCapability as jt, NcRuleInputSchema as k, resolveLocationMode as kt, DEFAULT_TIMELAPSE_PREVIEW_TEXT as l, deriveRecordingMode as lt, EVENT_PAD_MS as m, faceGalleryCapability as mt, ArchivedDebugNoteSchema as n, string as nn, assertTimelapseCadences as nt, COCO_TO_MACRO as o, cosineSimilarity as ot, EVENT_KIND_BY_CAP as p, evictionPolicyOfLocation as pt, SceneMonitorSchema as q, _enum as qt, BaseDevice as r, unknown as rn, audioMetricsCapability as rt, DEFAULT_EVENT_COLOR as s, customAction as st, AUDIO_MACRO_LABELS as t, record as tn, alarmPanelCapability as tt, DETECTION_MACRO_CLASSES as u, embeddingEncoderCapability as ut, MACRO_LABELS as v, isScheduleActive as vt, NC_CONDITION_CATALOG as w, pickClusterStepModels as wt, MAX_BIRTH_LATENCY_PROXY_MS as x, mayWriteToLocation as xt, MAX_ARCHIVED_DEBUG_NOTES as y, isSourceCap as yt, OpsLogEntrySchema as z, BaseAddon as zt };
45433
+ export { VISIT_MERGE_GAP_MS as $, object as $t, NcRulePatchSchema as A, resolvePoolMemoryPolicy as At, PoolMemoryWatchdog as B, CamProfileSchema as Bt, NC_ALARM_SYSTEM_EVENT_KINDS as C, parseProcStatus as Ct, NC_TAXONOMY as D, readDeviceStateFrom as Dt, NC_SNOOZE_MAX_MINUTES as E, plateGalleryCapability as Et, NcSnoozeSchema as F, vectorDimFromBase64 as Ft, SCENE_DEFAULT_UNCOVERED_POLICY as G, nodePin as Gt, RetrainStatusSchema as H, createEvent as Ht, NcSnoozeSuppressedSchema as I, videoclipsCapability as It, TIMELAPSE_DENSE_FLOOR_SEC as J, array as Jt, SCENE_DIVERGED as K, sleep as Kt, NcSystemEventKindSchema as L, zoneAnalyticsCapability as Lt, NcRuleTargetSchema as M, storageOccupancyCapability as Mt, NcScheduleSchema as N, subKindsOf as Nt, NcConditionDescriptorSchema as O, readTimelapseGeneratedAt as Ot, NcSnoozeInputSchema as P, systemEventFilterApplies as Pt, TrackSourceSchema as Q, number as Qt, NcTaxonomySchema as R, errMsg as Rt, MediaFileKindEnum as S, notificationRulesCapability as St, NC_DEFAULT_SNOOZE_MINUTES as T, pipelineAnalyticsCapability as Tt, SCENE_CONFIRM_DEFAULT_TIMEOUT_MS as U, hydrateSchema as Ut, RECORDING_EXPORT_MAX_READ_BYTES as V, DeviceType as Vt, SCENE_DEFAULT_ANCHOR_THRESHOLD as W, isDeviceScopedCap as Wt, TimelapseRulePatchSchema as X, discriminatedUnion as Xt, TimelapseRuleInputSchema as Y, boolean as Yt, TimelapseRuleSchema as Z, literal as Zt, FailureCounters as _, isDetectionMacroClass as _t, CLUSTER_MODEL_SCOPED_STEPS as a, buildEventKindDescriptor as at, MAX_BIRTH_DECISION_RECORDS as b, kebabToCamel as bt, DEFAULT_FIRST_SIGHTING_FRESHNESS_MS as c, defineCustomActions as ct, DETECTION_PIPELINE_CAP_NAME as d, encodeVectorBase64 as dt, partialRecord as en, addonWidgetsSourceCapability as et, DeclaredDevices as f, evaluateSensorEdge as ft, FULL_IMAGE_BBOX as g, hfModelUrl as gt, FIRST_LEVEL_MACRO_CLASSES as h, failureContributionCapability as ht, BirthDecisionRecordSchema as i, EventCategory as in, audioModeOf as it, NcRuleSchema as j, sceneMonitorCapability as jt, NcRuleInputSchema as k, resolveLocationMode as kt, DEFAULT_TIMELAPSE_PREVIEW_TEXT as l, deriveRecordingMode as lt, EVENT_PAD_MS as m, faceGalleryCapability as mt, ArchivedDebugNoteSchema as n, string as nn, assertTimelapseCadences as nt, COCO_TO_MACRO as o, cosineSimilarity as ot, EVENT_KIND_BY_CAP as p, evictionPolicyOfLocation as pt, SceneMonitorSchema as q, _enum as qt, BaseDevice as r, unknown as rn, audioMetricsCapability as rt, DEFAULT_EVENT_COLOR as s, customAction as st, AUDIO_MACRO_LABELS as t, record as tn, alarmPanelCapability as tt, DETECTION_MACRO_CLASSES as u, embeddingEncoderCapability as ut, LabelAttributionSchema as v, isScheduleActive as vt, NC_CONDITION_CATALOG as w, pickClusterStepModels as wt, MAX_BIRTH_LATENCY_PROXY_MS as x, mayWriteToLocation as xt, MAX_ARCHIVED_DEBUG_NOTES as y, isSourceCap as yt, OpsLogEntrySchema as z, BaseAddon as zt };
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Cnlk6Tyz.js");
5
+ const require_dist = require("../dist-Bw4aTXFP.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs);
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { At as resolvePoolMemoryPolicy, B as PoolMemoryWatchdog, Ct as parseProcStatus, gt as hfModelUrl, ut as embeddingEncoderCapability, zt as BaseAddon } from "../dist-D0l3xZqa.mjs";
1
+ import { At as resolvePoolMemoryPolicy, B as PoolMemoryWatchdog, Ct as parseProcStatus, gt as hfModelUrl, ut as embeddingEncoderCapability, zt as BaseAddon } from "../dist-DWXR9KNe.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.97",
6
+ version: "1.2.98",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_pipeline_analytics_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.177",
21
+ version: "1.2.178",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.145",
36
+ version: "1.2.146",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.177",
39
+ version: "1.2.178",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.2.97",
48
+ version: "1.2.98",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.145",
84
+ version: "1.2.146",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Cnlk6Tyz.js");
5
+ const require_dist = require("../dist-Bw4aTXFP.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -20002,6 +20002,14 @@ function checkTimelapsePacing(input) {
20002
20002
  unservedWindows: []
20003
20003
  };
20004
20004
  }
20005
+ //#endregion
20006
+ //#region src/notification-center/timelapse/timelapse-renderer.ts
20007
+ /** `part / whole` to two decimals — the shape a log line is read in. An empty
20008
+ * whole is 0, never NaN: a ratio nobody can compare is not a measurement. */
20009
+ function ratio(part, whole) {
20010
+ if (whole <= 0) return 0;
20011
+ return Math.round(part / whole * 100) / 100;
20012
+ }
20005
20013
  /** Bound on the post-render pacing check. The check is best-effort by charter:
20006
20014
  * a byte read that hangs must delay delivery by at most this, never sink it. */
20007
20015
  var PACING_CHECK_TIMEOUT_MS = 2e4;
@@ -20025,25 +20033,6 @@ function profileCost(profile) {
20025
20033
  const i = PROFILE_BY_COST.indexOf(profile);
20026
20034
  return i === -1 ? PROFILE_BY_COST.length : i;
20027
20035
  }
20028
- /**
20029
- * The CHEAPEST profile that has footage over the window.
20030
- *
20031
- * It used to be the profile with the MOST coverage, ties broken by name — and
20032
- * that is a rule which picks `high` almost every time, because the high band is
20033
- * the one that records continuously. On 2026-09-03 it did: a test render of a
20034
- * ten-hour window chose `profile=high` at 97% coverage, which on this hub means
20035
- * 4K read from a USB spinning disk. Decoding it made the machine stop answering
20036
- * SSH and its own web UI for half an hour, and it had to be power-cycled.
20037
- *
20038
- * A timelapse is watched accelerated. Resolution buys nothing at 12 fps over a
20039
- * night, and the cost difference between the bands is one to two orders of
20040
- * magnitude in both bytes read and frames decoded. So there is no trade to
20041
- * weigh: take the cheapest band that has anything, every time.
20042
- *
20043
- * Coverage no longer decides WHICH — only whether a band qualifies at all. A
20044
- * band with less footage is still the right source; the gaps show as a shorter
20045
- * video, not as a reason to read twenty times the bytes.
20046
- */
20047
20036
  function pickProfile(ranges, window) {
20048
20037
  const byProfile = /* @__PURE__ */ new Map();
20049
20038
  for (const r of ranges) {
@@ -20290,18 +20279,20 @@ var TimelapseRenderer = class {
20290
20279
  const finished = await this.awaitReady(queued, tags);
20291
20280
  const published = await this.publish(finished.id, request, tags);
20292
20281
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
20282
+ const archive = await this.measureArchive(finished.id, request, chosen, tags);
20283
+ const deliveredRows = archive?.rows ?? availability.filter((r) => r.profile === chosen.profile);
20293
20284
  await this.verifyPacing({
20294
20285
  exportId: finished.id,
20295
20286
  request,
20296
20287
  dense,
20297
20288
  subjects,
20298
- availability: availability.filter((r) => r.profile === chosen.profile),
20289
+ availability: deliveredRows,
20299
20290
  tags
20300
20291
  });
20301
20292
  return {
20302
20293
  exportId: finished.id,
20303
20294
  profile: chosen.profile,
20304
- coverage: chosen.coverage,
20295
+ coverage: archive?.coverage ?? chosen.coverage,
20305
20296
  plan,
20306
20297
  detections,
20307
20298
  artifact: published.video,
@@ -20409,6 +20400,96 @@ var TimelapseRenderer = class {
20409
20400
  throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
20410
20401
  }
20411
20402
  /**
20403
+ * The window against the archive, measured AFTER the export and always said.
20404
+ *
20405
+ * Three figures, one line: the window's length, what the PLAN was cut from
20406
+ * (the availability read before the export) and what the ARCHIVE holds (the
20407
+ * same read after it — the export's verified walk has repaired the index the
20408
+ * calendar serves from by then). `plannedOfArchive` is the ratio D445 found
20409
+ * at 0.20 on camera 4374 and nothing reported; a healthy night reads 1.00
20410
+ * because both reads return the same rows.
20411
+ *
20412
+ * WHY THE THRESHOLD IS A SLACK AND NOT A RATIO. The two reads are the SAME
20413
+ * query against the SAME index, so on a healthy night they return the same
20414
+ * rows and the shortfall is exactly 0 — there is no distribution to pick a
20415
+ * percentile out of. It is non-zero only when something CHANGED the index
20416
+ * between them, and the export's verified walk is the only thing that does.
20417
+ * So the bar is not "how short is too short" (the robot's night was 0.20 and
20418
+ * a milder one would be just as wrong); it is only "is this a real change or
20419
+ * a boundary artefact". One artefact exists: the window is closed, but a
20420
+ * segment straddling its end can finalize while the export runs and extend
20421
+ * the last range by its own length. {@link COVERAGE_COMPLETE_SLACK_MS} (30 s)
20422
+ * is the slack the coverage figure already allows at exactly those edges,
20423
+ * and it is several segments wide. No second constant, and nothing between
20424
+ * "unchanged" and "the planner rendered from a short answer".
20425
+ *
20426
+ * Never throws. A re-read that fails is SAID, the plan's figure stands, and
20427
+ * every figure that needed the archive is `null` rather than a stand-in.
20428
+ */
20429
+ async measureArchive(exportId, request, chosen, tags) {
20430
+ const { window } = request;
20431
+ const windowSec = Math.round((window.endMs - window.startMs) / 1e3);
20432
+ const plannedSec = Math.round(chosen.coverage.coveredMs / 1e3);
20433
+ let rows;
20434
+ try {
20435
+ rows = (await this.ports.getAvailability({
20436
+ deviceId: request.deviceId,
20437
+ fromMs: window.startMs,
20438
+ toMs: window.endMs,
20439
+ profile: chosen.profile
20440
+ })).ranges.filter((r) => r.profile === chosen.profile);
20441
+ } catch (err) {
20442
+ this.ports.logger.warn("timelapse coverage: archive re-read failed — the window is reported against the plan only", {
20443
+ tags,
20444
+ meta: {
20445
+ exportId,
20446
+ ruleId: request.ruleId,
20447
+ profile: chosen.profile,
20448
+ windowSec,
20449
+ plannedSec,
20450
+ plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
20451
+ archiveSec: null,
20452
+ plannedOfArchive: null,
20453
+ archiveOfWindow: null,
20454
+ error: String(err)
20455
+ }
20456
+ });
20457
+ return null;
20458
+ }
20459
+ const coverage = footageCoverage(rows, window.startMs, window.endMs);
20460
+ const shortfallMs = coverage.coveredMs - chosen.coverage.coveredMs;
20461
+ const meta = {
20462
+ exportId,
20463
+ ruleId: request.ruleId,
20464
+ profile: chosen.profile,
20465
+ windowSec,
20466
+ plannedSec,
20467
+ archiveSec: Math.round(coverage.coveredMs / 1e3),
20468
+ /** What the operator ASKED for against what the file HOLDS — the ratio
20469
+ * the robot's night made someone file a report over: ten hours in,
20470
+ * 6.4 s of video out. Low is not by itself a defect (a lazy source is
20471
+ * legitimately idle most of a night), which is why it never gates. */
20472
+ plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
20473
+ archiveOfWindow: ratio(coverage.coveredMs, window.endMs - window.startMs),
20474
+ /** The one that IS a defect: what the planner saw against what the
20475
+ * export's verified walk found. 0.20 on camera 4374, 1.00 when healthy. */
20476
+ plannedOfArchive: ratio(chosen.coverage.coveredMs, coverage.coveredMs),
20477
+ shortfallSec: Math.round(shortfallMs / 1e3)
20478
+ };
20479
+ if (shortfallMs > 3e4) this.ports.logger.warn("timelapse coverage: the plan was cut from less footage than the archive holds", {
20480
+ tags,
20481
+ meta
20482
+ });
20483
+ else this.ports.logger.info("timelapse coverage: the window against the archive", {
20484
+ tags,
20485
+ meta
20486
+ });
20487
+ return {
20488
+ rows,
20489
+ coverage
20490
+ };
20491
+ }
20492
+ /**
20412
20493
  * Run the pacing check against the finished file, and SAY what it found.
20413
20494
  *
20414
20495
  * Never throws and never blocks past its bound: the check is telemetry about
@@ -28431,6 +28512,34 @@ var MEDIA_INDEXES = [
28431
28512
  "deviceId",
28432
28513
  "sizeBytes"
28433
28514
  ]
28515
+ }),
28516
+ (
28517
+ /**
28518
+ * The per-LOCATION footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE locationId
28519
+ * = ?`, and the legacy `locationId IS NULL` half of the same question. See
28520
+ * {@link MediaStore.footprintByLocation} (D388) and D447.
28521
+ *
28522
+ * **Why not `idx_media_location`.** It leads with `locationId`, so it finds
28523
+ * the range — and then, exactly as the `idx_media_device_size` note above
28524
+ * describes for `deviceId`, fetches the table row behind every entry to read
28525
+ * `sizeBytes`. Every row of the store is on SOME location, so the range is
28526
+ * the table: measured 2026-09-10/11 on `hub/sqlite-settings`, 4 128–6 962 ms
28527
+ * per statement, every five minutes (the occupancy refresh), all night —
28528
+ * `plan=["SEARCH … USING INDEX idx_media_location (locationId=?)"]` on every
28529
+ * WARN. That thread is synchronous, so every configuration read in the
28530
+ * cluster queued behind it: the orchestrator's `configMs=6640` at 23:26:11
28531
+ * is this statement's 6 002 ms at 23:26:10.
28532
+ *
28533
+ * With `sizeBytes` in the index the planner never touches the table
28534
+ * (`USING COVERING INDEX idx_media_location_size`), and the seal gate keeps
28535
+ * its own narrower index. Same shape, same one-off caveat as its two
28536
+ * siblings: ~30 bytes a row, one more b-tree per insert, and the first
28537
+ * `declareCollection` after this ships builds it over every existing row —
28538
+ * do not deploy it beside a running drain or seal.
28539
+ */
28540
+ {
28541
+ name: "idx_media_location_size",
28542
+ columns: ["locationId", "sizeBytes"]
28434
28543
  })
28435
28544
  ];
28436
28545
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -65283,6 +65392,44 @@ function buildAudioLabels(input) {
65283
65392
  }];
65284
65393
  }
65285
65394
  //#endregion
65395
+ //#region src/pipeline-analytics/services/event-kinds-config.ts
65396
+ /** The honest answer for a pipeline that could not be read: nothing advertised. */
65397
+ var NO_EMITTABLE = {
65398
+ macroClasses: [],
65399
+ audioEnabled: false
65400
+ };
65401
+ /** Depth-first walk of the step tree; the detector is a root today, but the
65402
+ * question is "is it enabled anywhere", not "is it a root". */
65403
+ function* walkSteps(steps) {
65404
+ for (const step of steps) {
65405
+ yield step;
65406
+ if (step.children !== void 0) yield* walkSteps(step.children);
65407
+ }
65408
+ }
65409
+ /**
65410
+ * The macro classes an enabled `object-detection` step emits. Order follows
65411
+ * the authority, not the stored array, so two cameras narrowed to the same
65412
+ * set render the same lanes in the same order.
65413
+ */
65414
+ function macroClassesOf(step) {
65415
+ const stored = step.settings?.["enabledMacroClasses"];
65416
+ if (!Array.isArray(stored) || stored.length === 0) return [...require_dist.FIRST_LEVEL_MACRO_CLASSES];
65417
+ const chosen = new Set(stored.filter((v) => typeof v === "string"));
65418
+ return require_dist.FIRST_LEVEL_MACRO_CLASSES.filter((macro) => chosen.has(macro));
65419
+ }
65420
+ function emittableFromPipelineConfig(config) {
65421
+ let macroClasses = [];
65422
+ for (const step of walkSteps(config.steps)) {
65423
+ if (step.addonId !== "object-detection" || step.enabled === false) continue;
65424
+ macroClasses = macroClassesOf(step);
65425
+ break;
65426
+ }
65427
+ return {
65428
+ macroClasses,
65429
+ audioEnabled: config.audio?.enabled === true
65430
+ };
65431
+ }
65432
+ //#endregion
65286
65433
  //#region src/pipeline-analytics/services/event-kinds.ts
65287
65434
  /**
65288
65435
  * Extensible per-device event kinds (Part B).
@@ -78190,28 +78337,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78190
78337
  };
78191
78338
  }
78192
78339
  /**
78193
- * Every event kind the device can produce: built-ins (motion + audio),
78194
- * detection classes actually observed on the device, and sensor kinds
78195
- * from LINKED devices (device-manager `getLinkedDevices`, binding-driven
78196
- * per linked device). Degrades to the built-ins on error.
78340
+ * Every event kind the device can produce: built-ins (motion + audio), the
78341
+ * detection classes its CONFIGURED pipeline can emit, and sensor kinds from
78342
+ * LINKED devices (device-manager `getLinkedDevices`, binding-driven per
78343
+ * linked device). Degrades to the built-ins on error.
78344
+ *
78345
+ * The detection and audio facts are read from `resolvePipeline` — the
78346
+ * configuration — and never from `getCameraStatus`, which is RUNTIME: an
78347
+ * on-motion camera holds no session between motion events
78348
+ * (`reasons.detection: 'armed:on-motion'`, `detection: null`) and was being
78349
+ * advertised as detecting nothing while its tracks said person / vehicle /
78350
+ * animal ([D449](../../../../docs/decisions/adr-0449-the-timeline-taxonomy-is-the-configured-one-raw-forward-has-no-switch-and-a-one-row-write-waits-on-the-disk.md)).
78197
78351
  */
78198
78352
  async listEventKinds(input) {
78199
78353
  const api = this.ctx.api;
78200
- const status = await api.pipelineOrchestrator.getCameraStatus.query({ deviceId: input.deviceId }).catch((err) => {
78201
- this.ctx.logger.warn("listEventKinds: getCameraStatus failed", {
78202
- tags: { deviceId: input.deviceId },
78203
- meta: { error: require_dist.errMsg(err) }
78204
- });
78205
- return null;
78206
- });
78207
- const detectionOn = status?.detection != null;
78208
- const audioOn = status?.audio?.enabled === true;
78209
- const detectionMacros = require_dist.MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
78354
+ const emittable = await this.emittableFromConfig(input.deviceId, "listEventKinds");
78210
78355
  return composeEventKinds({
78211
78356
  linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
78212
78357
  bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
78213
- enabledMacroClasses: async () => detectionOn ? detectionMacros : [],
78214
- audioEnabled: async () => audioOn,
78358
+ enabledMacroClasses: async () => emittable.macroClasses,
78359
+ audioEnabled: async () => emittable.audioEnabled,
78215
78360
  packageZonesEnabled: async (deviceId) => {
78216
78361
  return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
78217
78362
  },
@@ -78225,6 +78370,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78225
78370
  }, input.deviceId);
78226
78371
  }
78227
78372
  /**
78373
+ * What one camera's configured pipeline can emit (`resolvePipeline`), or
78374
+ * the honest fallback when the orchestrator could not answer: nothing
78375
+ * advertised, and a WARN saying so. The cap's answer is a plain list, so it
78376
+ * has no room for "unknown" — that residual of D315 is named in D449; the
78377
+ * steady state (an armed camera with no session) is no longer in it.
78378
+ */
78379
+ async emittableFromConfig(deviceId, scope) {
78380
+ try {
78381
+ return emittableFromPipelineConfig(await this.ctx.api.pipelineOrchestrator.resolvePipeline.query({ deviceId }));
78382
+ } catch (err) {
78383
+ this.ctx.logger.warn(`${scope}: resolvePipeline failed — no ML kinds advertised`, {
78384
+ tags: { deviceId },
78385
+ meta: { error: require_dist.errMsg(err) }
78386
+ });
78387
+ return NO_EMITTABLE;
78388
+ }
78389
+ }
78390
+ /**
78228
78391
  * `listEventKinds` for many cameras, in one call.
78229
78392
  *
78230
78393
  * The saving is not the round-trips the CALLER avoids — it is the fan-out
@@ -78236,11 +78399,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78236
78399
  * `getLinkedDevices` alone took 4110 ms — each one runs a full-fleet
78237
78400
  * `listAll` inside device-manager and thirty of them QUEUE.
78238
78401
  *
78239
- * Now three calls for the whole set, whatever N is: one
78240
- * `getCameraStatuses`, then `prefetchDeviceTopology`'s
78241
- * `getLinkedDevicesBatch` + `getBindingsBatch`. `composeEventKinds` is
78242
- * unchanged it is handed the same per-device client interfaces, backed by
78243
- * maps instead of RPC.
78402
+ * Now two calls for the whole topology, whatever N is
78403
+ * `prefetchDeviceTopology`'s `getLinkedDevicesBatch` + `getBindingsBatch`
78404
+ * plus one `resolvePipeline` per camera, all in flight at once (D449; there
78405
+ * is no batch form of it). `composeEventKinds` is unchanged it is handed
78406
+ * the same per-device client interfaces, backed by maps instead of RPC.
78244
78407
  *
78245
78408
  * A camera whose composition fails yields an EMPTY list rather than dropping
78246
78409
  * out of the response: a caller that asked for twelve and got eleven cannot
@@ -78249,15 +78412,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78249
78412
  async listEventKindsBatch(input) {
78250
78413
  const api = this.ctx.api;
78251
78414
  const deviceIds = [...new Set(input.deviceIds)];
78252
- const statuses = await api.pipelineOrchestrator.getCameraStatuses.query({ deviceIds }).catch((err) => {
78253
- this.ctx.logger.warn("listEventKindsBatch: getCameraStatuses failed", { meta: {
78254
- deviceCount: deviceIds.length,
78255
- error: require_dist.errMsg(err)
78256
- } });
78257
- return [];
78258
- });
78259
- const statusByDevice = new Map(statuses.map((st) => [st.deviceId, st]));
78260
- const detectionMacros = require_dist.MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
78415
+ const emittableByDevice = new Map(await Promise.all(deviceIds.map(async (deviceId) => [deviceId, await this.emittableFromConfig(deviceId, "listEventKindsBatch")])));
78261
78416
  const topology = await prefetchDeviceTopology({
78262
78417
  getLinkedDevicesBatch: (i) => api.deviceManager.getLinkedDevicesBatch.query(i),
78263
78418
  getBindingsBatch: (i) => api.deviceManager.getBindingsBatch.query(i)
@@ -78269,14 +78424,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78269
78424
  return null;
78270
78425
  });
78271
78426
  return composeEventKindsForDevices(deviceIds, async (deviceId) => {
78272
- const status = statusByDevice.get(deviceId) ?? null;
78273
- const detectionOn = status?.detection != null;
78274
- const audioOn = status?.audio?.enabled === true;
78427
+ const emittable = emittableByDevice.get(deviceId) ?? NO_EMITTABLE;
78275
78428
  return composeEventKinds({
78276
78429
  linkedDevices: topology?.linkedDevices ?? { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
78277
78430
  bindings: topology?.bindings ?? { getBindings: (i) => api.deviceManager.getBindings.query(i) },
78278
- enabledMacroClasses: async () => detectionOn ? detectionMacros : [],
78279
- audioEnabled: async () => audioOn,
78431
+ enabledMacroClasses: async () => emittable.macroClasses,
78432
+ audioEnabled: async () => emittable.audioEnabled,
78280
78433
  packageZonesEnabled: async (id) => {
78281
78434
  return (await this.resolveDevicePackageRules(id)).some((r) => r.enabled !== false);
78282
78435
  },
@@ -1,4 +1,4 @@
1
- import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as LabelAttributionSchema, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FailureCounters, h as FULL_IMAGE_BBOX, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as MACRO_LABELS, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-D0l3xZqa.mjs";
1
+ import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as FailureCounters, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FULL_IMAGE_BBOX, h as FIRST_LEVEL_MACRO_CLASSES, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as LabelAttributionSchema, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-DWXR9KNe.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";
@@ -19980,6 +19980,14 @@ function checkTimelapsePacing(input) {
19980
19980
  unservedWindows: []
19981
19981
  };
19982
19982
  }
19983
+ //#endregion
19984
+ //#region src/notification-center/timelapse/timelapse-renderer.ts
19985
+ /** `part / whole` to two decimals — the shape a log line is read in. An empty
19986
+ * whole is 0, never NaN: a ratio nobody can compare is not a measurement. */
19987
+ function ratio(part, whole) {
19988
+ if (whole <= 0) return 0;
19989
+ return Math.round(part / whole * 100) / 100;
19990
+ }
19983
19991
  /** Bound on the post-render pacing check. The check is best-effort by charter:
19984
19992
  * a byte read that hangs must delay delivery by at most this, never sink it. */
19985
19993
  var PACING_CHECK_TIMEOUT_MS = 2e4;
@@ -20003,25 +20011,6 @@ function profileCost(profile) {
20003
20011
  const i = PROFILE_BY_COST.indexOf(profile);
20004
20012
  return i === -1 ? PROFILE_BY_COST.length : i;
20005
20013
  }
20006
- /**
20007
- * The CHEAPEST profile that has footage over the window.
20008
- *
20009
- * It used to be the profile with the MOST coverage, ties broken by name — and
20010
- * that is a rule which picks `high` almost every time, because the high band is
20011
- * the one that records continuously. On 2026-09-03 it did: a test render of a
20012
- * ten-hour window chose `profile=high` at 97% coverage, which on this hub means
20013
- * 4K read from a USB spinning disk. Decoding it made the machine stop answering
20014
- * SSH and its own web UI for half an hour, and it had to be power-cycled.
20015
- *
20016
- * A timelapse is watched accelerated. Resolution buys nothing at 12 fps over a
20017
- * night, and the cost difference between the bands is one to two orders of
20018
- * magnitude in both bytes read and frames decoded. So there is no trade to
20019
- * weigh: take the cheapest band that has anything, every time.
20020
- *
20021
- * Coverage no longer decides WHICH — only whether a band qualifies at all. A
20022
- * band with less footage is still the right source; the gaps show as a shorter
20023
- * video, not as a reason to read twenty times the bytes.
20024
- */
20025
20014
  function pickProfile(ranges, window) {
20026
20015
  const byProfile = /* @__PURE__ */ new Map();
20027
20016
  for (const r of ranges) {
@@ -20268,18 +20257,20 @@ var TimelapseRenderer = class {
20268
20257
  const finished = await this.awaitReady(queued, tags);
20269
20258
  const published = await this.publish(finished.id, request, tags);
20270
20259
  const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
20260
+ const archive = await this.measureArchive(finished.id, request, chosen, tags);
20261
+ const deliveredRows = archive?.rows ?? availability.filter((r) => r.profile === chosen.profile);
20271
20262
  await this.verifyPacing({
20272
20263
  exportId: finished.id,
20273
20264
  request,
20274
20265
  dense,
20275
20266
  subjects,
20276
- availability: availability.filter((r) => r.profile === chosen.profile),
20267
+ availability: deliveredRows,
20277
20268
  tags
20278
20269
  });
20279
20270
  return {
20280
20271
  exportId: finished.id,
20281
20272
  profile: chosen.profile,
20282
- coverage: chosen.coverage,
20273
+ coverage: archive?.coverage ?? chosen.coverage,
20283
20274
  plan,
20284
20275
  detections,
20285
20276
  artifact: published.video,
@@ -20387,6 +20378,96 @@ var TimelapseRenderer = class {
20387
20378
  throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
20388
20379
  }
20389
20380
  /**
20381
+ * The window against the archive, measured AFTER the export and always said.
20382
+ *
20383
+ * Three figures, one line: the window's length, what the PLAN was cut from
20384
+ * (the availability read before the export) and what the ARCHIVE holds (the
20385
+ * same read after it — the export's verified walk has repaired the index the
20386
+ * calendar serves from by then). `plannedOfArchive` is the ratio D445 found
20387
+ * at 0.20 on camera 4374 and nothing reported; a healthy night reads 1.00
20388
+ * because both reads return the same rows.
20389
+ *
20390
+ * WHY THE THRESHOLD IS A SLACK AND NOT A RATIO. The two reads are the SAME
20391
+ * query against the SAME index, so on a healthy night they return the same
20392
+ * rows and the shortfall is exactly 0 — there is no distribution to pick a
20393
+ * percentile out of. It is non-zero only when something CHANGED the index
20394
+ * between them, and the export's verified walk is the only thing that does.
20395
+ * So the bar is not "how short is too short" (the robot's night was 0.20 and
20396
+ * a milder one would be just as wrong); it is only "is this a real change or
20397
+ * a boundary artefact". One artefact exists: the window is closed, but a
20398
+ * segment straddling its end can finalize while the export runs and extend
20399
+ * the last range by its own length. {@link COVERAGE_COMPLETE_SLACK_MS} (30 s)
20400
+ * is the slack the coverage figure already allows at exactly those edges,
20401
+ * and it is several segments wide. No second constant, and nothing between
20402
+ * "unchanged" and "the planner rendered from a short answer".
20403
+ *
20404
+ * Never throws. A re-read that fails is SAID, the plan's figure stands, and
20405
+ * every figure that needed the archive is `null` rather than a stand-in.
20406
+ */
20407
+ async measureArchive(exportId, request, chosen, tags) {
20408
+ const { window } = request;
20409
+ const windowSec = Math.round((window.endMs - window.startMs) / 1e3);
20410
+ const plannedSec = Math.round(chosen.coverage.coveredMs / 1e3);
20411
+ let rows;
20412
+ try {
20413
+ rows = (await this.ports.getAvailability({
20414
+ deviceId: request.deviceId,
20415
+ fromMs: window.startMs,
20416
+ toMs: window.endMs,
20417
+ profile: chosen.profile
20418
+ })).ranges.filter((r) => r.profile === chosen.profile);
20419
+ } catch (err) {
20420
+ this.ports.logger.warn("timelapse coverage: archive re-read failed — the window is reported against the plan only", {
20421
+ tags,
20422
+ meta: {
20423
+ exportId,
20424
+ ruleId: request.ruleId,
20425
+ profile: chosen.profile,
20426
+ windowSec,
20427
+ plannedSec,
20428
+ plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
20429
+ archiveSec: null,
20430
+ plannedOfArchive: null,
20431
+ archiveOfWindow: null,
20432
+ error: String(err)
20433
+ }
20434
+ });
20435
+ return null;
20436
+ }
20437
+ const coverage = footageCoverage(rows, window.startMs, window.endMs);
20438
+ const shortfallMs = coverage.coveredMs - chosen.coverage.coveredMs;
20439
+ const meta = {
20440
+ exportId,
20441
+ ruleId: request.ruleId,
20442
+ profile: chosen.profile,
20443
+ windowSec,
20444
+ plannedSec,
20445
+ archiveSec: Math.round(coverage.coveredMs / 1e3),
20446
+ /** What the operator ASKED for against what the file HOLDS — the ratio
20447
+ * the robot's night made someone file a report over: ten hours in,
20448
+ * 6.4 s of video out. Low is not by itself a defect (a lazy source is
20449
+ * legitimately idle most of a night), which is why it never gates. */
20450
+ plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
20451
+ archiveOfWindow: ratio(coverage.coveredMs, window.endMs - window.startMs),
20452
+ /** The one that IS a defect: what the planner saw against what the
20453
+ * export's verified walk found. 0.20 on camera 4374, 1.00 when healthy. */
20454
+ plannedOfArchive: ratio(chosen.coverage.coveredMs, coverage.coveredMs),
20455
+ shortfallSec: Math.round(shortfallMs / 1e3)
20456
+ };
20457
+ if (shortfallMs > 3e4) this.ports.logger.warn("timelapse coverage: the plan was cut from less footage than the archive holds", {
20458
+ tags,
20459
+ meta
20460
+ });
20461
+ else this.ports.logger.info("timelapse coverage: the window against the archive", {
20462
+ tags,
20463
+ meta
20464
+ });
20465
+ return {
20466
+ rows,
20467
+ coverage
20468
+ };
20469
+ }
20470
+ /**
20390
20471
  * Run the pacing check against the finished file, and SAY what it found.
20391
20472
  *
20392
20473
  * Never throws and never blocks past its bound: the check is telemetry about
@@ -28409,6 +28490,34 @@ var MEDIA_INDEXES = [
28409
28490
  "deviceId",
28410
28491
  "sizeBytes"
28411
28492
  ]
28493
+ }),
28494
+ (
28495
+ /**
28496
+ * The per-LOCATION footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE locationId
28497
+ * = ?`, and the legacy `locationId IS NULL` half of the same question. See
28498
+ * {@link MediaStore.footprintByLocation} (D388) and D447.
28499
+ *
28500
+ * **Why not `idx_media_location`.** It leads with `locationId`, so it finds
28501
+ * the range — and then, exactly as the `idx_media_device_size` note above
28502
+ * describes for `deviceId`, fetches the table row behind every entry to read
28503
+ * `sizeBytes`. Every row of the store is on SOME location, so the range is
28504
+ * the table: measured 2026-09-10/11 on `hub/sqlite-settings`, 4 128–6 962 ms
28505
+ * per statement, every five minutes (the occupancy refresh), all night —
28506
+ * `plan=["SEARCH … USING INDEX idx_media_location (locationId=?)"]` on every
28507
+ * WARN. That thread is synchronous, so every configuration read in the
28508
+ * cluster queued behind it: the orchestrator's `configMs=6640` at 23:26:11
28509
+ * is this statement's 6 002 ms at 23:26:10.
28510
+ *
28511
+ * With `sizeBytes` in the index the planner never touches the table
28512
+ * (`USING COVERING INDEX idx_media_location_size`), and the seal gate keeps
28513
+ * its own narrower index. Same shape, same one-off caveat as its two
28514
+ * siblings: ~30 bytes a row, one more b-tree per insert, and the first
28515
+ * `declareCollection` after this ships builds it over every existing row —
28516
+ * do not deploy it beside a running drain or seal.
28517
+ */
28518
+ {
28519
+ name: "idx_media_location_size",
28520
+ columns: ["locationId", "sizeBytes"]
28412
28521
  })
28413
28522
  ];
28414
28523
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -65209,6 +65318,44 @@ function buildAudioLabels(input) {
65209
65318
  }];
65210
65319
  }
65211
65320
  //#endregion
65321
+ //#region src/pipeline-analytics/services/event-kinds-config.ts
65322
+ /** The honest answer for a pipeline that could not be read: nothing advertised. */
65323
+ var NO_EMITTABLE = {
65324
+ macroClasses: [],
65325
+ audioEnabled: false
65326
+ };
65327
+ /** Depth-first walk of the step tree; the detector is a root today, but the
65328
+ * question is "is it enabled anywhere", not "is it a root". */
65329
+ function* walkSteps(steps) {
65330
+ for (const step of steps) {
65331
+ yield step;
65332
+ if (step.children !== void 0) yield* walkSteps(step.children);
65333
+ }
65334
+ }
65335
+ /**
65336
+ * The macro classes an enabled `object-detection` step emits. Order follows
65337
+ * the authority, not the stored array, so two cameras narrowed to the same
65338
+ * set render the same lanes in the same order.
65339
+ */
65340
+ function macroClassesOf(step) {
65341
+ const stored = step.settings?.["enabledMacroClasses"];
65342
+ if (!Array.isArray(stored) || stored.length === 0) return [...FIRST_LEVEL_MACRO_CLASSES];
65343
+ const chosen = new Set(stored.filter((v) => typeof v === "string"));
65344
+ return FIRST_LEVEL_MACRO_CLASSES.filter((macro) => chosen.has(macro));
65345
+ }
65346
+ function emittableFromPipelineConfig(config) {
65347
+ let macroClasses = [];
65348
+ for (const step of walkSteps(config.steps)) {
65349
+ if (step.addonId !== "object-detection" || step.enabled === false) continue;
65350
+ macroClasses = macroClassesOf(step);
65351
+ break;
65352
+ }
65353
+ return {
65354
+ macroClasses,
65355
+ audioEnabled: config.audio?.enabled === true
65356
+ };
65357
+ }
65358
+ //#endregion
65212
65359
  //#region src/pipeline-analytics/services/event-kinds.ts
65213
65360
  /**
65214
65361
  * Extensible per-device event kinds (Part B).
@@ -78116,28 +78263,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78116
78263
  };
78117
78264
  }
78118
78265
  /**
78119
- * Every event kind the device can produce: built-ins (motion + audio),
78120
- * detection classes actually observed on the device, and sensor kinds
78121
- * from LINKED devices (device-manager `getLinkedDevices`, binding-driven
78122
- * per linked device). Degrades to the built-ins on error.
78266
+ * Every event kind the device can produce: built-ins (motion + audio), the
78267
+ * detection classes its CONFIGURED pipeline can emit, and sensor kinds from
78268
+ * LINKED devices (device-manager `getLinkedDevices`, binding-driven per
78269
+ * linked device). Degrades to the built-ins on error.
78270
+ *
78271
+ * The detection and audio facts are read from `resolvePipeline` — the
78272
+ * configuration — and never from `getCameraStatus`, which is RUNTIME: an
78273
+ * on-motion camera holds no session between motion events
78274
+ * (`reasons.detection: 'armed:on-motion'`, `detection: null`) and was being
78275
+ * advertised as detecting nothing while its tracks said person / vehicle /
78276
+ * animal ([D449](../../../../docs/decisions/adr-0449-the-timeline-taxonomy-is-the-configured-one-raw-forward-has-no-switch-and-a-one-row-write-waits-on-the-disk.md)).
78123
78277
  */
78124
78278
  async listEventKinds(input) {
78125
78279
  const api = this.ctx.api;
78126
- const status = await api.pipelineOrchestrator.getCameraStatus.query({ deviceId: input.deviceId }).catch((err) => {
78127
- this.ctx.logger.warn("listEventKinds: getCameraStatus failed", {
78128
- tags: { deviceId: input.deviceId },
78129
- meta: { error: errMsg$1(err) }
78130
- });
78131
- return null;
78132
- });
78133
- const detectionOn = status?.detection != null;
78134
- const audioOn = status?.audio?.enabled === true;
78135
- const detectionMacros = MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
78280
+ const emittable = await this.emittableFromConfig(input.deviceId, "listEventKinds");
78136
78281
  return composeEventKinds({
78137
78282
  linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
78138
78283
  bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
78139
- enabledMacroClasses: async () => detectionOn ? detectionMacros : [],
78140
- audioEnabled: async () => audioOn,
78284
+ enabledMacroClasses: async () => emittable.macroClasses,
78285
+ audioEnabled: async () => emittable.audioEnabled,
78141
78286
  packageZonesEnabled: async (deviceId) => {
78142
78287
  return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
78143
78288
  },
@@ -78151,6 +78296,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78151
78296
  }, input.deviceId);
78152
78297
  }
78153
78298
  /**
78299
+ * What one camera's configured pipeline can emit (`resolvePipeline`), or
78300
+ * the honest fallback when the orchestrator could not answer: nothing
78301
+ * advertised, and a WARN saying so. The cap's answer is a plain list, so it
78302
+ * has no room for "unknown" — that residual of D315 is named in D449; the
78303
+ * steady state (an armed camera with no session) is no longer in it.
78304
+ */
78305
+ async emittableFromConfig(deviceId, scope) {
78306
+ try {
78307
+ return emittableFromPipelineConfig(await this.ctx.api.pipelineOrchestrator.resolvePipeline.query({ deviceId }));
78308
+ } catch (err) {
78309
+ this.ctx.logger.warn(`${scope}: resolvePipeline failed — no ML kinds advertised`, {
78310
+ tags: { deviceId },
78311
+ meta: { error: errMsg$1(err) }
78312
+ });
78313
+ return NO_EMITTABLE;
78314
+ }
78315
+ }
78316
+ /**
78154
78317
  * `listEventKinds` for many cameras, in one call.
78155
78318
  *
78156
78319
  * The saving is not the round-trips the CALLER avoids — it is the fan-out
@@ -78162,11 +78325,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78162
78325
  * `getLinkedDevices` alone took 4110 ms — each one runs a full-fleet
78163
78326
  * `listAll` inside device-manager and thirty of them QUEUE.
78164
78327
  *
78165
- * Now three calls for the whole set, whatever N is: one
78166
- * `getCameraStatuses`, then `prefetchDeviceTopology`'s
78167
- * `getLinkedDevicesBatch` + `getBindingsBatch`. `composeEventKinds` is
78168
- * unchanged it is handed the same per-device client interfaces, backed by
78169
- * maps instead of RPC.
78328
+ * Now two calls for the whole topology, whatever N is
78329
+ * `prefetchDeviceTopology`'s `getLinkedDevicesBatch` + `getBindingsBatch`
78330
+ * plus one `resolvePipeline` per camera, all in flight at once (D449; there
78331
+ * is no batch form of it). `composeEventKinds` is unchanged it is handed
78332
+ * the same per-device client interfaces, backed by maps instead of RPC.
78170
78333
  *
78171
78334
  * A camera whose composition fails yields an EMPTY list rather than dropping
78172
78335
  * out of the response: a caller that asked for twelve and got eleven cannot
@@ -78175,15 +78338,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78175
78338
  async listEventKindsBatch(input) {
78176
78339
  const api = this.ctx.api;
78177
78340
  const deviceIds = [...new Set(input.deviceIds)];
78178
- const statuses = await api.pipelineOrchestrator.getCameraStatuses.query({ deviceIds }).catch((err) => {
78179
- this.ctx.logger.warn("listEventKindsBatch: getCameraStatuses failed", { meta: {
78180
- deviceCount: deviceIds.length,
78181
- error: errMsg$1(err)
78182
- } });
78183
- return [];
78184
- });
78185
- const statusByDevice = new Map(statuses.map((st) => [st.deviceId, st]));
78186
- const detectionMacros = MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
78341
+ const emittableByDevice = new Map(await Promise.all(deviceIds.map(async (deviceId) => [deviceId, await this.emittableFromConfig(deviceId, "listEventKindsBatch")])));
78187
78342
  const topology = await prefetchDeviceTopology({
78188
78343
  getLinkedDevicesBatch: (i) => api.deviceManager.getLinkedDevicesBatch.query(i),
78189
78344
  getBindingsBatch: (i) => api.deviceManager.getBindingsBatch.query(i)
@@ -78195,14 +78350,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78195
78350
  return null;
78196
78351
  });
78197
78352
  return composeEventKindsForDevices(deviceIds, async (deviceId) => {
78198
- const status = statusByDevice.get(deviceId) ?? null;
78199
- const detectionOn = status?.detection != null;
78200
- const audioOn = status?.audio?.enabled === true;
78353
+ const emittable = emittableByDevice.get(deviceId) ?? NO_EMITTABLE;
78201
78354
  return composeEventKinds({
78202
78355
  linkedDevices: topology?.linkedDevices ?? { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
78203
78356
  bindings: topology?.bindings ?? { getBindings: (i) => api.deviceManager.getBindings.query(i) },
78204
- enabledMacroClasses: async () => detectionOn ? detectionMacros : [],
78205
- audioEnabled: async () => audioOn,
78357
+ enabledMacroClasses: async () => emittable.macroClasses,
78358
+ audioEnabled: async () => emittable.audioEnabled,
78206
78359
  packageZonesEnabled: async (id) => {
78207
78360
  return (await this.resolveDevicePackageRules(id)).some((r) => r.enabled !== false);
78208
78361
  },
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C8d8YCvw.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Bfm6wOeY.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.219",
3
+ "version": "1.2.220",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",