@camstack/addon-post-analysis 1.2.64 → 1.2.66

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.
@@ -1,4 +1,4 @@
1
- import { $ as array, B as subKindsOf, C as addonWidgetsSourceCapability, D as cosineSimilarity, E as buildEventKindDescriptor, F as kebabToCamel, G as BaseAddon, H as videoclipsCapability, I as notificationRulesCapability, J as hydrateSchema, K as DeviceType, L as pipelineAnalyticsCapability, M as faceGalleryCapability, O as customAction, P as isScheduleActive, Q as _enum, R as plateGalleryCapability, S as TrackSourceSchema, T as audioMetricsCapability, U as zoneAnalyticsCapability, V as vectorDimFromBase64, W as errMsg, X as nodePin, Y as isDeviceScopedCap, Z as sleep, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as string, b as TimelapseRuleInputSchema, c as NC_CONDITION_CATALOG, d as NcRuleInputSchema, et as boolean, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as record, j as encodeVectorBase64, k as defineCustomActions, l as NC_TAXONOMY, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as number, o as LabelAttributionSchema, ot as unknown, p as NcRuleSchema, q as createEvent, r as DeclaredDevices, rt as object, s as MACRO_LABELS, st as EventCategory, t as BaseDevice, tt as literal, u as NcConditionDescriptorSchema, v as OpsLogEntrySchema, w as alarmPanelCapability, x as TimelapseRuleSchema, y as RetrainStatusSchema, z as readDeviceStateFrom } from "../dist-CSTtfEOC.mjs";
1
+ import { $ as isDeviceScopedCap, A as customAction, B as pipelineAnalyticsCapability, C as TimelapseRuleSchema, D as audioMetricsCapability, E as alarmPanelCapability, F as faceGalleryCapability, G as vectorDimFromBase64, H as readDeviceStateFrom, J as errMsg, K as videoclipsCapability, L as isScheduleActive, M as deriveRecordingMode, O as buildEventKindDescriptor, P as encodeVectorBase64, Q as hydrateSchema, R as kebabToCamel, S as TimelapseRulePatchSchema, T as addonWidgetsSourceCapability, U as readTimelapseGeneratedAt, V as plateGalleryCapability, W as subKindsOf, X as DeviceType, Y as BaseAddon, Z as createEvent, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as literal, b as RetrainStatusSchema, c as NC_CONDITION_CATALOG, ct as record, d as NcRuleInputSchema, dt as EventCategory, et as nodePin, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as boolean, j as defineCustomActions, k as cosineSimilarity, l as NC_TAXONOMY, lt as string, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as _enum, o as LabelAttributionSchema, ot as number, p as NcRuleSchema, q as zoneAnalyticsCapability, r as DeclaredDevices, rt as array, s as MACRO_LABELS, st as object, t as BaseDevice, tt as sleep, u as NcConditionDescriptorSchema, ut as unknown, v as OpsLogEntrySchema, w as TrackSourceSchema, x as TimelapseRuleInputSchema, y as RECORDING_EXPORT_MAX_READ_BYTES, z as notificationRulesCapability } from "../dist-DPzTTt9R.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";
@@ -1114,6 +1114,25 @@ var NcArtifactPlane = class {
1114
1114
  return null;
1115
1115
  }
1116
1116
  }
1117
+ /**
1118
+ * Absolutize a path against the SAME base URL an artifact link would use.
1119
+ *
1120
+ * For links this plane does not host but a RECIPIENT still has to open — a
1121
+ * recorder export download, served `access: 'authenticated'`. It is not
1122
+ * signed and it is not public: it is a tap-through the person opens where
1123
+ * they already hold a session, which is why it goes on `clickUrl` and never
1124
+ * on an attachment.
1125
+ *
1126
+ * Reuses `resolveBaseUrl` rather than re-deriving one, because "which
1127
+ * address can the recipient actually reach" is a question this repo has
1128
+ * already answered wrongly once (the first artifact link went out on the LAN
1129
+ * address while a tunnel was connected).
1130
+ */
1131
+ async resolveExternalUrl(pathname) {
1132
+ const baseUrl = await this.resolveBaseUrl().catch(() => null);
1133
+ if (baseUrl === null) return null;
1134
+ return `${baseUrl.replace(/\/+$/, "")}/${pathname.replace(/^\/+/, "")}`;
1135
+ }
1117
1136
  /** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
1118
1137
  handler = async (req, res) => {
1119
1138
  const url = new URL(req.url ?? "/", "http://placeholder");
@@ -2728,7 +2747,7 @@ var NcDispatcher = class {
2728
2747
  const vars = buildTemplateVars(entry, deviceName, zoneLabels);
2729
2748
  const title = renderTemplate(entry.payload.template?.title, vars) ?? systemEvent?.title ?? entry.payload.ruleName;
2730
2749
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
2731
- const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
2750
+ const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
2732
2751
  const params = pickParams(entry.payload.params);
2733
2752
  const actions = await this.mintActions(entry, target);
2734
2753
  return {
@@ -2996,6 +3015,33 @@ function occupancyOpWord(occupied) {
2996
3015
  return occupied ? "occupied" : "free";
2997
3016
  }
2998
3017
  /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
3018
+ /**
3019
+ * The attachment a row was CREATED with, if any.
3020
+ *
3021
+ * Url-only by construction: the bytes were never in this process (a timelapse
3022
+ * is rendered by the recorder and republished on the artifact plane), and a
3023
+ * durable outbox row must never carry an mp4. The degrade engine turns this
3024
+ * into an upload for a bytes-only backend and a plain link for a url-mode one
3025
+ * — which is the whole reason a url-mode target now receives the video at all.
3026
+ */
3027
+ function frozenArtifactAttachments(entry) {
3028
+ const artifact = entry.payload.artifact;
3029
+ if (artifact === void 0) return [];
3030
+ return [{
3031
+ mediaType: artifact.mediaType,
3032
+ url: artifact.url,
3033
+ mime: artifact.mime,
3034
+ name: artifact.name
3035
+ }];
3036
+ }
3037
+ /**
3038
+ * `{{var}}` substitution over a frozen variable map. An unknown name renders
3039
+ * EMPTY rather than leaving the braces on screen.
3040
+ *
3041
+ * Exported so the timelapse producer renders its title/body with the SAME
3042
+ * vocabulary and the same missing-var rule. A second `{{}}` renderer in this
3043
+ * package would be free to disagree about exactly that.
3044
+ */
2999
3045
  function renderTemplate(template, vars) {
3000
3046
  if (template === void 0 || template.trim().length === 0) return null;
3001
3047
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
@@ -5287,8 +5333,20 @@ function applyTemplatePatch(merged, template) {
5287
5333
  const { template: _cleared, ...withoutTemplate } = merged;
5288
5334
  return withoutTemplate;
5289
5335
  }
5336
+ /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
5337
+ function isRecord$1(value) {
5338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5339
+ }
5290
5340
  var TimelapseStore = class {
5291
5341
  byId = /* @__PURE__ */ new Map();
5342
+ /**
5343
+ * Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
5344
+ * so a repeated `load()` re-uses the FIRST one. Without it the synthesised
5345
+ * `createdAt` would advance to "now" on every refresh, and the scheduler's
5346
+ * eligibility floor — which is `max(stamp, createdAt)` — would creep past
5347
+ * every window such a rule was ever offered, silently producing nothing.
5348
+ */
5349
+ synthesisedCreatedAt = /* @__PURE__ */ new Map();
5292
5350
  store;
5293
5351
  logger;
5294
5352
  now;
@@ -5320,8 +5378,11 @@ var TimelapseStore = class {
5320
5378
  });
5321
5379
  this.byId.clear();
5322
5380
  let skipped = 0;
5381
+ let synthesised = 0;
5323
5382
  for (const row of rows) {
5324
- const parsed = TimelapseRuleSchema.safeParse(row.data["rule"]);
5383
+ const raw = this.withCreatedAt(row.id, row.data["rule"]);
5384
+ if (raw !== row.data["rule"]) synthesised += 1;
5385
+ const parsed = TimelapseRuleSchema.safeParse(raw);
5325
5386
  if (!parsed.success) {
5326
5387
  skipped += 1;
5327
5388
  continue;
@@ -5330,12 +5391,40 @@ var TimelapseStore = class {
5330
5391
  }
5331
5392
  this.logger.debug("timelapse rules loaded", { meta: {
5332
5393
  rules: this.byId.size,
5333
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
5394
+ ...skipped > 0 ? { skippedInvalid: skipped } : {},
5395
+ ...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
5334
5396
  } });
5335
5397
  } catch (err) {
5336
5398
  this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
5337
5399
  }
5338
5400
  }
5401
+ /**
5402
+ * Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
5403
+ * anything: the rule reads as born at this load.
5404
+ *
5405
+ * `createdAt` has always been required by `TimelapseRuleSchema`, so a row
5406
+ * without one is a pre-schema artefact — and before this it was dropped
5407
+ * outright at load (a silently vanished rule). Reading it as "born now" is
5408
+ * the conservative choice in BOTH directions that matter: the rule survives,
5409
+ * and the scheduler's eligibility floor stops it back-filling a window that
5410
+ * closed years before anyone looked at it. The cost is one skipped window if
5411
+ * a legacy rule is loaded between a close and its settle — a bounded miss,
5412
+ * against an unbounded retry of a window no footage can satisfy.
5413
+ *
5414
+ * The synthesised value is remembered per rule id so a later refresh does not
5415
+ * move the floor, and the first `update`/`markGenerated` writes it through —
5416
+ * after which the row is no longer legacy.
5417
+ */
5418
+ withCreatedAt(rowId, raw) {
5419
+ if (!isRecord$1(raw)) return raw;
5420
+ if (typeof raw["createdAt"] === "number") return raw;
5421
+ const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
5422
+ this.synthesisedCreatedAt.set(rowId, stamped);
5423
+ return {
5424
+ ...raw,
5425
+ createdAt: stamped
5426
+ };
5427
+ }
5339
5428
  /** Every rule, newest-first (admin path). */
5340
5429
  list() {
5341
5430
  return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
@@ -5417,6 +5506,7 @@ var TimelapseStore = class {
5417
5506
  id: existing.id,
5418
5507
  ownerUserId: existing.ownerUserId,
5419
5508
  lastGeneratedAt: existing.lastGeneratedAt,
5509
+ generatedByDevice: existing.generatedByDevice,
5420
5510
  createdBy: existing.createdBy,
5421
5511
  createdAt: existing.createdAt,
5422
5512
  updatedAt: this.now()
@@ -5427,16 +5517,39 @@ var TimelapseStore = class {
5427
5517
  return this.update(ruleId, { enabled });
5428
5518
  }
5429
5519
  /**
5430
- * Record a successful generation. `at` is the generation epoch-ms — the
5431
- * durable state behind the 1-hour re-generation guard. Does NOT bump
5432
- * `updatedAt` (generation is not an edit of the rule definition).
5520
+ * Record a successful generation for ONE camera of the rule. `at` is the
5521
+ * generation epoch-ms — the durable state behind the re-generation guard.
5522
+ * Does NOT bump `updatedAt` (a generation is not an edit of the rule).
5523
+ *
5524
+ * PER-DEVICE, and that is the whole point: a rule covering three cameras
5525
+ * renders three videos, and stamping the rule when the first one succeeds
5526
+ * tells the other two they are already done. Their night is then gone for
5527
+ * good — a closed window does not come back. Call this ONLY after the export
5528
+ * reached `ready` and the notification was enqueued; a failed render must
5529
+ * leave the pair unstamped so the next tick can retry it.
5530
+ *
5531
+ * The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
5532
+ * a UI shows) and never regresses when an older device generation lands.
5533
+ *
5534
+ * A rule that still carries only the LEGACY rule-wide stamp has its map
5535
+ * SEEDED from it for every camera the rule covers, before the new stamp is
5536
+ * applied — writing `{ [deviceId]: at }` alone would drop the other cameras
5537
+ * to "never generated" and re-render them once.
5433
5538
  */
5434
- async markGenerated(ruleId, at) {
5539
+ async markGenerated(ruleId, deviceId, at) {
5435
5540
  const existing = this.byId.get(ruleId);
5436
5541
  if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
5542
+ const seeded = {};
5543
+ if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
5544
+ const generatedByDevice = {
5545
+ ...seeded,
5546
+ ...existing.generatedByDevice,
5547
+ [String(deviceId)]: at
5548
+ };
5437
5549
  return this.write({
5438
5550
  ...existing,
5439
- lastGeneratedAt: at
5551
+ generatedByDevice,
5552
+ lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
5440
5553
  });
5441
5554
  }
5442
5555
  /** Idempotent delete — unknown ids are a no-op. */
@@ -5481,6 +5594,705 @@ var TimelapseStore = class {
5481
5594
  }
5482
5595
  };
5483
5596
  //#endregion
5597
+ //#region src/notification-center/timelapse/dense-ranges.ts
5598
+ /**
5599
+ * The POST-HOC timelapse's render plan: which seconds of the closed window
5600
+ * deserve the fast cadence.
5601
+ *
5602
+ * Pure — no clock, no I/O, no store. The scheduler hands over the tracks the
5603
+ * window actually contained (`pipelineAnalytics.listTracks`, which carry
5604
+ * `firstSeen`/`lastSeen`) and gets back ranges expressed as SECONDS FROM THE
5605
+ * WINDOW START, which is exactly the coordinate system
5606
+ * `ExportTimelapseSchema.dense.ranges` speaks.
5607
+ *
5608
+ * Why post-hoc and not a live sampler: the footage already exists, the tracks
5609
+ * already exist, and the recorder already knows how to render a range. A live
5610
+ * sampler would hold a frame ring open for eight hours per camera to
5611
+ * reconstruct information the database already has.
5612
+ *
5613
+ * The coalescing rule is `2 × base cadence`: two tracks closer together than
5614
+ * two base frames would render as one continuous busy stretch anyway, so
5615
+ * splitting them buys nothing and costs a range out of the budget.
5616
+ */
5617
+ /** Re-exported so a caller plans and validates against the SAME ceiling the
5618
+ * cap enforces — two constants would be free to drift, and the drift would
5619
+ * surface as a rejected export at render time, hours after the window. */
5620
+ var DENSE_MAX_RANGES = 200;
5621
+ /** Clamp a track to the window and pad it, dropping what falls outside. */
5622
+ function clampTrack(track, windowStartMs, windowEndMs, padMs) {
5623
+ const rawFrom = Math.min(track.firstSeen, track.lastSeen);
5624
+ const rawTo = Math.max(track.firstSeen, track.lastSeen);
5625
+ if (rawTo < windowStartMs || rawFrom > windowEndMs) return null;
5626
+ const fromMs = Math.max(windowStartMs, rawFrom - padMs);
5627
+ const toMs = Math.min(windowEndMs, rawTo + padMs);
5628
+ return toMs > fromMs ? {
5629
+ fromMs,
5630
+ toMs
5631
+ } : null;
5632
+ }
5633
+ /**
5634
+ * Merge the window's tracks into dense ranges.
5635
+ *
5636
+ * @returns ranges in seconds from `windowStartMs`, already coalesced, sorted
5637
+ * and clamped — or an empty capped plan.
5638
+ */
5639
+ function planDenseRanges(input) {
5640
+ const maxRanges = Math.max(1, input.maxRanges ?? DENSE_MAX_RANGES);
5641
+ const padMs = Math.max(0, input.padSec) * 1e3;
5642
+ const gapMs = Math.max(1, input.baseCadenceSec) * 2e3;
5643
+ const clamped = [];
5644
+ for (const track of input.tracks) {
5645
+ const r = clampTrack(track, input.windowStartMs, input.windowEndMs, padMs);
5646
+ if (r !== null) clamped.push(r);
5647
+ }
5648
+ clamped.sort((a, b) => a.fromMs - b.fromMs);
5649
+ const merged = [];
5650
+ for (const r of clamped) {
5651
+ const last = merged[merged.length - 1];
5652
+ if (last !== void 0 && r.fromMs - last.toMs < gapMs) {
5653
+ merged[merged.length - 1] = {
5654
+ fromMs: last.fromMs,
5655
+ toMs: Math.max(last.toMs, r.toMs)
5656
+ };
5657
+ continue;
5658
+ }
5659
+ merged.push(r);
5660
+ }
5661
+ if (merged.length > maxRanges) return {
5662
+ ranges: [],
5663
+ capped: true,
5664
+ mergedCount: merged.length,
5665
+ denseSec: 0
5666
+ };
5667
+ const ranges = merged.map((r) => ({
5668
+ fromSec: (r.fromMs - input.windowStartMs) / 1e3,
5669
+ toSec: (r.toMs - input.windowStartMs) / 1e3
5670
+ }));
5671
+ const denseSec = ranges.reduce((sum, r) => sum + (r.toSec - r.fromSec), 0);
5672
+ return {
5673
+ ranges,
5674
+ capped: false,
5675
+ mergedCount: merged.length,
5676
+ denseSec
5677
+ };
5678
+ }
5679
+ //#endregion
5680
+ //#region src/notification-center/timelapse/footage-coverage.ts
5681
+ /**
5682
+ * Slack at the window edges before a window counts as incomplete.
5683
+ *
5684
+ * Recorded footage is segment-granular, so the last segment almost never lands
5685
+ * exactly on the boundary. Without slack every complete night reports "99% of
5686
+ * the window" — noise the operator learns to skip, which is how the genuinely
5687
+ * short video gets skipped too.
5688
+ */
5689
+ var COVERAGE_COMPLETE_SLACK_MS = 3e4;
5690
+ /** Merge + clamp + sum. */
5691
+ function footageCoverage(ranges, windowStartMs, windowEndMs) {
5692
+ const windowMs = Math.max(0, windowEndMs - windowStartMs);
5693
+ if (windowMs === 0) return {
5694
+ fraction: 0,
5695
+ percent: 0,
5696
+ coveredMs: 0,
5697
+ windowMs: 0,
5698
+ complete: false
5699
+ };
5700
+ const clamped = ranges.map((r) => ({
5701
+ startMs: Math.max(windowStartMs, Math.min(r.startMs, r.endMs)),
5702
+ endMs: Math.min(windowEndMs, Math.max(r.startMs, r.endMs))
5703
+ })).filter((r) => r.endMs > r.startMs).sort((a, b) => a.startMs - b.startMs);
5704
+ let coveredMs = 0;
5705
+ let curStart = null;
5706
+ let curEnd = 0;
5707
+ for (const r of clamped) {
5708
+ if (curStart === null) {
5709
+ curStart = r.startMs;
5710
+ curEnd = r.endMs;
5711
+ continue;
5712
+ }
5713
+ if (r.startMs <= curEnd) {
5714
+ curEnd = Math.max(curEnd, r.endMs);
5715
+ continue;
5716
+ }
5717
+ coveredMs += curEnd - curStart;
5718
+ curStart = r.startMs;
5719
+ curEnd = r.endMs;
5720
+ }
5721
+ if (curStart !== null) coveredMs += curEnd - curStart;
5722
+ const fraction = coveredMs / windowMs;
5723
+ return {
5724
+ fraction,
5725
+ percent: Math.round(fraction * 100),
5726
+ coveredMs,
5727
+ windowMs,
5728
+ complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
5729
+ };
5730
+ }
5731
+ /** How long a scheduled timelapse's file is kept by the export engine. */
5732
+ var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
5733
+ var TimelapseRenderError = class extends Error {
5734
+ reason;
5735
+ constructor(reason, message) {
5736
+ super(message);
5737
+ this.name = "TimelapseRenderError";
5738
+ this.reason = reason;
5739
+ }
5740
+ };
5741
+ /** The profile with the most footage over the window; ties broken by name so
5742
+ * the same window always renders from the same source. */
5743
+ function pickProfile(ranges, window) {
5744
+ const byProfile = /* @__PURE__ */ new Map();
5745
+ for (const r of ranges) {
5746
+ const list = byProfile.get(r.profile);
5747
+ if (list === void 0) byProfile.set(r.profile, [r]);
5748
+ else list.push(r);
5749
+ }
5750
+ let best = null;
5751
+ for (const [profile, rows] of [...byProfile].sort((a, b) => a[0].localeCompare(b[0]))) {
5752
+ const coverage = footageCoverage(rows, window.startMs, window.endMs);
5753
+ if (best === null || coverage.coveredMs > best.coverage.coveredMs) best = {
5754
+ profile,
5755
+ coverage
5756
+ };
5757
+ }
5758
+ return best;
5759
+ }
5760
+ /** `HH:MM`, for the export title an operator sees in the exports list. */
5761
+ function clockOf$1(atMs) {
5762
+ const d = new Date(atMs);
5763
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
5764
+ }
5765
+ var TimelapseRenderer = class {
5766
+ ports;
5767
+ pollMs;
5768
+ timeoutMs;
5769
+ constructor(ports) {
5770
+ this.ports = ports;
5771
+ this.pollMs = ports.pollMs ?? 5e3;
5772
+ this.timeoutMs = ports.timeoutMs ?? 18e5;
5773
+ }
5774
+ async render(request) {
5775
+ const { deviceId, window } = request;
5776
+ const tags = { deviceId };
5777
+ const chosen = pickProfile(await this.ports.getAvailability({
5778
+ deviceId,
5779
+ fromMs: window.startMs,
5780
+ toMs: window.endMs
5781
+ }), window);
5782
+ if (chosen === null || chosen.coverage.coveredMs === 0) {
5783
+ this.ports.logger.warn("timelapse skipped: no footage covers the window", {
5784
+ tags,
5785
+ meta: {
5786
+ ruleId: request.ruleId,
5787
+ windowStart: window.startMs,
5788
+ windowEnd: window.endMs
5789
+ }
5790
+ });
5791
+ throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
5792
+ }
5793
+ const plan = planDenseRanges({
5794
+ tracks: await this.ports.listTracks({
5795
+ deviceId,
5796
+ since: window.startMs,
5797
+ until: window.endMs
5798
+ }).catch((err) => {
5799
+ this.ports.logger.warn("timelapse dense plan unavailable — rendering uniform", {
5800
+ tags,
5801
+ meta: {
5802
+ ruleId: request.ruleId,
5803
+ error: String(err)
5804
+ }
5805
+ });
5806
+ return [];
5807
+ }),
5808
+ windowStartMs: window.startMs,
5809
+ windowEndMs: window.endMs,
5810
+ padSec: 5,
5811
+ baseCadenceSec: request.cadenceSec
5812
+ });
5813
+ const everyMs = request.cadenceSec * 1e3;
5814
+ const denseEveryMs = Math.max(1e3, Math.round(everyMs / 10));
5815
+ const dense = plan.ranges.length > 0 && denseEveryMs < everyMs ? {
5816
+ everyMs: denseEveryMs,
5817
+ ranges: [...plan.ranges]
5818
+ } : void 0;
5819
+ if (plan.capped) this.ports.logger.info("timelapse dense plan exceeded the range ceiling — uniform cadence", {
5820
+ tags,
5821
+ meta: {
5822
+ ruleId: request.ruleId,
5823
+ mergedRanges: plan.mergedCount
5824
+ }
5825
+ });
5826
+ const queued = await this.ports.createExport({
5827
+ deviceId,
5828
+ profile: chosen.profile,
5829
+ fromMs: window.startMs,
5830
+ toMs: window.endMs,
5831
+ options: {
5832
+ includeAudio: false,
5833
+ maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
5834
+ deleteAfterDownload: false,
5835
+ title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
5836
+ timelapse: {
5837
+ everyMs,
5838
+ outputFps: request.framerate,
5839
+ ...dense !== void 0 ? { dense } : {}
5840
+ }
5841
+ }
5842
+ });
5843
+ this.ports.logger.info("timelapse export queued", {
5844
+ tags,
5845
+ meta: {
5846
+ ruleId: request.ruleId,
5847
+ exportId: queued.id,
5848
+ profile: chosen.profile,
5849
+ everyMs,
5850
+ denseRanges: plan.ranges.length,
5851
+ coveragePct: chosen.coverage.percent
5852
+ }
5853
+ });
5854
+ const finished = await this.awaitReady(queued, tags);
5855
+ const artifact = await this.publish(finished.id, deviceId, tags);
5856
+ const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
5857
+ return {
5858
+ exportId: finished.id,
5859
+ profile: chosen.profile,
5860
+ coverage: chosen.coverage,
5861
+ plan,
5862
+ artifact,
5863
+ downloadUrl: downloadUrl ?? null,
5864
+ fileBytes: finished.fileBytes
5865
+ };
5866
+ }
5867
+ /** Poll to a terminal state, under a bound. */
5868
+ async awaitReady(queued, tags) {
5869
+ if (queued.state === "ready") return queued;
5870
+ const deadline = this.ports.now() + this.timeoutMs;
5871
+ let current = queued;
5872
+ while (this.ports.now() < deadline) {
5873
+ await this.ports.sleep(this.pollMs);
5874
+ current = await this.ports.getExport({ exportId: queued.id });
5875
+ if (current.state === "ready") return current;
5876
+ if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
5877
+ this.ports.logger.warn("timelapse export did not finish", {
5878
+ tags,
5879
+ meta: {
5880
+ exportId: queued.id,
5881
+ state: current.state,
5882
+ error: current.error
5883
+ }
5884
+ });
5885
+ throw new TimelapseRenderError("export-failed", current.error ?? `export ${queued.id} ended ${current.state}`);
5886
+ }
5887
+ }
5888
+ this.ports.logger.warn("timelapse export timed out", {
5889
+ tags,
5890
+ meta: {
5891
+ exportId: queued.id,
5892
+ timeoutMs: this.timeoutMs,
5893
+ lastState: current.state
5894
+ }
5895
+ });
5896
+ throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
5897
+ }
5898
+ /**
5899
+ * Mint the public link, or say why there is none.
5900
+ *
5901
+ * Never throws: a video that exists and cannot be linked is a degraded
5902
+ * notification, not a failed window — and failing here would leave the pair
5903
+ * unstamped and re-render the same eight hours on the next tick.
5904
+ */
5905
+ async publish(exportId, deviceId, tags) {
5906
+ const publish = this.ports.publishArtifact;
5907
+ if (publish === void 0) {
5908
+ this.ports.logger.info("timelapse artifact publishing is not wired — link-less delivery", {
5909
+ tags,
5910
+ meta: { exportId }
5911
+ });
5912
+ return null;
5913
+ }
5914
+ try {
5915
+ const ref = await publish({
5916
+ exportId,
5917
+ deviceId
5918
+ });
5919
+ if (ref === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
5920
+ tags,
5921
+ meta: { exportId }
5922
+ });
5923
+ return ref;
5924
+ } catch (err) {
5925
+ this.ports.logger.warn("timelapse artifact publish threw — link-less delivery", {
5926
+ tags,
5927
+ meta: {
5928
+ exportId,
5929
+ error: String(err)
5930
+ }
5931
+ });
5932
+ return null;
5933
+ }
5934
+ }
5935
+ };
5936
+ //#endregion
5937
+ //#region src/notification-center/timelapse/timelapse-delivery.ts
5938
+ /** `HH:MM` in the host timezone — the two numbers a body actually needs. */
5939
+ function clockOf(atMs) {
5940
+ const d = new Date(atMs);
5941
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
5942
+ }
5943
+ /** Template vocabulary. Deliberately the same NAMES the rule templates use
5944
+ * (`camera`, `rule`, `time`) plus the timelapse-only ones. */
5945
+ function templateVars(input) {
5946
+ return {
5947
+ camera: input.deviceName ?? `camera ${input.deviceId}`,
5948
+ rule: input.ruleName,
5949
+ from: clockOf(input.window.startMs),
5950
+ to: clockOf(input.window.endMs),
5951
+ time: clockOf(input.generatedAt),
5952
+ coverage: String(input.coverage.percent),
5953
+ events: String(input.denseRanges)
5954
+ };
5955
+ }
5956
+ /**
5957
+ * The body an operator reads when he did not author one.
5958
+ *
5959
+ * The COVERAGE sentence is the load-bearing part and only appears when it
5960
+ * carries information: a video assembled from two of the eight hours it claims
5961
+ * to show is visually indistinguishable from a very quiet night, and shipping
5962
+ * it silently is how a three-hour recorder outage looks like nothing happened.
5963
+ * A complete window says nothing — a line every night is a line nobody reads.
5964
+ */
5965
+ function derivedBody(input) {
5966
+ const vars = templateVars(input);
5967
+ const parts = [`${vars.rule} — ${vars.from}→${vars.to}`];
5968
+ if (input.denseCapped === true) parts.push("troppi eventi per il campionamento denso: video a cadenza uniforme");
5969
+ else if (input.denseRanges > 0) parts.push(`${input.denseRanges} intervalli ripresi a cadenza fitta`);
5970
+ if (!input.coverage.complete) parts.push(`ATTENZIONE: registrazione disponibile solo per il ${vars.coverage}% della finestra`);
5971
+ return parts.join(" · ");
5972
+ }
5973
+ /** Per-target params with the derived `clickUrl` filled in where absent. */
5974
+ function paramsFor(target, clickUrl) {
5975
+ if (clickUrl === void 0) return target.params;
5976
+ if (target.params === void 0) return { clickUrl };
5977
+ if (typeof target.params["clickUrl"] === "string") return target.params;
5978
+ return {
5979
+ ...target.params,
5980
+ clickUrl
5981
+ };
5982
+ }
5983
+ /** Stable, retry-safe outbox record id for one (device, closed window). */
5984
+ function timelapseRecordId(deviceId, windowEndMs) {
5985
+ return `timelapse:${deviceId}:${windowEndMs}`;
5986
+ }
5987
+ /**
5988
+ * One outbox row per (still-enabled) target.
5989
+ *
5990
+ * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
5991
+ */
5992
+ function buildTimelapseOutboxInputs(input) {
5993
+ const vars = templateVars(input);
5994
+ const optedOut = new Set(input.disabledTargetIds ?? []);
5995
+ const title = renderTemplate(input.template?.title, vars) ?? `Timelapse — ${vars.camera}`;
5996
+ const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input);
5997
+ const recordId = timelapseRecordId(input.deviceId, input.window.endMs);
5998
+ return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
5999
+ const payload = {
6000
+ ruleName: input.ruleName,
6001
+ delivery: "timelapse",
6002
+ priority: input.priority,
6003
+ template: {
6004
+ title,
6005
+ body
6006
+ },
6007
+ media: "none",
6008
+ ...input.artifact !== void 0 ? { artifact: {
6009
+ mediaType: "video",
6010
+ ...input.artifact
6011
+ } } : {},
6012
+ ...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
6013
+ subject: {
6014
+ deviceId: input.deviceId,
6015
+ className: "timelapse",
6016
+ zones: [],
6017
+ timestamp: input.window.endMs
6018
+ }
6019
+ };
6020
+ return {
6021
+ ruleId: input.ruleId,
6022
+ targetId: target.targetId,
6023
+ deviceId: input.deviceId,
6024
+ recordKind: "timelapse",
6025
+ recordId,
6026
+ payload
6027
+ };
6028
+ });
6029
+ }
6030
+ /**
6031
+ * The most recent window that had already CLOSED at `nowMs`.
6032
+ *
6033
+ * Standing INSIDE a window returns the PREVIOUS one — a scheduler that
6034
+ * answered "the one I am in" would render half a night and burn the guard on
6035
+ * it.
6036
+ *
6037
+ * @returns `null` when no close falls inside `lookbackMs`, when the schedule
6038
+ * never closes (always active), OR when the close was found but its OPEN
6039
+ * predates the lookback floor. The last case is null and not a clamped
6040
+ * window on purpose: reporting the scan floor as the start ships a video
6041
+ * that silently begins hours late.
6042
+ */
6043
+ function findLastClosedWindow(input) {
6044
+ const step = Math.max(1e3, input.stepMs ?? 6e4);
6045
+ const from = Math.floor(input.nowMs / step) * step;
6046
+ const floor = input.nowMs - input.lookbackMs;
6047
+ const active = (t) => isScheduleActive(input.schedule, t);
6048
+ let endMs = null;
6049
+ for (let t = from; t - step >= floor; t -= step) if (!active(t) && active(t - step)) {
6050
+ endMs = t;
6051
+ break;
6052
+ }
6053
+ if (endMs === null) return null;
6054
+ for (let t = endMs - step; t - step >= floor; t -= step) if (!active(t - step)) return {
6055
+ startMs: t,
6056
+ endMs
6057
+ };
6058
+ return null;
6059
+ }
6060
+ /**
6061
+ * The earliest window CLOSE a (rule, device) pair may ever be offered.
6062
+ *
6063
+ * `max(this device's last generation, the rule's birth)`:
6064
+ * - the per-device stamp is the re-generation guard — a window already
6065
+ * produced for THIS camera is done (and a sibling camera's success says
6066
+ * nothing about it, hence `readTimelapseGeneratedAt` and not the rule-wide
6067
+ * field);
6068
+ * - `createdAt` is what stops a brand-new rule mining the past. Without it an
6069
+ * unstamped pair is indistinguishable from one the hub was down for, and
6070
+ * the back-fill happily reaches back a full lookback for footage no rule was
6071
+ * ever recording against.
6072
+ *
6073
+ * The comparison is against the window's CLOSE, matching the stamp guard: a
6074
+ * rule created mid-window still gets that window (its footage exists), a rule
6075
+ * created after the close never does.
6076
+ *
6077
+ * RULE-LEVEL, deliberately. `TimelapseRule.deviceIds` is a plain array that a
6078
+ * patch replaces wholesale, so there is no per-device add time to floor
6079
+ * against; a camera added to an old rule may therefore be offered the one
6080
+ * window that closed before it joined. That over-produces at most a single
6081
+ * video, whereas the defect this function exists to stop is unbounded — an
6082
+ * every-minute retry of a window nothing can satisfy. If a per-device add time
6083
+ * is ever persisted, raise the floor to `max(…, addedAt)` here and nowhere
6084
+ * else.
6085
+ */
6086
+ function timelapseEligibilityFloor(rule, deviceId) {
6087
+ return Math.max(readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
6088
+ }
6089
+ var TimelapseScheduler = class {
6090
+ deps;
6091
+ timer = null;
6092
+ tickInFlight = false;
6093
+ settleMs;
6094
+ lookbackMs;
6095
+ tickMs;
6096
+ constructor(deps) {
6097
+ this.deps = deps;
6098
+ this.settleMs = deps.settleMs ?? 3e5;
6099
+ this.lookbackMs = deps.lookbackMs ?? 6912e5;
6100
+ this.tickMs = deps.tickMs ?? 6e4;
6101
+ }
6102
+ /**
6103
+ * Boot: one tick (the back-fill) and then the interval.
6104
+ *
6105
+ * The back-fill needs no special path — it IS a tick. A window that closed
6106
+ * while the process was down is simply a window whose pair has no stamp.
6107
+ */
6108
+ async start() {
6109
+ await this.tick();
6110
+ this.timer = setInterval(() => {
6111
+ this.tick();
6112
+ }, this.tickMs);
6113
+ }
6114
+ stop() {
6115
+ if (this.timer !== null) {
6116
+ clearInterval(this.timer);
6117
+ this.timer = null;
6118
+ }
6119
+ }
6120
+ /** One pass over every enabled rule × camera. Single-flight. */
6121
+ async tick() {
6122
+ if (this.tickInFlight) return {
6123
+ produced: 0,
6124
+ waiting: 0,
6125
+ alreadyDone: 0,
6126
+ beforeFloor: 0,
6127
+ failed: 0,
6128
+ idle: 0
6129
+ };
6130
+ this.tickInFlight = true;
6131
+ const report = {
6132
+ produced: 0,
6133
+ waiting: 0,
6134
+ alreadyDone: 0,
6135
+ beforeFloor: 0,
6136
+ failed: 0,
6137
+ idle: 0
6138
+ };
6139
+ try {
6140
+ const now = this.deps.now();
6141
+ for (const rule of this.deps.store.listEnabled()) {
6142
+ const window = findLastClosedWindow({
6143
+ schedule: rule.schedule,
6144
+ nowMs: now,
6145
+ lookbackMs: this.lookbackMs
6146
+ });
6147
+ if (window === null) {
6148
+ report.idle += rule.deviceIds.length;
6149
+ continue;
6150
+ }
6151
+ if (now < window.endMs + this.settleMs) {
6152
+ report.waiting += rule.deviceIds.length;
6153
+ continue;
6154
+ }
6155
+ for (const deviceId of rule.deviceIds) {
6156
+ if (readTimelapseGeneratedAt(rule, deviceId) >= window.endMs) {
6157
+ report.alreadyDone += 1;
6158
+ continue;
6159
+ }
6160
+ if (timelapseEligibilityFloor(rule, deviceId) >= window.endMs) {
6161
+ report.beforeFloor += 1;
6162
+ this.deps.logger.debug("timelapse window predates the rule — not attempted", {
6163
+ tags: { deviceId },
6164
+ meta: {
6165
+ ruleId: rule.id,
6166
+ windowStart: window.startMs,
6167
+ windowEnd: window.endMs,
6168
+ createdAt: rule.createdAt
6169
+ }
6170
+ });
6171
+ continue;
6172
+ }
6173
+ if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
6174
+ else report.failed += 1;
6175
+ }
6176
+ }
6177
+ } finally {
6178
+ this.tickInFlight = false;
6179
+ }
6180
+ return report;
6181
+ }
6182
+ /**
6183
+ * The operator's Test button: produce the last closed window for one camera
6184
+ * of one rule, ignoring the settle delay and the guard.
6185
+ *
6186
+ * It DELIVERS — same as `nc.injectTestEvent`, and for the same reason: a
6187
+ * test that stops short of the notifier does not test the thing that breaks.
6188
+ * It deliberately does NOT stamp: pressing Test at noon must not cancel
6189
+ * tonight's scheduled run.
6190
+ *
6191
+ * It also ignores the eligibility floor. The floor exists to stop an
6192
+ * UNATTENDED loop mining the past; an operator who presses Test on a rule
6193
+ * created an hour ago is asking exactly that question, and gets the honest
6194
+ * answer (`no footage …`) once, not every minute.
6195
+ */
6196
+ async runNow(input) {
6197
+ const rule = this.deps.store.get(input.ruleId);
6198
+ if (rule === null) throw new Error(`timelapse rule not found: ${input.ruleId}`);
6199
+ if (!rule.deviceIds.includes(input.deviceId)) throw new Error(`rule ${rule.name} does not cover camera ${input.deviceId}`);
6200
+ const window = findLastClosedWindow({
6201
+ schedule: rule.schedule,
6202
+ nowMs: this.deps.now(),
6203
+ lookbackMs: this.lookbackMs
6204
+ });
6205
+ if (window === null) return {
6206
+ produced: 0,
6207
+ window: null,
6208
+ error: "no schedule window has closed recently"
6209
+ };
6210
+ return this.produce(rule, input.deviceId, window, { stamp: false });
6211
+ }
6212
+ /**
6213
+ * Render → enqueue → (optionally) stamp. Never throws.
6214
+ *
6215
+ * The stamp is LAST and conditional on both steps: a render that failed, or
6216
+ * a queue that refused the rows, must leave the pair eligible. A window does
6217
+ * not come back.
6218
+ */
6219
+ async produce(rule, deviceId, window, opts) {
6220
+ const tags = { deviceId };
6221
+ try {
6222
+ const outcome = await this.deps.render({
6223
+ ruleId: rule.id,
6224
+ ruleName: rule.name,
6225
+ deviceId,
6226
+ window,
6227
+ cadenceSec: rule.cadenceSec,
6228
+ framerate: rule.framerate
6229
+ });
6230
+ const deviceName = await this.resolveDeviceName(deviceId);
6231
+ const rows = buildTimelapseOutboxInputs({
6232
+ ruleId: rule.id,
6233
+ ruleName: rule.name,
6234
+ deviceId,
6235
+ ...deviceName !== null ? { deviceName } : {},
6236
+ window,
6237
+ targets: rule.targets,
6238
+ ...this.deps.disabledTargetIds !== void 0 ? { disabledTargetIds: this.deps.disabledTargetIds(rule.id) } : {},
6239
+ priority: rule.priority,
6240
+ coverage: outcome.coverage,
6241
+ ...outcome.artifact !== null ? { artifact: outcome.artifact } : {},
6242
+ ...outcome.downloadUrl !== null ? { clickUrl: outcome.downloadUrl } : {},
6243
+ exportId: outcome.exportId,
6244
+ denseRanges: outcome.plan.ranges.length,
6245
+ ...outcome.plan.capped ? { denseCapped: true } : {},
6246
+ generatedAt: this.deps.now(),
6247
+ ...rule.template !== void 0 ? { template: rule.template } : {}
6248
+ });
6249
+ const enqueued = await this.deps.enqueue(rows);
6250
+ if (opts.stamp) await this.deps.store.markGenerated(rule.id, deviceId, this.deps.now());
6251
+ this.deps.logger.info("timelapse produced", {
6252
+ tags,
6253
+ meta: {
6254
+ ruleId: rule.id,
6255
+ exportId: outcome.exportId,
6256
+ windowStart: window.startMs,
6257
+ windowEnd: window.endMs,
6258
+ coveragePct: outcome.coverage.percent,
6259
+ denseRanges: outcome.plan.ranges.length,
6260
+ enqueued,
6261
+ stamped: opts.stamp,
6262
+ ...outcome.artifact === null ? { artifact: "none" } : {}
6263
+ }
6264
+ });
6265
+ return {
6266
+ produced: 1,
6267
+ window,
6268
+ exportId: outcome.exportId,
6269
+ coveragePercent: outcome.coverage.percent,
6270
+ enqueued
6271
+ };
6272
+ } catch (err) {
6273
+ this.deps.logger.warn("timelapse not produced — the window stays eligible", {
6274
+ tags,
6275
+ meta: {
6276
+ ruleId: rule.id,
6277
+ windowStart: window.startMs,
6278
+ windowEnd: window.endMs,
6279
+ error: String(err)
6280
+ }
6281
+ });
6282
+ return {
6283
+ produced: 0,
6284
+ window,
6285
+ error: err instanceof Error ? err.message : String(err)
6286
+ };
6287
+ }
6288
+ }
6289
+ async resolveDeviceName(deviceId) {
6290
+ const get = this.deps.getDeviceName;
6291
+ if (get === void 0) return null;
6292
+ return get(deviceId).catch(() => null);
6293
+ }
6294
+ };
6295
+ //#endregion
5484
6296
  //#region src/notification-center/index.ts
5485
6297
  /**
5486
6298
  * NotificationCenter — the P1 core of the Notification Center (spec
@@ -5610,16 +6422,21 @@ function packagePhaseOf(ev) {
5610
6422
  * never disagree. `ruleName` + `subject` come from the intent snapshot
5611
6423
  * frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
5612
6424
  */
6425
+ function historyRecordKind(kind) {
6426
+ if (kind === "audio-event") return "object-event";
6427
+ if (kind === "timelapse") return "system-event";
6428
+ return kind;
6429
+ }
5613
6430
  function outboxEntryToHistory(entry) {
5614
6431
  const s = entry.payload.subject;
5615
6432
  return {
5616
6433
  id: entry.id,
5617
6434
  ruleId: entry.ruleId,
5618
6435
  ruleName: entry.payload.ruleName,
5619
- delivery: entry.payload.delivery,
6436
+ delivery: entry.payload.delivery === "timelapse" ? "system-event" : entry.payload.delivery,
5620
6437
  targetId: entry.targetId,
5621
6438
  deviceId: entry.deviceId,
5622
- recordKind: entry.recordKind === "audio-event" ? "object-event" : entry.recordKind,
6439
+ recordKind: historyRecordKind(entry.recordKind),
5623
6440
  recordId: entry.recordId,
5624
6441
  ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
5625
6442
  status: entry.status,
@@ -5662,6 +6479,13 @@ var NotificationCenter = class NotificationCenter {
5662
6479
  /** Grants behind the tap-through buttons. In-process and single-use — read
5663
6480
  * `action-token.ts` before assuming anything about what a tap proves. */
5664
6481
  actionTokens = new NcActionTokenRegistry();
6482
+ /**
6483
+ * The scheduled-timelapse rule set. Held on EVERY node so the bridge actions
6484
+ * can serve CRUD from wherever they are asked (same reasoning as the rule
6485
+ * store); only the hub, with `deps.timelapse` wired, gets a scheduler.
6486
+ */
6487
+ timelapseRules;
6488
+ timelapseScheduler = null;
5665
6489
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
5666
6490
  * the watcher is idle (zero per-frame cost) otherwise. */
5667
6491
  occupancyEnabled = false;
@@ -5851,6 +6675,47 @@ var NotificationCenter = class NotificationCenter {
5851
6675
  deliver: (entry) => this.dispatcher.deliver(entry),
5852
6676
  ...deps.now !== void 0 ? { now: deps.now } : {}
5853
6677
  });
6678
+ this.timelapseRules = new TimelapseStore({
6679
+ store: deps.store,
6680
+ logger: this.logger.child("timelapse"),
6681
+ ...deps.now !== void 0 ? { now: deps.now } : {}
6682
+ });
6683
+ const timelapsePorts = deps.timelapse;
6684
+ if (timelapsePorts !== void 0) {
6685
+ const logger = this.logger.child("timelapse");
6686
+ const renderer = new TimelapseRenderer({
6687
+ ...timelapsePorts,
6688
+ logger,
6689
+ now: this.now,
6690
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
6691
+ });
6692
+ this.timelapseScheduler = new TimelapseScheduler({
6693
+ logger,
6694
+ now: this.now,
6695
+ store: this.timelapseRules,
6696
+ render: (request) => renderer.render(request),
6697
+ enqueue: (rows) => this.outbox.enqueue(rows),
6698
+ getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId)
6699
+ });
6700
+ }
6701
+ }
6702
+ /**
6703
+ * The durable timelapse rule set — exposed for the `nc.*Timelapse*` bridge
6704
+ * actions, exactly as {@link ruleStore} is for the detection rules.
6705
+ */
6706
+ get timelapseStore() {
6707
+ return this.timelapseRules;
6708
+ }
6709
+ /**
6710
+ * Produce the last closed window for one camera of one timelapse rule, now.
6711
+ *
6712
+ * Throws when this node hosts no producer — an honest refusal beats a
6713
+ * `produced: 0` an operator reads as "there was nothing to make".
6714
+ */
6715
+ async runTimelapseNow(input) {
6716
+ const scheduler = this.timelapseScheduler;
6717
+ if (scheduler === null) throw new Error("this node does not host the timelapse producer — produce it on the hub");
6718
+ return scheduler.runNow(input);
5854
6719
  }
5855
6720
  /**
5856
6721
  * The durable rule store — exposed so the hub-only `nc.*` bridge actions
@@ -5887,6 +6752,7 @@ var NotificationCenter = class NotificationCenter {
5887
6752
  await this.rules.load();
5888
6753
  await this.snoozes.load();
5889
6754
  await this.deviceMutes.load();
6755
+ await this.timelapseRules.load();
5890
6756
  this.refreshOccupancyWatch();
5891
6757
  if (!opts.evaluation) return;
5892
6758
  await this.deviceStates.refresh();
@@ -5902,9 +6768,14 @@ var NotificationCenter = class NotificationCenter {
5902
6768
  this.reloadTimer = setInterval(() => {
5903
6769
  this.reloadRules();
5904
6770
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
6771
+ if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
6772
+ this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
6773
+ });
5905
6774
  this.logger.info("notification center started", { meta: {
5906
6775
  rules: this.rules.list().length,
5907
- pendingOutbox: this.outbox.pendingCount()
6776
+ pendingOutbox: this.outbox.pendingCount(),
6777
+ timelapseRules: this.timelapseRules.list().length,
6778
+ timelapseProducer: this.timelapseScheduler !== null
5908
6779
  } });
5909
6780
  }
5910
6781
  async stop() {
@@ -5916,6 +6787,7 @@ var NotificationCenter = class NotificationCenter {
5916
6787
  clearInterval(this.reloadTimer);
5917
6788
  this.reloadTimer = null;
5918
6789
  }
6790
+ this.timelapseScheduler?.stop();
5919
6791
  this.evaluationActive = false;
5920
6792
  }
5921
6793
  /**
@@ -6714,6 +7586,7 @@ var NotificationCenter = class NotificationCenter {
6714
7586
  * recompute the occupancy watched set + prune stale durable rows. */
6715
7587
  async reloadRules() {
6716
7588
  await this.rules.load();
7589
+ await this.timelapseRules.load();
6717
7590
  this.refreshOccupancyWatch();
6718
7591
  if (!this.evaluationActive) return;
6719
7592
  await this.deviceStates.refresh();
@@ -6952,6 +7825,75 @@ var NotificationCenter = class NotificationCenter {
6952
7825
  }
6953
7826
  };
6954
7827
  //#endregion
7828
+ //#region src/notification-center/timelapse/artifact-publish.ts
7829
+ /**
7830
+ * Build the renderer's `publishArtifact` port.
7831
+ *
7832
+ * @returns a function answering the artifact ref, or `null` with a logged
7833
+ * reason. It never rejects.
7834
+ */
7835
+ function buildTimelapseArtifactPublisher(ports) {
7836
+ return async ({ exportId, deviceId }) => {
7837
+ const tags = { deviceId };
7838
+ let read;
7839
+ try {
7840
+ read = await ports.readExportBytes({ exportId });
7841
+ } catch (err) {
7842
+ ports.logger.warn("timelapse artifact not published: export bytes unavailable", {
7843
+ tags,
7844
+ meta: {
7845
+ exportId,
7846
+ error: String(err)
7847
+ }
7848
+ });
7849
+ return null;
7850
+ }
7851
+ if (read.bytes > 52428800) {
7852
+ ports.logger.warn("timelapse artifact not published: video too large to republish", {
7853
+ tags,
7854
+ meta: {
7855
+ exportId,
7856
+ bytes: read.bytes,
7857
+ maxBytes: RECORDING_EXPORT_MAX_READ_BYTES
7858
+ }
7859
+ });
7860
+ return null;
7861
+ }
7862
+ try {
7863
+ const url = await ports.publish(Buffer.from(read.base64, "base64"), read.contentType);
7864
+ if (url === null) {
7865
+ ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
7866
+ tags,
7867
+ meta: { exportId }
7868
+ });
7869
+ return null;
7870
+ }
7871
+ ports.logger.info("timelapse artifact published", {
7872
+ tags,
7873
+ meta: {
7874
+ exportId,
7875
+ bytes: read.bytes,
7876
+ name: read.name
7877
+ }
7878
+ });
7879
+ return {
7880
+ url,
7881
+ mime: read.contentType,
7882
+ name: read.name
7883
+ };
7884
+ } catch (err) {
7885
+ ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
7886
+ tags,
7887
+ meta: {
7888
+ exportId,
7889
+ error: String(err)
7890
+ }
7891
+ });
7892
+ return null;
7893
+ }
7894
+ };
7895
+ }
7896
+ //#endregion
6955
7897
  //#region src/notification-center/nc-actions.ts
6956
7898
  /**
6957
7899
  * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
@@ -6979,6 +7921,8 @@ var NotificationCenter = class NotificationCenter {
6979
7921
  */
6980
7922
  /** A rule as served to the viewer — carries the server's `readOnly` verdict. */
6981
7923
  var NcViewerRuleSchema = NcRuleSchema.extend({ readOnly: boolean() });
7924
+ /** Same verdict, for a timelapse rule. */
7925
+ var NcViewerTimelapseRuleSchema = TimelapseRuleSchema.extend({ readOnly: boolean() });
6982
7926
  /**
6983
7927
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
6984
7928
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
@@ -7046,6 +7990,54 @@ var ncActions = defineCustomActions({
7046
7990
  kind: "mutation",
7047
7991
  auth: "admin",
7048
7992
  caller: "required"
7993
+ }),
7994
+ "nc.listTimelapseRules": customAction(object({}), object({ rules: array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
7995
+ /**
7996
+ * ONE action for create AND update, unlike the detection-rule pair.
7997
+ *
7998
+ * A timelapse rule is edited as a whole in a single form (schedule +
7999
+ * cameras + targets), and the guards a create must pass — every camera
8000
+ * actually records, every target is owned — are the SAME ones a patch that
8001
+ * adds a camera must pass. Two entry points would be two places to forget
8002
+ * the recording check, and forgetting it is invisible until 06:05.
8003
+ *
8004
+ * `ruleId` absent ⇒ create from `rule`; `ruleId` present ⇒ patch.
8005
+ */
8006
+ "nc.upsertTimelapseRule": customAction(object({
8007
+ ruleId: string().optional(),
8008
+ rule: TimelapseRuleInputSchema.optional(),
8009
+ patch: TimelapseRulePatchSchema.optional()
8010
+ }), object({ rule: TimelapseRuleSchema }), {
8011
+ kind: "mutation",
8012
+ caller: "required"
8013
+ }),
8014
+ "nc.deleteTimelapseRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
8015
+ kind: "mutation",
8016
+ caller: "required"
8017
+ }),
8018
+ /**
8019
+ * Produce the LAST CLOSED window for one camera of one rule, right now.
8020
+ *
8021
+ * `auth: 'admin'` for the same reason as `nc.injectTestEvent`: it DELIVERS,
8022
+ * to every target the rule addresses, on other people's phones. It does not
8023
+ * consume the schedule guard — pressing Test at noon must not cancel
8024
+ * tonight's run.
8025
+ */
8026
+ "nc.testTimelapseRule": customAction(object({
8027
+ ruleId: string(),
8028
+ deviceId: number()
8029
+ }), object({
8030
+ produced: number().int(),
8031
+ exportId: string().optional(),
8032
+ coveragePercent: number().optional(),
8033
+ enqueued: number().int().optional(),
8034
+ windowStartMs: number().optional(),
8035
+ windowEndMs: number().optional(),
8036
+ error: string().optional()
8037
+ }), {
8038
+ kind: "mutation",
8039
+ auth: "admin",
8040
+ caller: "required"
7049
8041
  })
7050
8042
  });
7051
8043
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -7084,6 +8076,44 @@ function makeNcActionHandlers(deps) {
7084
8076
  if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7085
8077
  return rule;
7086
8078
  };
8079
+ /** The producer half, or an honest refusal. */
8080
+ const requireTimelapse = () => {
8081
+ const tl = deps.timelapse;
8082
+ if (tl === void 0) throw new Error("this node does not host the timelapse producer — use the hub");
8083
+ return tl;
8084
+ };
8085
+ /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
8086
+ const assertTimelapseEditable = (ruleId, caller, tl) => {
8087
+ const rule = tl.store.get(ruleId);
8088
+ if (rule === null) throw new Error(`forbidden: timelapse rule not found: ${ruleId}`);
8089
+ if (caller.isAdmin) return rule;
8090
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: timelapse rule not owned: ${ruleId}`);
8091
+ return rule;
8092
+ };
8093
+ /**
8094
+ * THE RECORDING GUARD.
8095
+ *
8096
+ * A post-hoc timelapse renders RECORDED footage — a camera that is not
8097
+ * recording can never produce one, no matter how the rule is written. Save
8098
+ * time is the only moment an operator is present to be told: at 06:05 the
8099
+ * next morning the window is closed, the footage was never written, and the
8100
+ * only signal available is a log line nobody is reading.
8101
+ *
8102
+ * The authority is `RecordingConfig` through `deriveRecordingMode` — the
8103
+ * same pair camera-status reads. Never a second opinion, and never a guess:
8104
+ * `enabled: true` with no bands records NOTHING, which is exactly the case a
8105
+ * naive `config.enabled` check would wave through.
8106
+ *
8107
+ * FAIL-CLOSED on an unreadable config: a read that failed is not permission
8108
+ * to save.
8109
+ */
8110
+ const assertCamerasRecord = async (deviceIds, tl) => {
8111
+ for (const deviceId of deviceIds) {
8112
+ const config = await tl.getRecordingConfig(deviceId).catch(() => null);
8113
+ if (config === null) throw new Error(`camera ${deviceId}: recording configuration could not be read — cannot save a timelapse rule for it`);
8114
+ if (deriveRecordingMode(config) === "off") throw new Error(`camera ${deviceId}: recording is not enabled — a timelapse renders recorded footage, so enable recording for this camera first`);
8115
+ }
8116
+ };
7087
8117
  const assertTargetsOwned = async (targetIds, caller) => {
7088
8118
  if (caller.isAdmin) return;
7089
8119
  const owned = new Set(await deps.listCallerTargetIds(caller.userId));
@@ -7176,6 +8206,83 @@ function makeNcActionHandlers(deps) {
7176
8206
  });
7177
8207
  return inject(parsed);
7178
8208
  },
8209
+ "nc.listTimelapseRules": async (_input, caller) => {
8210
+ const c = requireCaller(caller);
8211
+ const tl = requireTimelapse();
8212
+ return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
8213
+ ...r,
8214
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
8215
+ })) };
8216
+ },
8217
+ "nc.upsertTimelapseRule": async (input, caller) => {
8218
+ const c = requireCaller(caller);
8219
+ const tl = requireTimelapse();
8220
+ if (input.ruleId === void 0) {
8221
+ if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
8222
+ const parsed = TimelapseRuleInputSchema.parse(input.rule);
8223
+ await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
8224
+ await assertCamerasRecord(parsed.deviceIds, tl);
8225
+ const rule = await tl.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
8226
+ deps.logger.info("timelapse rule created", { meta: {
8227
+ ruleId: rule.id,
8228
+ owner: c.userId,
8229
+ devices: rule.deviceIds.join(",")
8230
+ } });
8231
+ return { rule };
8232
+ }
8233
+ const existing = assertTimelapseEditable(input.ruleId, c, tl);
8234
+ if (input.patch === void 0) throw new Error("upsertTimelapseRule: patch required");
8235
+ const patch = TimelapseRulePatchSchema.parse(input.patch);
8236
+ if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
8237
+ if (patch.deviceIds !== void 0) {
8238
+ const known = new Set(existing.deviceIds);
8239
+ await assertCamerasRecord(patch.deviceIds.filter((id) => !known.has(id)), tl);
8240
+ }
8241
+ const rule = await tl.store.update(input.ruleId, patch);
8242
+ deps.logger.info("timelapse rule updated", { meta: {
8243
+ ruleId: rule.id,
8244
+ owner: c.userId
8245
+ } });
8246
+ return { rule };
8247
+ },
8248
+ "nc.deleteTimelapseRule": async (input, caller) => {
8249
+ const c = requireCaller(caller);
8250
+ const tl = requireTimelapse();
8251
+ assertTimelapseEditable(input.ruleId, c, tl);
8252
+ await tl.store.delete(input.ruleId);
8253
+ deps.logger.info("timelapse rule deleted", { meta: {
8254
+ ruleId: input.ruleId,
8255
+ owner: c.userId
8256
+ } });
8257
+ return { success: true };
8258
+ },
8259
+ "nc.testTimelapseRule": async (input, caller) => {
8260
+ const c = requireCaller(caller);
8261
+ if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
8262
+ const tl = requireTimelapse();
8263
+ deps.logger.info("timelapse test requested", {
8264
+ tags: { deviceId: input.deviceId },
8265
+ meta: {
8266
+ by: c.userId,
8267
+ ruleId: input.ruleId
8268
+ }
8269
+ });
8270
+ const out = await tl.runNow({
8271
+ ruleId: input.ruleId,
8272
+ deviceId: input.deviceId
8273
+ });
8274
+ return {
8275
+ produced: out.produced,
8276
+ ...out.exportId !== void 0 ? { exportId: out.exportId } : {},
8277
+ ...out.coveragePercent !== void 0 ? { coveragePercent: out.coveragePercent } : {},
8278
+ ...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
8279
+ ...out.window !== null ? {
8280
+ windowStartMs: out.window.startMs,
8281
+ windowEndMs: out.window.endMs
8282
+ } : {},
8283
+ ...out.error !== void 0 ? { error: out.error } : {}
8284
+ };
8285
+ },
7179
8286
  "nc.setRuleTargetEnabled": async (input, caller) => {
7180
8287
  const c = requireCaller(caller);
7181
8288
  assertRuleVisible(input.ruleId, c);
@@ -32047,7 +33154,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
32047
33154
  listCallerTargetIds: async (userId) => {
32048
33155
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
32049
33156
  },
32050
- injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
33157
+ injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
33158
+ timelapse: {
33159
+ store: center.timelapseStore,
33160
+ getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
33161
+ runNow: (input) => center.runTimelapseNow(input)
33162
+ }
32051
33163
  });
32052
33164
  }
32053
33165
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -32660,12 +33772,91 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
32660
33772
  limit,
32661
33773
  projection: "slim"
32662
33774
  })).tracks;
32663
- }
33775
+ },
33776
+ ...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
32664
33777
  });
32665
33778
  this.wireAlarmPanel(this.notificationCenter);
32666
33779
  await this.serveNcActionPlane(this.notificationCenter);
32667
33780
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
32668
33781
  }
33782
+ /**
33783
+ * The scheduled-timelapse producer's ports.
33784
+ *
33785
+ * Every one of them is something that already exists: `recording` for the
33786
+ * footage index, the LOCAL track store for the history (no self-cap call, and
33787
+ * `projection: 'slim'` because the planner reads two timestamps per track and
33788
+ * nothing else), `recordingExport` for the render.
33789
+ *
33790
+ * TWO links, two audiences, and they are not interchangeable:
33791
+ *
33792
+ * - `publishArtifact` produces the one a notifier BACKEND fetches. It reads
33793
+ * the finished mp4 once through `recordingExport.readExportBytes` and
33794
+ * republishes it on the NC artifact plane — the same signed, expiring,
33795
+ * public surface a detection rule's still and clip already ride. Bounded
33796
+ * at `RECORDING_EXPORT_MAX_READ_BYTES`; above it there is no attachment
33797
+ * and a log line saying so.
33798
+ * - `resolveDownloadUrl` produces the one the RECIPIENT taps. The export's
33799
+ * own download route is served `access: 'authenticated'`, so it is a
33800
+ * perfectly good `clickUrl` and a useless attachment URL — a backend
33801
+ * fetching it holds no session and gets a 401.
33802
+ */
33803
+ buildTimelapsePorts(api, stores) {
33804
+ return {
33805
+ getAvailability: async ({ deviceId, fromMs, toMs }) => {
33806
+ return (await api.recording.getAvailability.query({
33807
+ deviceId,
33808
+ fromMs,
33809
+ toMs
33810
+ })).ranges;
33811
+ },
33812
+ listTracks: async ({ deviceId, since, until }) => {
33813
+ return (await stores.trackStore.queryHistorical({
33814
+ deviceId,
33815
+ since,
33816
+ until,
33817
+ projection: "slim"
33818
+ })).map((t) => ({
33819
+ firstSeen: t.firstSeen,
33820
+ lastSeen: t.lastSeen
33821
+ }));
33822
+ },
33823
+ createExport: (input) => api.recordingExport.createExport.mutate({
33824
+ deviceId: input.deviceId,
33825
+ profile: input.profile,
33826
+ fromMs: input.fromMs,
33827
+ toMs: input.toMs,
33828
+ options: {
33829
+ includeAudio: input.options.includeAudio,
33830
+ maxLifeMs: input.options.maxLifeMs,
33831
+ deleteAfterDownload: input.options.deleteAfterDownload,
33832
+ title: input.options.title,
33833
+ timelapse: {
33834
+ everyMs: input.options.timelapse.everyMs,
33835
+ outputFps: input.options.timelapse.outputFps,
33836
+ ...input.options.timelapse.dense !== void 0 ? { dense: {
33837
+ everyMs: input.options.timelapse.dense.everyMs,
33838
+ ranges: input.options.timelapse.dense.ranges.map((r) => ({
33839
+ fromSec: r.fromSec,
33840
+ toSec: r.toSec
33841
+ }))
33842
+ } } : {}
33843
+ }
33844
+ }
33845
+ }),
33846
+ getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
33847
+ publishArtifact: buildTimelapseArtifactPublisher({
33848
+ logger: this.ctx.logger.child("Timelapse"),
33849
+ readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
33850
+ publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
33851
+ }),
33852
+ resolveDownloadUrl: async ({ exportId }) => {
33853
+ const plane = this.ncArtifactPlane;
33854
+ if (plane === null) return null;
33855
+ const download = await api.recordingExport.getDownloadUrl.query({ exportId });
33856
+ return plane.resolveExternalUrl(download.url);
33857
+ }
33858
+ };
33859
+ }
32669
33860
  /** Event-media data-plane: serve thumbnail JPEGs at
32670
33861
  * `/addon/<addonId>/event-media/<eventId>` through the hub's port.
32671
33862
  * Mirror the recorder playback pattern: try/warn, store handle for dispose.