@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.
@@ -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-BQ-M7jn9.js");
5
+ const require_dist = require("../dist-DKsqvmSl.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");
@@ -1119,6 +1119,25 @@ var NcArtifactPlane = class {
1119
1119
  return null;
1120
1120
  }
1121
1121
  }
1122
+ /**
1123
+ * Absolutize a path against the SAME base URL an artifact link would use.
1124
+ *
1125
+ * For links this plane does not host but a RECIPIENT still has to open — a
1126
+ * recorder export download, served `access: 'authenticated'`. It is not
1127
+ * signed and it is not public: it is a tap-through the person opens where
1128
+ * they already hold a session, which is why it goes on `clickUrl` and never
1129
+ * on an attachment.
1130
+ *
1131
+ * Reuses `resolveBaseUrl` rather than re-deriving one, because "which
1132
+ * address can the recipient actually reach" is a question this repo has
1133
+ * already answered wrongly once (the first artifact link went out on the LAN
1134
+ * address while a tunnel was connected).
1135
+ */
1136
+ async resolveExternalUrl(pathname) {
1137
+ const baseUrl = await this.resolveBaseUrl().catch(() => null);
1138
+ if (baseUrl === null) return null;
1139
+ return `${baseUrl.replace(/\/+$/, "")}/${pathname.replace(/^\/+/, "")}`;
1140
+ }
1122
1141
  /** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
1123
1142
  handler = async (req, res) => {
1124
1143
  const url = new URL(req.url ?? "/", "http://placeholder");
@@ -2733,7 +2752,7 @@ var NcDispatcher = class {
2733
2752
  const vars = buildTemplateVars(entry, deviceName, zoneLabels);
2734
2753
  const title = renderTemplate(entry.payload.template?.title, vars) ?? systemEvent?.title ?? entry.payload.ruleName;
2735
2754
  const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
2736
- const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
2755
+ const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
2737
2756
  const params = pickParams(entry.payload.params);
2738
2757
  const actions = await this.mintActions(entry, target);
2739
2758
  return {
@@ -3001,6 +3020,33 @@ function occupancyOpWord(occupied) {
3001
3020
  return occupied ? "occupied" : "free";
3002
3021
  }
3003
3022
  /** `{{var}}` interpolation; missing vars render empty. Null template → null. */
3023
+ /**
3024
+ * The attachment a row was CREATED with, if any.
3025
+ *
3026
+ * Url-only by construction: the bytes were never in this process (a timelapse
3027
+ * is rendered by the recorder and republished on the artifact plane), and a
3028
+ * durable outbox row must never carry an mp4. The degrade engine turns this
3029
+ * into an upload for a bytes-only backend and a plain link for a url-mode one
3030
+ * — which is the whole reason a url-mode target now receives the video at all.
3031
+ */
3032
+ function frozenArtifactAttachments(entry) {
3033
+ const artifact = entry.payload.artifact;
3034
+ if (artifact === void 0) return [];
3035
+ return [{
3036
+ mediaType: artifact.mediaType,
3037
+ url: artifact.url,
3038
+ mime: artifact.mime,
3039
+ name: artifact.name
3040
+ }];
3041
+ }
3042
+ /**
3043
+ * `{{var}}` substitution over a frozen variable map. An unknown name renders
3044
+ * EMPTY rather than leaving the braces on screen.
3045
+ *
3046
+ * Exported so the timelapse producer renders its title/body with the SAME
3047
+ * vocabulary and the same missing-var rule. A second `{{}}` renderer in this
3048
+ * package would be free to disagree about exactly that.
3049
+ */
3004
3050
  function renderTemplate(template, vars) {
3005
3051
  if (template === void 0 || template.trim().length === 0) return null;
3006
3052
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
@@ -5300,8 +5346,20 @@ function applyTemplatePatch(merged, template) {
5300
5346
  const { template: _cleared, ...withoutTemplate } = merged;
5301
5347
  return withoutTemplate;
5302
5348
  }
5349
+ /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
5350
+ function isRecord$1(value) {
5351
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5352
+ }
5303
5353
  var TimelapseStore = class {
5304
5354
  byId = /* @__PURE__ */ new Map();
5355
+ /**
5356
+ * Birth stamps synthesised for LEGACY rows (see {@link withCreatedAt}), kept
5357
+ * so a repeated `load()` re-uses the FIRST one. Without it the synthesised
5358
+ * `createdAt` would advance to "now" on every refresh, and the scheduler's
5359
+ * eligibility floor — which is `max(stamp, createdAt)` — would creep past
5360
+ * every window such a rule was ever offered, silently producing nothing.
5361
+ */
5362
+ synthesisedCreatedAt = /* @__PURE__ */ new Map();
5305
5363
  store;
5306
5364
  logger;
5307
5365
  now;
@@ -5333,8 +5391,11 @@ var TimelapseStore = class {
5333
5391
  });
5334
5392
  this.byId.clear();
5335
5393
  let skipped = 0;
5394
+ let synthesised = 0;
5336
5395
  for (const row of rows) {
5337
- const parsed = require_dist.TimelapseRuleSchema.safeParse(row.data["rule"]);
5396
+ const raw = this.withCreatedAt(row.id, row.data["rule"]);
5397
+ if (raw !== row.data["rule"]) synthesised += 1;
5398
+ const parsed = require_dist.TimelapseRuleSchema.safeParse(raw);
5338
5399
  if (!parsed.success) {
5339
5400
  skipped += 1;
5340
5401
  continue;
@@ -5343,12 +5404,40 @@ var TimelapseStore = class {
5343
5404
  }
5344
5405
  this.logger.debug("timelapse rules loaded", { meta: {
5345
5406
  rules: this.byId.size,
5346
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
5407
+ ...skipped > 0 ? { skippedInvalid: skipped } : {},
5408
+ ...synthesised > 0 ? { synthesisedCreatedAt: synthesised } : {}
5347
5409
  } });
5348
5410
  } catch (err) {
5349
5411
  this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
5350
5412
  }
5351
5413
  }
5414
+ /**
5415
+ * Give a persisted blob a `createdAt` if it has none, WITHOUT persisting
5416
+ * anything: the rule reads as born at this load.
5417
+ *
5418
+ * `createdAt` has always been required by `TimelapseRuleSchema`, so a row
5419
+ * without one is a pre-schema artefact — and before this it was dropped
5420
+ * outright at load (a silently vanished rule). Reading it as "born now" is
5421
+ * the conservative choice in BOTH directions that matter: the rule survives,
5422
+ * and the scheduler's eligibility floor stops it back-filling a window that
5423
+ * closed years before anyone looked at it. The cost is one skipped window if
5424
+ * a legacy rule is loaded between a close and its settle — a bounded miss,
5425
+ * against an unbounded retry of a window no footage can satisfy.
5426
+ *
5427
+ * The synthesised value is remembered per rule id so a later refresh does not
5428
+ * move the floor, and the first `update`/`markGenerated` writes it through —
5429
+ * after which the row is no longer legacy.
5430
+ */
5431
+ withCreatedAt(rowId, raw) {
5432
+ if (!isRecord$1(raw)) return raw;
5433
+ if (typeof raw["createdAt"] === "number") return raw;
5434
+ const stamped = this.synthesisedCreatedAt.get(rowId) ?? this.now();
5435
+ this.synthesisedCreatedAt.set(rowId, stamped);
5436
+ return {
5437
+ ...raw,
5438
+ createdAt: stamped
5439
+ };
5440
+ }
5352
5441
  /** Every rule, newest-first (admin path). */
5353
5442
  list() {
5354
5443
  return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
@@ -5430,6 +5519,7 @@ var TimelapseStore = class {
5430
5519
  id: existing.id,
5431
5520
  ownerUserId: existing.ownerUserId,
5432
5521
  lastGeneratedAt: existing.lastGeneratedAt,
5522
+ generatedByDevice: existing.generatedByDevice,
5433
5523
  createdBy: existing.createdBy,
5434
5524
  createdAt: existing.createdAt,
5435
5525
  updatedAt: this.now()
@@ -5440,16 +5530,39 @@ var TimelapseStore = class {
5440
5530
  return this.update(ruleId, { enabled });
5441
5531
  }
5442
5532
  /**
5443
- * Record a successful generation. `at` is the generation epoch-ms — the
5444
- * durable state behind the 1-hour re-generation guard. Does NOT bump
5445
- * `updatedAt` (generation is not an edit of the rule definition).
5533
+ * Record a successful generation for ONE camera of the rule. `at` is the
5534
+ * generation epoch-ms — the durable state behind the re-generation guard.
5535
+ * Does NOT bump `updatedAt` (a generation is not an edit of the rule).
5536
+ *
5537
+ * PER-DEVICE, and that is the whole point: a rule covering three cameras
5538
+ * renders three videos, and stamping the rule when the first one succeeds
5539
+ * tells the other two they are already done. Their night is then gone for
5540
+ * good — a closed window does not come back. Call this ONLY after the export
5541
+ * reached `ready` and the notification was enqueued; a failed render must
5542
+ * leave the pair unstamped so the next tick can retry it.
5543
+ *
5544
+ * The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
5545
+ * a UI shows) and never regresses when an older device generation lands.
5546
+ *
5547
+ * A rule that still carries only the LEGACY rule-wide stamp has its map
5548
+ * SEEDED from it for every camera the rule covers, before the new stamp is
5549
+ * applied — writing `{ [deviceId]: at }` alone would drop the other cameras
5550
+ * to "never generated" and re-render them once.
5446
5551
  */
5447
- async markGenerated(ruleId, at) {
5552
+ async markGenerated(ruleId, deviceId, at) {
5448
5553
  const existing = this.byId.get(ruleId);
5449
5554
  if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
5555
+ const seeded = {};
5556
+ if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
5557
+ const generatedByDevice = {
5558
+ ...seeded,
5559
+ ...existing.generatedByDevice,
5560
+ [String(deviceId)]: at
5561
+ };
5450
5562
  return this.write({
5451
5563
  ...existing,
5452
- lastGeneratedAt: at
5564
+ generatedByDevice,
5565
+ lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
5453
5566
  });
5454
5567
  }
5455
5568
  /** Idempotent delete — unknown ids are a no-op. */
@@ -5494,6 +5607,705 @@ var TimelapseStore = class {
5494
5607
  }
5495
5608
  };
5496
5609
  //#endregion
5610
+ //#region src/notification-center/timelapse/dense-ranges.ts
5611
+ /**
5612
+ * The POST-HOC timelapse's render plan: which seconds of the closed window
5613
+ * deserve the fast cadence.
5614
+ *
5615
+ * Pure — no clock, no I/O, no store. The scheduler hands over the tracks the
5616
+ * window actually contained (`pipelineAnalytics.listTracks`, which carry
5617
+ * `firstSeen`/`lastSeen`) and gets back ranges expressed as SECONDS FROM THE
5618
+ * WINDOW START, which is exactly the coordinate system
5619
+ * `ExportTimelapseSchema.dense.ranges` speaks.
5620
+ *
5621
+ * Why post-hoc and not a live sampler: the footage already exists, the tracks
5622
+ * already exist, and the recorder already knows how to render a range. A live
5623
+ * sampler would hold a frame ring open for eight hours per camera to
5624
+ * reconstruct information the database already has.
5625
+ *
5626
+ * The coalescing rule is `2 × base cadence`: two tracks closer together than
5627
+ * two base frames would render as one continuous busy stretch anyway, so
5628
+ * splitting them buys nothing and costs a range out of the budget.
5629
+ */
5630
+ /** Re-exported so a caller plans and validates against the SAME ceiling the
5631
+ * cap enforces — two constants would be free to drift, and the drift would
5632
+ * surface as a rejected export at render time, hours after the window. */
5633
+ var DENSE_MAX_RANGES = 200;
5634
+ /** Clamp a track to the window and pad it, dropping what falls outside. */
5635
+ function clampTrack(track, windowStartMs, windowEndMs, padMs) {
5636
+ const rawFrom = Math.min(track.firstSeen, track.lastSeen);
5637
+ const rawTo = Math.max(track.firstSeen, track.lastSeen);
5638
+ if (rawTo < windowStartMs || rawFrom > windowEndMs) return null;
5639
+ const fromMs = Math.max(windowStartMs, rawFrom - padMs);
5640
+ const toMs = Math.min(windowEndMs, rawTo + padMs);
5641
+ return toMs > fromMs ? {
5642
+ fromMs,
5643
+ toMs
5644
+ } : null;
5645
+ }
5646
+ /**
5647
+ * Merge the window's tracks into dense ranges.
5648
+ *
5649
+ * @returns ranges in seconds from `windowStartMs`, already coalesced, sorted
5650
+ * and clamped — or an empty capped plan.
5651
+ */
5652
+ function planDenseRanges(input) {
5653
+ const maxRanges = Math.max(1, input.maxRanges ?? DENSE_MAX_RANGES);
5654
+ const padMs = Math.max(0, input.padSec) * 1e3;
5655
+ const gapMs = Math.max(1, input.baseCadenceSec) * 2e3;
5656
+ const clamped = [];
5657
+ for (const track of input.tracks) {
5658
+ const r = clampTrack(track, input.windowStartMs, input.windowEndMs, padMs);
5659
+ if (r !== null) clamped.push(r);
5660
+ }
5661
+ clamped.sort((a, b) => a.fromMs - b.fromMs);
5662
+ const merged = [];
5663
+ for (const r of clamped) {
5664
+ const last = merged[merged.length - 1];
5665
+ if (last !== void 0 && r.fromMs - last.toMs < gapMs) {
5666
+ merged[merged.length - 1] = {
5667
+ fromMs: last.fromMs,
5668
+ toMs: Math.max(last.toMs, r.toMs)
5669
+ };
5670
+ continue;
5671
+ }
5672
+ merged.push(r);
5673
+ }
5674
+ if (merged.length > maxRanges) return {
5675
+ ranges: [],
5676
+ capped: true,
5677
+ mergedCount: merged.length,
5678
+ denseSec: 0
5679
+ };
5680
+ const ranges = merged.map((r) => ({
5681
+ fromSec: (r.fromMs - input.windowStartMs) / 1e3,
5682
+ toSec: (r.toMs - input.windowStartMs) / 1e3
5683
+ }));
5684
+ const denseSec = ranges.reduce((sum, r) => sum + (r.toSec - r.fromSec), 0);
5685
+ return {
5686
+ ranges,
5687
+ capped: false,
5688
+ mergedCount: merged.length,
5689
+ denseSec
5690
+ };
5691
+ }
5692
+ //#endregion
5693
+ //#region src/notification-center/timelapse/footage-coverage.ts
5694
+ /**
5695
+ * Slack at the window edges before a window counts as incomplete.
5696
+ *
5697
+ * Recorded footage is segment-granular, so the last segment almost never lands
5698
+ * exactly on the boundary. Without slack every complete night reports "99% of
5699
+ * the window" — noise the operator learns to skip, which is how the genuinely
5700
+ * short video gets skipped too.
5701
+ */
5702
+ var COVERAGE_COMPLETE_SLACK_MS = 3e4;
5703
+ /** Merge + clamp + sum. */
5704
+ function footageCoverage(ranges, windowStartMs, windowEndMs) {
5705
+ const windowMs = Math.max(0, windowEndMs - windowStartMs);
5706
+ if (windowMs === 0) return {
5707
+ fraction: 0,
5708
+ percent: 0,
5709
+ coveredMs: 0,
5710
+ windowMs: 0,
5711
+ complete: false
5712
+ };
5713
+ const clamped = ranges.map((r) => ({
5714
+ startMs: Math.max(windowStartMs, Math.min(r.startMs, r.endMs)),
5715
+ endMs: Math.min(windowEndMs, Math.max(r.startMs, r.endMs))
5716
+ })).filter((r) => r.endMs > r.startMs).sort((a, b) => a.startMs - b.startMs);
5717
+ let coveredMs = 0;
5718
+ let curStart = null;
5719
+ let curEnd = 0;
5720
+ for (const r of clamped) {
5721
+ if (curStart === null) {
5722
+ curStart = r.startMs;
5723
+ curEnd = r.endMs;
5724
+ continue;
5725
+ }
5726
+ if (r.startMs <= curEnd) {
5727
+ curEnd = Math.max(curEnd, r.endMs);
5728
+ continue;
5729
+ }
5730
+ coveredMs += curEnd - curStart;
5731
+ curStart = r.startMs;
5732
+ curEnd = r.endMs;
5733
+ }
5734
+ if (curStart !== null) coveredMs += curEnd - curStart;
5735
+ const fraction = coveredMs / windowMs;
5736
+ return {
5737
+ fraction,
5738
+ percent: Math.round(fraction * 100),
5739
+ coveredMs,
5740
+ windowMs,
5741
+ complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
5742
+ };
5743
+ }
5744
+ /** How long a scheduled timelapse's file is kept by the export engine. */
5745
+ var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
5746
+ var TimelapseRenderError = class extends Error {
5747
+ reason;
5748
+ constructor(reason, message) {
5749
+ super(message);
5750
+ this.name = "TimelapseRenderError";
5751
+ this.reason = reason;
5752
+ }
5753
+ };
5754
+ /** The profile with the most footage over the window; ties broken by name so
5755
+ * the same window always renders from the same source. */
5756
+ function pickProfile(ranges, window) {
5757
+ const byProfile = /* @__PURE__ */ new Map();
5758
+ for (const r of ranges) {
5759
+ const list = byProfile.get(r.profile);
5760
+ if (list === void 0) byProfile.set(r.profile, [r]);
5761
+ else list.push(r);
5762
+ }
5763
+ let best = null;
5764
+ for (const [profile, rows] of [...byProfile].sort((a, b) => a[0].localeCompare(b[0]))) {
5765
+ const coverage = footageCoverage(rows, window.startMs, window.endMs);
5766
+ if (best === null || coverage.coveredMs > best.coverage.coveredMs) best = {
5767
+ profile,
5768
+ coverage
5769
+ };
5770
+ }
5771
+ return best;
5772
+ }
5773
+ /** `HH:MM`, for the export title an operator sees in the exports list. */
5774
+ function clockOf$1(atMs) {
5775
+ const d = new Date(atMs);
5776
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
5777
+ }
5778
+ var TimelapseRenderer = class {
5779
+ ports;
5780
+ pollMs;
5781
+ timeoutMs;
5782
+ constructor(ports) {
5783
+ this.ports = ports;
5784
+ this.pollMs = ports.pollMs ?? 5e3;
5785
+ this.timeoutMs = ports.timeoutMs ?? 18e5;
5786
+ }
5787
+ async render(request) {
5788
+ const { deviceId, window } = request;
5789
+ const tags = { deviceId };
5790
+ const chosen = pickProfile(await this.ports.getAvailability({
5791
+ deviceId,
5792
+ fromMs: window.startMs,
5793
+ toMs: window.endMs
5794
+ }), window);
5795
+ if (chosen === null || chosen.coverage.coveredMs === 0) {
5796
+ this.ports.logger.warn("timelapse skipped: no footage covers the window", {
5797
+ tags,
5798
+ meta: {
5799
+ ruleId: request.ruleId,
5800
+ windowStart: window.startMs,
5801
+ windowEnd: window.endMs
5802
+ }
5803
+ });
5804
+ throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
5805
+ }
5806
+ const plan = planDenseRanges({
5807
+ tracks: await this.ports.listTracks({
5808
+ deviceId,
5809
+ since: window.startMs,
5810
+ until: window.endMs
5811
+ }).catch((err) => {
5812
+ this.ports.logger.warn("timelapse dense plan unavailable — rendering uniform", {
5813
+ tags,
5814
+ meta: {
5815
+ ruleId: request.ruleId,
5816
+ error: String(err)
5817
+ }
5818
+ });
5819
+ return [];
5820
+ }),
5821
+ windowStartMs: window.startMs,
5822
+ windowEndMs: window.endMs,
5823
+ padSec: 5,
5824
+ baseCadenceSec: request.cadenceSec
5825
+ });
5826
+ const everyMs = request.cadenceSec * 1e3;
5827
+ const denseEveryMs = Math.max(1e3, Math.round(everyMs / 10));
5828
+ const dense = plan.ranges.length > 0 && denseEveryMs < everyMs ? {
5829
+ everyMs: denseEveryMs,
5830
+ ranges: [...plan.ranges]
5831
+ } : void 0;
5832
+ if (plan.capped) this.ports.logger.info("timelapse dense plan exceeded the range ceiling — uniform cadence", {
5833
+ tags,
5834
+ meta: {
5835
+ ruleId: request.ruleId,
5836
+ mergedRanges: plan.mergedCount
5837
+ }
5838
+ });
5839
+ const queued = await this.ports.createExport({
5840
+ deviceId,
5841
+ profile: chosen.profile,
5842
+ fromMs: window.startMs,
5843
+ toMs: window.endMs,
5844
+ options: {
5845
+ includeAudio: false,
5846
+ maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
5847
+ deleteAfterDownload: false,
5848
+ title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
5849
+ timelapse: {
5850
+ everyMs,
5851
+ outputFps: request.framerate,
5852
+ ...dense !== void 0 ? { dense } : {}
5853
+ }
5854
+ }
5855
+ });
5856
+ this.ports.logger.info("timelapse export queued", {
5857
+ tags,
5858
+ meta: {
5859
+ ruleId: request.ruleId,
5860
+ exportId: queued.id,
5861
+ profile: chosen.profile,
5862
+ everyMs,
5863
+ denseRanges: plan.ranges.length,
5864
+ coveragePct: chosen.coverage.percent
5865
+ }
5866
+ });
5867
+ const finished = await this.awaitReady(queued, tags);
5868
+ const artifact = await this.publish(finished.id, deviceId, tags);
5869
+ const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
5870
+ return {
5871
+ exportId: finished.id,
5872
+ profile: chosen.profile,
5873
+ coverage: chosen.coverage,
5874
+ plan,
5875
+ artifact,
5876
+ downloadUrl: downloadUrl ?? null,
5877
+ fileBytes: finished.fileBytes
5878
+ };
5879
+ }
5880
+ /** Poll to a terminal state, under a bound. */
5881
+ async awaitReady(queued, tags) {
5882
+ if (queued.state === "ready") return queued;
5883
+ const deadline = this.ports.now() + this.timeoutMs;
5884
+ let current = queued;
5885
+ while (this.ports.now() < deadline) {
5886
+ await this.ports.sleep(this.pollMs);
5887
+ current = await this.ports.getExport({ exportId: queued.id });
5888
+ if (current.state === "ready") return current;
5889
+ if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
5890
+ this.ports.logger.warn("timelapse export did not finish", {
5891
+ tags,
5892
+ meta: {
5893
+ exportId: queued.id,
5894
+ state: current.state,
5895
+ error: current.error
5896
+ }
5897
+ });
5898
+ throw new TimelapseRenderError("export-failed", current.error ?? `export ${queued.id} ended ${current.state}`);
5899
+ }
5900
+ }
5901
+ this.ports.logger.warn("timelapse export timed out", {
5902
+ tags,
5903
+ meta: {
5904
+ exportId: queued.id,
5905
+ timeoutMs: this.timeoutMs,
5906
+ lastState: current.state
5907
+ }
5908
+ });
5909
+ throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
5910
+ }
5911
+ /**
5912
+ * Mint the public link, or say why there is none.
5913
+ *
5914
+ * Never throws: a video that exists and cannot be linked is a degraded
5915
+ * notification, not a failed window — and failing here would leave the pair
5916
+ * unstamped and re-render the same eight hours on the next tick.
5917
+ */
5918
+ async publish(exportId, deviceId, tags) {
5919
+ const publish = this.ports.publishArtifact;
5920
+ if (publish === void 0) {
5921
+ this.ports.logger.info("timelapse artifact publishing is not wired — link-less delivery", {
5922
+ tags,
5923
+ meta: { exportId }
5924
+ });
5925
+ return null;
5926
+ }
5927
+ try {
5928
+ const ref = await publish({
5929
+ exportId,
5930
+ deviceId
5931
+ });
5932
+ if (ref === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
5933
+ tags,
5934
+ meta: { exportId }
5935
+ });
5936
+ return ref;
5937
+ } catch (err) {
5938
+ this.ports.logger.warn("timelapse artifact publish threw — link-less delivery", {
5939
+ tags,
5940
+ meta: {
5941
+ exportId,
5942
+ error: String(err)
5943
+ }
5944
+ });
5945
+ return null;
5946
+ }
5947
+ }
5948
+ };
5949
+ //#endregion
5950
+ //#region src/notification-center/timelapse/timelapse-delivery.ts
5951
+ /** `HH:MM` in the host timezone — the two numbers a body actually needs. */
5952
+ function clockOf(atMs) {
5953
+ const d = new Date(atMs);
5954
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
5955
+ }
5956
+ /** Template vocabulary. Deliberately the same NAMES the rule templates use
5957
+ * (`camera`, `rule`, `time`) plus the timelapse-only ones. */
5958
+ function templateVars(input) {
5959
+ return {
5960
+ camera: input.deviceName ?? `camera ${input.deviceId}`,
5961
+ rule: input.ruleName,
5962
+ from: clockOf(input.window.startMs),
5963
+ to: clockOf(input.window.endMs),
5964
+ time: clockOf(input.generatedAt),
5965
+ coverage: String(input.coverage.percent),
5966
+ events: String(input.denseRanges)
5967
+ };
5968
+ }
5969
+ /**
5970
+ * The body an operator reads when he did not author one.
5971
+ *
5972
+ * The COVERAGE sentence is the load-bearing part and only appears when it
5973
+ * carries information: a video assembled from two of the eight hours it claims
5974
+ * to show is visually indistinguishable from a very quiet night, and shipping
5975
+ * it silently is how a three-hour recorder outage looks like nothing happened.
5976
+ * A complete window says nothing — a line every night is a line nobody reads.
5977
+ */
5978
+ function derivedBody(input) {
5979
+ const vars = templateVars(input);
5980
+ const parts = [`${vars.rule} — ${vars.from}→${vars.to}`];
5981
+ if (input.denseCapped === true) parts.push("troppi eventi per il campionamento denso: video a cadenza uniforme");
5982
+ else if (input.denseRanges > 0) parts.push(`${input.denseRanges} intervalli ripresi a cadenza fitta`);
5983
+ if (!input.coverage.complete) parts.push(`ATTENZIONE: registrazione disponibile solo per il ${vars.coverage}% della finestra`);
5984
+ return parts.join(" · ");
5985
+ }
5986
+ /** Per-target params with the derived `clickUrl` filled in where absent. */
5987
+ function paramsFor(target, clickUrl) {
5988
+ if (clickUrl === void 0) return target.params;
5989
+ if (target.params === void 0) return { clickUrl };
5990
+ if (typeof target.params["clickUrl"] === "string") return target.params;
5991
+ return {
5992
+ ...target.params,
5993
+ clickUrl
5994
+ };
5995
+ }
5996
+ /** Stable, retry-safe outbox record id for one (device, closed window). */
5997
+ function timelapseRecordId(deviceId, windowEndMs) {
5998
+ return `timelapse:${deviceId}:${windowEndMs}`;
5999
+ }
6000
+ /**
6001
+ * One outbox row per (still-enabled) target.
6002
+ *
6003
+ * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
6004
+ */
6005
+ function buildTimelapseOutboxInputs(input) {
6006
+ const vars = templateVars(input);
6007
+ const optedOut = new Set(input.disabledTargetIds ?? []);
6008
+ const title = renderTemplate(input.template?.title, vars) ?? `Timelapse — ${vars.camera}`;
6009
+ const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input);
6010
+ const recordId = timelapseRecordId(input.deviceId, input.window.endMs);
6011
+ return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
6012
+ const payload = {
6013
+ ruleName: input.ruleName,
6014
+ delivery: "timelapse",
6015
+ priority: input.priority,
6016
+ template: {
6017
+ title,
6018
+ body
6019
+ },
6020
+ media: "none",
6021
+ ...input.artifact !== void 0 ? { artifact: {
6022
+ mediaType: "video",
6023
+ ...input.artifact
6024
+ } } : {},
6025
+ ...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
6026
+ subject: {
6027
+ deviceId: input.deviceId,
6028
+ className: "timelapse",
6029
+ zones: [],
6030
+ timestamp: input.window.endMs
6031
+ }
6032
+ };
6033
+ return {
6034
+ ruleId: input.ruleId,
6035
+ targetId: target.targetId,
6036
+ deviceId: input.deviceId,
6037
+ recordKind: "timelapse",
6038
+ recordId,
6039
+ payload
6040
+ };
6041
+ });
6042
+ }
6043
+ /**
6044
+ * The most recent window that had already CLOSED at `nowMs`.
6045
+ *
6046
+ * Standing INSIDE a window returns the PREVIOUS one — a scheduler that
6047
+ * answered "the one I am in" would render half a night and burn the guard on
6048
+ * it.
6049
+ *
6050
+ * @returns `null` when no close falls inside `lookbackMs`, when the schedule
6051
+ * never closes (always active), OR when the close was found but its OPEN
6052
+ * predates the lookback floor. The last case is null and not a clamped
6053
+ * window on purpose: reporting the scan floor as the start ships a video
6054
+ * that silently begins hours late.
6055
+ */
6056
+ function findLastClosedWindow(input) {
6057
+ const step = Math.max(1e3, input.stepMs ?? 6e4);
6058
+ const from = Math.floor(input.nowMs / step) * step;
6059
+ const floor = input.nowMs - input.lookbackMs;
6060
+ const active = (t) => require_dist.isScheduleActive(input.schedule, t);
6061
+ let endMs = null;
6062
+ for (let t = from; t - step >= floor; t -= step) if (!active(t) && active(t - step)) {
6063
+ endMs = t;
6064
+ break;
6065
+ }
6066
+ if (endMs === null) return null;
6067
+ for (let t = endMs - step; t - step >= floor; t -= step) if (!active(t - step)) return {
6068
+ startMs: t,
6069
+ endMs
6070
+ };
6071
+ return null;
6072
+ }
6073
+ /**
6074
+ * The earliest window CLOSE a (rule, device) pair may ever be offered.
6075
+ *
6076
+ * `max(this device's last generation, the rule's birth)`:
6077
+ * - the per-device stamp is the re-generation guard — a window already
6078
+ * produced for THIS camera is done (and a sibling camera's success says
6079
+ * nothing about it, hence `readTimelapseGeneratedAt` and not the rule-wide
6080
+ * field);
6081
+ * - `createdAt` is what stops a brand-new rule mining the past. Without it an
6082
+ * unstamped pair is indistinguishable from one the hub was down for, and
6083
+ * the back-fill happily reaches back a full lookback for footage no rule was
6084
+ * ever recording against.
6085
+ *
6086
+ * The comparison is against the window's CLOSE, matching the stamp guard: a
6087
+ * rule created mid-window still gets that window (its footage exists), a rule
6088
+ * created after the close never does.
6089
+ *
6090
+ * RULE-LEVEL, deliberately. `TimelapseRule.deviceIds` is a plain array that a
6091
+ * patch replaces wholesale, so there is no per-device add time to floor
6092
+ * against; a camera added to an old rule may therefore be offered the one
6093
+ * window that closed before it joined. That over-produces at most a single
6094
+ * video, whereas the defect this function exists to stop is unbounded — an
6095
+ * every-minute retry of a window nothing can satisfy. If a per-device add time
6096
+ * is ever persisted, raise the floor to `max(…, addedAt)` here and nowhere
6097
+ * else.
6098
+ */
6099
+ function timelapseEligibilityFloor(rule, deviceId) {
6100
+ return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
6101
+ }
6102
+ var TimelapseScheduler = class {
6103
+ deps;
6104
+ timer = null;
6105
+ tickInFlight = false;
6106
+ settleMs;
6107
+ lookbackMs;
6108
+ tickMs;
6109
+ constructor(deps) {
6110
+ this.deps = deps;
6111
+ this.settleMs = deps.settleMs ?? 3e5;
6112
+ this.lookbackMs = deps.lookbackMs ?? 6912e5;
6113
+ this.tickMs = deps.tickMs ?? 6e4;
6114
+ }
6115
+ /**
6116
+ * Boot: one tick (the back-fill) and then the interval.
6117
+ *
6118
+ * The back-fill needs no special path — it IS a tick. A window that closed
6119
+ * while the process was down is simply a window whose pair has no stamp.
6120
+ */
6121
+ async start() {
6122
+ await this.tick();
6123
+ this.timer = setInterval(() => {
6124
+ this.tick();
6125
+ }, this.tickMs);
6126
+ }
6127
+ stop() {
6128
+ if (this.timer !== null) {
6129
+ clearInterval(this.timer);
6130
+ this.timer = null;
6131
+ }
6132
+ }
6133
+ /** One pass over every enabled rule × camera. Single-flight. */
6134
+ async tick() {
6135
+ if (this.tickInFlight) return {
6136
+ produced: 0,
6137
+ waiting: 0,
6138
+ alreadyDone: 0,
6139
+ beforeFloor: 0,
6140
+ failed: 0,
6141
+ idle: 0
6142
+ };
6143
+ this.tickInFlight = true;
6144
+ const report = {
6145
+ produced: 0,
6146
+ waiting: 0,
6147
+ alreadyDone: 0,
6148
+ beforeFloor: 0,
6149
+ failed: 0,
6150
+ idle: 0
6151
+ };
6152
+ try {
6153
+ const now = this.deps.now();
6154
+ for (const rule of this.deps.store.listEnabled()) {
6155
+ const window = findLastClosedWindow({
6156
+ schedule: rule.schedule,
6157
+ nowMs: now,
6158
+ lookbackMs: this.lookbackMs
6159
+ });
6160
+ if (window === null) {
6161
+ report.idle += rule.deviceIds.length;
6162
+ continue;
6163
+ }
6164
+ if (now < window.endMs + this.settleMs) {
6165
+ report.waiting += rule.deviceIds.length;
6166
+ continue;
6167
+ }
6168
+ for (const deviceId of rule.deviceIds) {
6169
+ if (require_dist.readTimelapseGeneratedAt(rule, deviceId) >= window.endMs) {
6170
+ report.alreadyDone += 1;
6171
+ continue;
6172
+ }
6173
+ if (timelapseEligibilityFloor(rule, deviceId) >= window.endMs) {
6174
+ report.beforeFloor += 1;
6175
+ this.deps.logger.debug("timelapse window predates the rule — not attempted", {
6176
+ tags: { deviceId },
6177
+ meta: {
6178
+ ruleId: rule.id,
6179
+ windowStart: window.startMs,
6180
+ windowEnd: window.endMs,
6181
+ createdAt: rule.createdAt
6182
+ }
6183
+ });
6184
+ continue;
6185
+ }
6186
+ if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
6187
+ else report.failed += 1;
6188
+ }
6189
+ }
6190
+ } finally {
6191
+ this.tickInFlight = false;
6192
+ }
6193
+ return report;
6194
+ }
6195
+ /**
6196
+ * The operator's Test button: produce the last closed window for one camera
6197
+ * of one rule, ignoring the settle delay and the guard.
6198
+ *
6199
+ * It DELIVERS — same as `nc.injectTestEvent`, and for the same reason: a
6200
+ * test that stops short of the notifier does not test the thing that breaks.
6201
+ * It deliberately does NOT stamp: pressing Test at noon must not cancel
6202
+ * tonight's scheduled run.
6203
+ *
6204
+ * It also ignores the eligibility floor. The floor exists to stop an
6205
+ * UNATTENDED loop mining the past; an operator who presses Test on a rule
6206
+ * created an hour ago is asking exactly that question, and gets the honest
6207
+ * answer (`no footage …`) once, not every minute.
6208
+ */
6209
+ async runNow(input) {
6210
+ const rule = this.deps.store.get(input.ruleId);
6211
+ if (rule === null) throw new Error(`timelapse rule not found: ${input.ruleId}`);
6212
+ if (!rule.deviceIds.includes(input.deviceId)) throw new Error(`rule ${rule.name} does not cover camera ${input.deviceId}`);
6213
+ const window = findLastClosedWindow({
6214
+ schedule: rule.schedule,
6215
+ nowMs: this.deps.now(),
6216
+ lookbackMs: this.lookbackMs
6217
+ });
6218
+ if (window === null) return {
6219
+ produced: 0,
6220
+ window: null,
6221
+ error: "no schedule window has closed recently"
6222
+ };
6223
+ return this.produce(rule, input.deviceId, window, { stamp: false });
6224
+ }
6225
+ /**
6226
+ * Render → enqueue → (optionally) stamp. Never throws.
6227
+ *
6228
+ * The stamp is LAST and conditional on both steps: a render that failed, or
6229
+ * a queue that refused the rows, must leave the pair eligible. A window does
6230
+ * not come back.
6231
+ */
6232
+ async produce(rule, deviceId, window, opts) {
6233
+ const tags = { deviceId };
6234
+ try {
6235
+ const outcome = await this.deps.render({
6236
+ ruleId: rule.id,
6237
+ ruleName: rule.name,
6238
+ deviceId,
6239
+ window,
6240
+ cadenceSec: rule.cadenceSec,
6241
+ framerate: rule.framerate
6242
+ });
6243
+ const deviceName = await this.resolveDeviceName(deviceId);
6244
+ const rows = buildTimelapseOutboxInputs({
6245
+ ruleId: rule.id,
6246
+ ruleName: rule.name,
6247
+ deviceId,
6248
+ ...deviceName !== null ? { deviceName } : {},
6249
+ window,
6250
+ targets: rule.targets,
6251
+ ...this.deps.disabledTargetIds !== void 0 ? { disabledTargetIds: this.deps.disabledTargetIds(rule.id) } : {},
6252
+ priority: rule.priority,
6253
+ coverage: outcome.coverage,
6254
+ ...outcome.artifact !== null ? { artifact: outcome.artifact } : {},
6255
+ ...outcome.downloadUrl !== null ? { clickUrl: outcome.downloadUrl } : {},
6256
+ exportId: outcome.exportId,
6257
+ denseRanges: outcome.plan.ranges.length,
6258
+ ...outcome.plan.capped ? { denseCapped: true } : {},
6259
+ generatedAt: this.deps.now(),
6260
+ ...rule.template !== void 0 ? { template: rule.template } : {}
6261
+ });
6262
+ const enqueued = await this.deps.enqueue(rows);
6263
+ if (opts.stamp) await this.deps.store.markGenerated(rule.id, deviceId, this.deps.now());
6264
+ this.deps.logger.info("timelapse produced", {
6265
+ tags,
6266
+ meta: {
6267
+ ruleId: rule.id,
6268
+ exportId: outcome.exportId,
6269
+ windowStart: window.startMs,
6270
+ windowEnd: window.endMs,
6271
+ coveragePct: outcome.coverage.percent,
6272
+ denseRanges: outcome.plan.ranges.length,
6273
+ enqueued,
6274
+ stamped: opts.stamp,
6275
+ ...outcome.artifact === null ? { artifact: "none" } : {}
6276
+ }
6277
+ });
6278
+ return {
6279
+ produced: 1,
6280
+ window,
6281
+ exportId: outcome.exportId,
6282
+ coveragePercent: outcome.coverage.percent,
6283
+ enqueued
6284
+ };
6285
+ } catch (err) {
6286
+ this.deps.logger.warn("timelapse not produced — the window stays eligible", {
6287
+ tags,
6288
+ meta: {
6289
+ ruleId: rule.id,
6290
+ windowStart: window.startMs,
6291
+ windowEnd: window.endMs,
6292
+ error: String(err)
6293
+ }
6294
+ });
6295
+ return {
6296
+ produced: 0,
6297
+ window,
6298
+ error: err instanceof Error ? err.message : String(err)
6299
+ };
6300
+ }
6301
+ }
6302
+ async resolveDeviceName(deviceId) {
6303
+ const get = this.deps.getDeviceName;
6304
+ if (get === void 0) return null;
6305
+ return get(deviceId).catch(() => null);
6306
+ }
6307
+ };
6308
+ //#endregion
5497
6309
  //#region src/notification-center/index.ts
5498
6310
  /**
5499
6311
  * NotificationCenter — the P1 core of the Notification Center (spec
@@ -5623,16 +6435,21 @@ function packagePhaseOf(ev) {
5623
6435
  * never disagree. `ruleName` + `subject` come from the intent snapshot
5624
6436
  * frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
5625
6437
  */
6438
+ function historyRecordKind(kind) {
6439
+ if (kind === "audio-event") return "object-event";
6440
+ if (kind === "timelapse") return "system-event";
6441
+ return kind;
6442
+ }
5626
6443
  function outboxEntryToHistory(entry) {
5627
6444
  const s = entry.payload.subject;
5628
6445
  return {
5629
6446
  id: entry.id,
5630
6447
  ruleId: entry.ruleId,
5631
6448
  ruleName: entry.payload.ruleName,
5632
- delivery: entry.payload.delivery,
6449
+ delivery: entry.payload.delivery === "timelapse" ? "system-event" : entry.payload.delivery,
5633
6450
  targetId: entry.targetId,
5634
6451
  deviceId: entry.deviceId,
5635
- recordKind: entry.recordKind === "audio-event" ? "object-event" : entry.recordKind,
6452
+ recordKind: historyRecordKind(entry.recordKind),
5636
6453
  recordId: entry.recordId,
5637
6454
  ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
5638
6455
  status: entry.status,
@@ -5675,6 +6492,13 @@ var NotificationCenter = class NotificationCenter {
5675
6492
  /** Grants behind the tap-through buttons. In-process and single-use — read
5676
6493
  * `action-token.ts` before assuming anything about what a tap proves. */
5677
6494
  actionTokens = new NcActionTokenRegistry();
6495
+ /**
6496
+ * The scheduled-timelapse rule set. Held on EVERY node so the bridge actions
6497
+ * can serve CRUD from wherever they are asked (same reasoning as the rule
6498
+ * store); only the hub, with `deps.timelapse` wired, gets a scheduler.
6499
+ */
6500
+ timelapseRules;
6501
+ timelapseScheduler = null;
5678
6502
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
5679
6503
  * the watcher is idle (zero per-frame cost) otherwise. */
5680
6504
  occupancyEnabled = false;
@@ -5864,6 +6688,47 @@ var NotificationCenter = class NotificationCenter {
5864
6688
  deliver: (entry) => this.dispatcher.deliver(entry),
5865
6689
  ...deps.now !== void 0 ? { now: deps.now } : {}
5866
6690
  });
6691
+ this.timelapseRules = new TimelapseStore({
6692
+ store: deps.store,
6693
+ logger: this.logger.child("timelapse"),
6694
+ ...deps.now !== void 0 ? { now: deps.now } : {}
6695
+ });
6696
+ const timelapsePorts = deps.timelapse;
6697
+ if (timelapsePorts !== void 0) {
6698
+ const logger = this.logger.child("timelapse");
6699
+ const renderer = new TimelapseRenderer({
6700
+ ...timelapsePorts,
6701
+ logger,
6702
+ now: this.now,
6703
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
6704
+ });
6705
+ this.timelapseScheduler = new TimelapseScheduler({
6706
+ logger,
6707
+ now: this.now,
6708
+ store: this.timelapseRules,
6709
+ render: (request) => renderer.render(request),
6710
+ enqueue: (rows) => this.outbox.enqueue(rows),
6711
+ getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId)
6712
+ });
6713
+ }
6714
+ }
6715
+ /**
6716
+ * The durable timelapse rule set — exposed for the `nc.*Timelapse*` bridge
6717
+ * actions, exactly as {@link ruleStore} is for the detection rules.
6718
+ */
6719
+ get timelapseStore() {
6720
+ return this.timelapseRules;
6721
+ }
6722
+ /**
6723
+ * Produce the last closed window for one camera of one timelapse rule, now.
6724
+ *
6725
+ * Throws when this node hosts no producer — an honest refusal beats a
6726
+ * `produced: 0` an operator reads as "there was nothing to make".
6727
+ */
6728
+ async runTimelapseNow(input) {
6729
+ const scheduler = this.timelapseScheduler;
6730
+ if (scheduler === null) throw new Error("this node does not host the timelapse producer — produce it on the hub");
6731
+ return scheduler.runNow(input);
5867
6732
  }
5868
6733
  /**
5869
6734
  * The durable rule store — exposed so the hub-only `nc.*` bridge actions
@@ -5900,6 +6765,7 @@ var NotificationCenter = class NotificationCenter {
5900
6765
  await this.rules.load();
5901
6766
  await this.snoozes.load();
5902
6767
  await this.deviceMutes.load();
6768
+ await this.timelapseRules.load();
5903
6769
  this.refreshOccupancyWatch();
5904
6770
  if (!opts.evaluation) return;
5905
6771
  await this.deviceStates.refresh();
@@ -5915,9 +6781,14 @@ var NotificationCenter = class NotificationCenter {
5915
6781
  this.reloadTimer = setInterval(() => {
5916
6782
  this.reloadRules();
5917
6783
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
6784
+ if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
6785
+ this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
6786
+ });
5918
6787
  this.logger.info("notification center started", { meta: {
5919
6788
  rules: this.rules.list().length,
5920
- pendingOutbox: this.outbox.pendingCount()
6789
+ pendingOutbox: this.outbox.pendingCount(),
6790
+ timelapseRules: this.timelapseRules.list().length,
6791
+ timelapseProducer: this.timelapseScheduler !== null
5921
6792
  } });
5922
6793
  }
5923
6794
  async stop() {
@@ -5929,6 +6800,7 @@ var NotificationCenter = class NotificationCenter {
5929
6800
  clearInterval(this.reloadTimer);
5930
6801
  this.reloadTimer = null;
5931
6802
  }
6803
+ this.timelapseScheduler?.stop();
5932
6804
  this.evaluationActive = false;
5933
6805
  }
5934
6806
  /**
@@ -6727,6 +7599,7 @@ var NotificationCenter = class NotificationCenter {
6727
7599
  * recompute the occupancy watched set + prune stale durable rows. */
6728
7600
  async reloadRules() {
6729
7601
  await this.rules.load();
7602
+ await this.timelapseRules.load();
6730
7603
  this.refreshOccupancyWatch();
6731
7604
  if (!this.evaluationActive) return;
6732
7605
  await this.deviceStates.refresh();
@@ -6965,6 +7838,75 @@ var NotificationCenter = class NotificationCenter {
6965
7838
  }
6966
7839
  };
6967
7840
  //#endregion
7841
+ //#region src/notification-center/timelapse/artifact-publish.ts
7842
+ /**
7843
+ * Build the renderer's `publishArtifact` port.
7844
+ *
7845
+ * @returns a function answering the artifact ref, or `null` with a logged
7846
+ * reason. It never rejects.
7847
+ */
7848
+ function buildTimelapseArtifactPublisher(ports) {
7849
+ return async ({ exportId, deviceId }) => {
7850
+ const tags = { deviceId };
7851
+ let read;
7852
+ try {
7853
+ read = await ports.readExportBytes({ exportId });
7854
+ } catch (err) {
7855
+ ports.logger.warn("timelapse artifact not published: export bytes unavailable", {
7856
+ tags,
7857
+ meta: {
7858
+ exportId,
7859
+ error: String(err)
7860
+ }
7861
+ });
7862
+ return null;
7863
+ }
7864
+ if (read.bytes > 52428800) {
7865
+ ports.logger.warn("timelapse artifact not published: video too large to republish", {
7866
+ tags,
7867
+ meta: {
7868
+ exportId,
7869
+ bytes: read.bytes,
7870
+ maxBytes: require_dist.RECORDING_EXPORT_MAX_READ_BYTES
7871
+ }
7872
+ });
7873
+ return null;
7874
+ }
7875
+ try {
7876
+ const url = await ports.publish(Buffer.from(read.base64, "base64"), read.contentType);
7877
+ if (url === null) {
7878
+ ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
7879
+ tags,
7880
+ meta: { exportId }
7881
+ });
7882
+ return null;
7883
+ }
7884
+ ports.logger.info("timelapse artifact published", {
7885
+ tags,
7886
+ meta: {
7887
+ exportId,
7888
+ bytes: read.bytes,
7889
+ name: read.name
7890
+ }
7891
+ });
7892
+ return {
7893
+ url,
7894
+ mime: read.contentType,
7895
+ name: read.name
7896
+ };
7897
+ } catch (err) {
7898
+ ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
7899
+ tags,
7900
+ meta: {
7901
+ exportId,
7902
+ error: String(err)
7903
+ }
7904
+ });
7905
+ return null;
7906
+ }
7907
+ };
7908
+ }
7909
+ //#endregion
6968
7910
  //#region src/notification-center/nc-actions.ts
6969
7911
  /**
6970
7912
  * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
@@ -6992,6 +7934,8 @@ var NotificationCenter = class NotificationCenter {
6992
7934
  */
6993
7935
  /** A rule as served to the viewer — carries the server's `readOnly` verdict. */
6994
7936
  var NcViewerRuleSchema = require_dist.NcRuleSchema.extend({ readOnly: require_dist.boolean() });
7937
+ /** Same verdict, for a timelapse rule. */
7938
+ var NcViewerTimelapseRuleSchema = require_dist.TimelapseRuleSchema.extend({ readOnly: require_dist.boolean() });
6995
7939
  /**
6996
7940
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
6997
7941
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
@@ -7059,6 +8003,54 @@ var ncActions = require_dist.defineCustomActions({
7059
8003
  kind: "mutation",
7060
8004
  auth: "admin",
7061
8005
  caller: "required"
8006
+ }),
8007
+ "nc.listTimelapseRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
8008
+ /**
8009
+ * ONE action for create AND update, unlike the detection-rule pair.
8010
+ *
8011
+ * A timelapse rule is edited as a whole in a single form (schedule +
8012
+ * cameras + targets), and the guards a create must pass — every camera
8013
+ * actually records, every target is owned — are the SAME ones a patch that
8014
+ * adds a camera must pass. Two entry points would be two places to forget
8015
+ * the recording check, and forgetting it is invisible until 06:05.
8016
+ *
8017
+ * `ruleId` absent ⇒ create from `rule`; `ruleId` present ⇒ patch.
8018
+ */
8019
+ "nc.upsertTimelapseRule": require_dist.customAction(require_dist.object({
8020
+ ruleId: require_dist.string().optional(),
8021
+ rule: require_dist.TimelapseRuleInputSchema.optional(),
8022
+ patch: require_dist.TimelapseRulePatchSchema.optional()
8023
+ }), require_dist.object({ rule: require_dist.TimelapseRuleSchema }), {
8024
+ kind: "mutation",
8025
+ caller: "required"
8026
+ }),
8027
+ "nc.deleteTimelapseRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
8028
+ kind: "mutation",
8029
+ caller: "required"
8030
+ }),
8031
+ /**
8032
+ * Produce the LAST CLOSED window for one camera of one rule, right now.
8033
+ *
8034
+ * `auth: 'admin'` for the same reason as `nc.injectTestEvent`: it DELIVERS,
8035
+ * to every target the rule addresses, on other people's phones. It does not
8036
+ * consume the schedule guard — pressing Test at noon must not cancel
8037
+ * tonight's run.
8038
+ */
8039
+ "nc.testTimelapseRule": require_dist.customAction(require_dist.object({
8040
+ ruleId: require_dist.string(),
8041
+ deviceId: require_dist.number()
8042
+ }), require_dist.object({
8043
+ produced: require_dist.number().int(),
8044
+ exportId: require_dist.string().optional(),
8045
+ coveragePercent: require_dist.number().optional(),
8046
+ enqueued: require_dist.number().int().optional(),
8047
+ windowStartMs: require_dist.number().optional(),
8048
+ windowEndMs: require_dist.number().optional(),
8049
+ error: require_dist.string().optional()
8050
+ }), {
8051
+ kind: "mutation",
8052
+ auth: "admin",
8053
+ caller: "required"
7062
8054
  })
7063
8055
  });
7064
8056
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -7097,6 +8089,44 @@ function makeNcActionHandlers(deps) {
7097
8089
  if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7098
8090
  return rule;
7099
8091
  };
8092
+ /** The producer half, or an honest refusal. */
8093
+ const requireTimelapse = () => {
8094
+ const tl = deps.timelapse;
8095
+ if (tl === void 0) throw new Error("this node does not host the timelapse producer — use the hub");
8096
+ return tl;
8097
+ };
8098
+ /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
8099
+ const assertTimelapseEditable = (ruleId, caller, tl) => {
8100
+ const rule = tl.store.get(ruleId);
8101
+ if (rule === null) throw new Error(`forbidden: timelapse rule not found: ${ruleId}`);
8102
+ if (caller.isAdmin) return rule;
8103
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: timelapse rule not owned: ${ruleId}`);
8104
+ return rule;
8105
+ };
8106
+ /**
8107
+ * THE RECORDING GUARD.
8108
+ *
8109
+ * A post-hoc timelapse renders RECORDED footage — a camera that is not
8110
+ * recording can never produce one, no matter how the rule is written. Save
8111
+ * time is the only moment an operator is present to be told: at 06:05 the
8112
+ * next morning the window is closed, the footage was never written, and the
8113
+ * only signal available is a log line nobody is reading.
8114
+ *
8115
+ * The authority is `RecordingConfig` through `deriveRecordingMode` — the
8116
+ * same pair camera-status reads. Never a second opinion, and never a guess:
8117
+ * `enabled: true` with no bands records NOTHING, which is exactly the case a
8118
+ * naive `config.enabled` check would wave through.
8119
+ *
8120
+ * FAIL-CLOSED on an unreadable config: a read that failed is not permission
8121
+ * to save.
8122
+ */
8123
+ const assertCamerasRecord = async (deviceIds, tl) => {
8124
+ for (const deviceId of deviceIds) {
8125
+ const config = await tl.getRecordingConfig(deviceId).catch(() => null);
8126
+ if (config === null) throw new Error(`camera ${deviceId}: recording configuration could not be read — cannot save a timelapse rule for it`);
8127
+ if (require_dist.deriveRecordingMode(config) === "off") throw new Error(`camera ${deviceId}: recording is not enabled — a timelapse renders recorded footage, so enable recording for this camera first`);
8128
+ }
8129
+ };
7100
8130
  const assertTargetsOwned = async (targetIds, caller) => {
7101
8131
  if (caller.isAdmin) return;
7102
8132
  const owned = new Set(await deps.listCallerTargetIds(caller.userId));
@@ -7189,6 +8219,83 @@ function makeNcActionHandlers(deps) {
7189
8219
  });
7190
8220
  return inject(parsed);
7191
8221
  },
8222
+ "nc.listTimelapseRules": async (_input, caller) => {
8223
+ const c = requireCaller(caller);
8224
+ const tl = requireTimelapse();
8225
+ return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
8226
+ ...r,
8227
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
8228
+ })) };
8229
+ },
8230
+ "nc.upsertTimelapseRule": async (input, caller) => {
8231
+ const c = requireCaller(caller);
8232
+ const tl = requireTimelapse();
8233
+ if (input.ruleId === void 0) {
8234
+ if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
8235
+ const parsed = require_dist.TimelapseRuleInputSchema.parse(input.rule);
8236
+ await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
8237
+ await assertCamerasRecord(parsed.deviceIds, tl);
8238
+ const rule = await tl.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
8239
+ deps.logger.info("timelapse rule created", { meta: {
8240
+ ruleId: rule.id,
8241
+ owner: c.userId,
8242
+ devices: rule.deviceIds.join(",")
8243
+ } });
8244
+ return { rule };
8245
+ }
8246
+ const existing = assertTimelapseEditable(input.ruleId, c, tl);
8247
+ if (input.patch === void 0) throw new Error("upsertTimelapseRule: patch required");
8248
+ const patch = require_dist.TimelapseRulePatchSchema.parse(input.patch);
8249
+ if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
8250
+ if (patch.deviceIds !== void 0) {
8251
+ const known = new Set(existing.deviceIds);
8252
+ await assertCamerasRecord(patch.deviceIds.filter((id) => !known.has(id)), tl);
8253
+ }
8254
+ const rule = await tl.store.update(input.ruleId, patch);
8255
+ deps.logger.info("timelapse rule updated", { meta: {
8256
+ ruleId: rule.id,
8257
+ owner: c.userId
8258
+ } });
8259
+ return { rule };
8260
+ },
8261
+ "nc.deleteTimelapseRule": async (input, caller) => {
8262
+ const c = requireCaller(caller);
8263
+ const tl = requireTimelapse();
8264
+ assertTimelapseEditable(input.ruleId, c, tl);
8265
+ await tl.store.delete(input.ruleId);
8266
+ deps.logger.info("timelapse rule deleted", { meta: {
8267
+ ruleId: input.ruleId,
8268
+ owner: c.userId
8269
+ } });
8270
+ return { success: true };
8271
+ },
8272
+ "nc.testTimelapseRule": async (input, caller) => {
8273
+ const c = requireCaller(caller);
8274
+ if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
8275
+ const tl = requireTimelapse();
8276
+ deps.logger.info("timelapse test requested", {
8277
+ tags: { deviceId: input.deviceId },
8278
+ meta: {
8279
+ by: c.userId,
8280
+ ruleId: input.ruleId
8281
+ }
8282
+ });
8283
+ const out = await tl.runNow({
8284
+ ruleId: input.ruleId,
8285
+ deviceId: input.deviceId
8286
+ });
8287
+ return {
8288
+ produced: out.produced,
8289
+ ...out.exportId !== void 0 ? { exportId: out.exportId } : {},
8290
+ ...out.coveragePercent !== void 0 ? { coveragePercent: out.coveragePercent } : {},
8291
+ ...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
8292
+ ...out.window !== null ? {
8293
+ windowStartMs: out.window.startMs,
8294
+ windowEndMs: out.window.endMs
8295
+ } : {},
8296
+ ...out.error !== void 0 ? { error: out.error } : {}
8297
+ };
8298
+ },
7192
8299
  "nc.setRuleTargetEnabled": async (input, caller) => {
7193
8300
  const c = requireCaller(caller);
7194
8301
  assertRuleVisible(input.ruleId, c);
@@ -32060,7 +33167,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
32060
33167
  listCallerTargetIds: async (userId) => {
32061
33168
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
32062
33169
  },
32063
- injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
33170
+ injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
33171
+ timelapse: {
33172
+ store: center.timelapseStore,
33173
+ getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
33174
+ runNow: (input) => center.runTimelapseNow(input)
33175
+ }
32064
33176
  });
32065
33177
  }
32066
33178
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -32673,12 +33785,91 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
32673
33785
  limit,
32674
33786
  projection: "slim"
32675
33787
  })).tracks;
32676
- }
33788
+ },
33789
+ ...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
32677
33790
  });
32678
33791
  this.wireAlarmPanel(this.notificationCenter);
32679
33792
  await this.serveNcActionPlane(this.notificationCenter);
32680
33793
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
32681
33794
  }
33795
+ /**
33796
+ * The scheduled-timelapse producer's ports.
33797
+ *
33798
+ * Every one of them is something that already exists: `recording` for the
33799
+ * footage index, the LOCAL track store for the history (no self-cap call, and
33800
+ * `projection: 'slim'` because the planner reads two timestamps per track and
33801
+ * nothing else), `recordingExport` for the render.
33802
+ *
33803
+ * TWO links, two audiences, and they are not interchangeable:
33804
+ *
33805
+ * - `publishArtifact` produces the one a notifier BACKEND fetches. It reads
33806
+ * the finished mp4 once through `recordingExport.readExportBytes` and
33807
+ * republishes it on the NC artifact plane — the same signed, expiring,
33808
+ * public surface a detection rule's still and clip already ride. Bounded
33809
+ * at `RECORDING_EXPORT_MAX_READ_BYTES`; above it there is no attachment
33810
+ * and a log line saying so.
33811
+ * - `resolveDownloadUrl` produces the one the RECIPIENT taps. The export's
33812
+ * own download route is served `access: 'authenticated'`, so it is a
33813
+ * perfectly good `clickUrl` and a useless attachment URL — a backend
33814
+ * fetching it holds no session and gets a 401.
33815
+ */
33816
+ buildTimelapsePorts(api, stores) {
33817
+ return {
33818
+ getAvailability: async ({ deviceId, fromMs, toMs }) => {
33819
+ return (await api.recording.getAvailability.query({
33820
+ deviceId,
33821
+ fromMs,
33822
+ toMs
33823
+ })).ranges;
33824
+ },
33825
+ listTracks: async ({ deviceId, since, until }) => {
33826
+ return (await stores.trackStore.queryHistorical({
33827
+ deviceId,
33828
+ since,
33829
+ until,
33830
+ projection: "slim"
33831
+ })).map((t) => ({
33832
+ firstSeen: t.firstSeen,
33833
+ lastSeen: t.lastSeen
33834
+ }));
33835
+ },
33836
+ createExport: (input) => api.recordingExport.createExport.mutate({
33837
+ deviceId: input.deviceId,
33838
+ profile: input.profile,
33839
+ fromMs: input.fromMs,
33840
+ toMs: input.toMs,
33841
+ options: {
33842
+ includeAudio: input.options.includeAudio,
33843
+ maxLifeMs: input.options.maxLifeMs,
33844
+ deleteAfterDownload: input.options.deleteAfterDownload,
33845
+ title: input.options.title,
33846
+ timelapse: {
33847
+ everyMs: input.options.timelapse.everyMs,
33848
+ outputFps: input.options.timelapse.outputFps,
33849
+ ...input.options.timelapse.dense !== void 0 ? { dense: {
33850
+ everyMs: input.options.timelapse.dense.everyMs,
33851
+ ranges: input.options.timelapse.dense.ranges.map((r) => ({
33852
+ fromSec: r.fromSec,
33853
+ toSec: r.toSec
33854
+ }))
33855
+ } } : {}
33856
+ }
33857
+ }
33858
+ }),
33859
+ getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
33860
+ publishArtifact: buildTimelapseArtifactPublisher({
33861
+ logger: this.ctx.logger.child("Timelapse"),
33862
+ readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
33863
+ publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
33864
+ }),
33865
+ resolveDownloadUrl: async ({ exportId }) => {
33866
+ const plane = this.ncArtifactPlane;
33867
+ if (plane === null) return null;
33868
+ const download = await api.recordingExport.getDownloadUrl.query({ exportId });
33869
+ return plane.resolveExternalUrl(download.url);
33870
+ }
33871
+ };
33872
+ }
32682
33873
  /** Event-media data-plane: serve thumbnail JPEGs at
32683
33874
  * `/addon/<addonId>/event-media/<eventId>` through the hub's port.
32684
33875
  * Mirror the recorder playback pattern: try/warn, store handle for dispose.