@camstack/addon-post-analysis 1.2.64 → 1.2.65

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-DTClXZLm.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] ?? "");
@@ -5430,6 +5476,7 @@ var TimelapseStore = class {
5430
5476
  id: existing.id,
5431
5477
  ownerUserId: existing.ownerUserId,
5432
5478
  lastGeneratedAt: existing.lastGeneratedAt,
5479
+ generatedByDevice: existing.generatedByDevice,
5433
5480
  createdBy: existing.createdBy,
5434
5481
  createdAt: existing.createdAt,
5435
5482
  updatedAt: this.now()
@@ -5440,16 +5487,39 @@ var TimelapseStore = class {
5440
5487
  return this.update(ruleId, { enabled });
5441
5488
  }
5442
5489
  /**
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).
5490
+ * Record a successful generation for ONE camera of the rule. `at` is the
5491
+ * generation epoch-ms — the durable state behind the re-generation guard.
5492
+ * Does NOT bump `updatedAt` (a generation is not an edit of the rule).
5493
+ *
5494
+ * PER-DEVICE, and that is the whole point: a rule covering three cameras
5495
+ * renders three videos, and stamping the rule when the first one succeeds
5496
+ * tells the other two they are already done. Their night is then gone for
5497
+ * good — a closed window does not come back. Call this ONLY after the export
5498
+ * reached `ready` and the notification was enqueued; a failed render must
5499
+ * leave the pair unstamped so the next tick can retry it.
5500
+ *
5501
+ * The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
5502
+ * a UI shows) and never regresses when an older device generation lands.
5503
+ *
5504
+ * A rule that still carries only the LEGACY rule-wide stamp has its map
5505
+ * SEEDED from it for every camera the rule covers, before the new stamp is
5506
+ * applied — writing `{ [deviceId]: at }` alone would drop the other cameras
5507
+ * to "never generated" and re-render them once.
5446
5508
  */
5447
- async markGenerated(ruleId, at) {
5509
+ async markGenerated(ruleId, deviceId, at) {
5448
5510
  const existing = this.byId.get(ruleId);
5449
5511
  if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
5512
+ const seeded = {};
5513
+ if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
5514
+ const generatedByDevice = {
5515
+ ...seeded,
5516
+ ...existing.generatedByDevice,
5517
+ [String(deviceId)]: at
5518
+ };
5450
5519
  return this.write({
5451
5520
  ...existing,
5452
- lastGeneratedAt: at
5521
+ generatedByDevice,
5522
+ lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
5453
5523
  });
5454
5524
  }
5455
5525
  /** Idempotent delete — unknown ids are a no-op. */
@@ -5494,6 +5564,656 @@ var TimelapseStore = class {
5494
5564
  }
5495
5565
  };
5496
5566
  //#endregion
5567
+ //#region src/notification-center/timelapse/dense-ranges.ts
5568
+ /**
5569
+ * The POST-HOC timelapse's render plan: which seconds of the closed window
5570
+ * deserve the fast cadence.
5571
+ *
5572
+ * Pure — no clock, no I/O, no store. The scheduler hands over the tracks the
5573
+ * window actually contained (`pipelineAnalytics.listTracks`, which carry
5574
+ * `firstSeen`/`lastSeen`) and gets back ranges expressed as SECONDS FROM THE
5575
+ * WINDOW START, which is exactly the coordinate system
5576
+ * `ExportTimelapseSchema.dense.ranges` speaks.
5577
+ *
5578
+ * Why post-hoc and not a live sampler: the footage already exists, the tracks
5579
+ * already exist, and the recorder already knows how to render a range. A live
5580
+ * sampler would hold a frame ring open for eight hours per camera to
5581
+ * reconstruct information the database already has.
5582
+ *
5583
+ * The coalescing rule is `2 × base cadence`: two tracks closer together than
5584
+ * two base frames would render as one continuous busy stretch anyway, so
5585
+ * splitting them buys nothing and costs a range out of the budget.
5586
+ */
5587
+ /** Re-exported so a caller plans and validates against the SAME ceiling the
5588
+ * cap enforces — two constants would be free to drift, and the drift would
5589
+ * surface as a rejected export at render time, hours after the window. */
5590
+ var DENSE_MAX_RANGES = 200;
5591
+ /** Clamp a track to the window and pad it, dropping what falls outside. */
5592
+ function clampTrack(track, windowStartMs, windowEndMs, padMs) {
5593
+ const rawFrom = Math.min(track.firstSeen, track.lastSeen);
5594
+ const rawTo = Math.max(track.firstSeen, track.lastSeen);
5595
+ if (rawTo < windowStartMs || rawFrom > windowEndMs) return null;
5596
+ const fromMs = Math.max(windowStartMs, rawFrom - padMs);
5597
+ const toMs = Math.min(windowEndMs, rawTo + padMs);
5598
+ return toMs > fromMs ? {
5599
+ fromMs,
5600
+ toMs
5601
+ } : null;
5602
+ }
5603
+ /**
5604
+ * Merge the window's tracks into dense ranges.
5605
+ *
5606
+ * @returns ranges in seconds from `windowStartMs`, already coalesced, sorted
5607
+ * and clamped — or an empty capped plan.
5608
+ */
5609
+ function planDenseRanges(input) {
5610
+ const maxRanges = Math.max(1, input.maxRanges ?? DENSE_MAX_RANGES);
5611
+ const padMs = Math.max(0, input.padSec) * 1e3;
5612
+ const gapMs = Math.max(1, input.baseCadenceSec) * 2e3;
5613
+ const clamped = [];
5614
+ for (const track of input.tracks) {
5615
+ const r = clampTrack(track, input.windowStartMs, input.windowEndMs, padMs);
5616
+ if (r !== null) clamped.push(r);
5617
+ }
5618
+ clamped.sort((a, b) => a.fromMs - b.fromMs);
5619
+ const merged = [];
5620
+ for (const r of clamped) {
5621
+ const last = merged[merged.length - 1];
5622
+ if (last !== void 0 && r.fromMs - last.toMs < gapMs) {
5623
+ merged[merged.length - 1] = {
5624
+ fromMs: last.fromMs,
5625
+ toMs: Math.max(last.toMs, r.toMs)
5626
+ };
5627
+ continue;
5628
+ }
5629
+ merged.push(r);
5630
+ }
5631
+ if (merged.length > maxRanges) return {
5632
+ ranges: [],
5633
+ capped: true,
5634
+ mergedCount: merged.length,
5635
+ denseSec: 0
5636
+ };
5637
+ const ranges = merged.map((r) => ({
5638
+ fromSec: (r.fromMs - input.windowStartMs) / 1e3,
5639
+ toSec: (r.toMs - input.windowStartMs) / 1e3
5640
+ }));
5641
+ const denseSec = ranges.reduce((sum, r) => sum + (r.toSec - r.fromSec), 0);
5642
+ return {
5643
+ ranges,
5644
+ capped: false,
5645
+ mergedCount: merged.length,
5646
+ denseSec
5647
+ };
5648
+ }
5649
+ //#endregion
5650
+ //#region src/notification-center/timelapse/footage-coverage.ts
5651
+ /**
5652
+ * Slack at the window edges before a window counts as incomplete.
5653
+ *
5654
+ * Recorded footage is segment-granular, so the last segment almost never lands
5655
+ * exactly on the boundary. Without slack every complete night reports "99% of
5656
+ * the window" — noise the operator learns to skip, which is how the genuinely
5657
+ * short video gets skipped too.
5658
+ */
5659
+ var COVERAGE_COMPLETE_SLACK_MS = 3e4;
5660
+ /** Merge + clamp + sum. */
5661
+ function footageCoverage(ranges, windowStartMs, windowEndMs) {
5662
+ const windowMs = Math.max(0, windowEndMs - windowStartMs);
5663
+ if (windowMs === 0) return {
5664
+ fraction: 0,
5665
+ percent: 0,
5666
+ coveredMs: 0,
5667
+ windowMs: 0,
5668
+ complete: false
5669
+ };
5670
+ const clamped = ranges.map((r) => ({
5671
+ startMs: Math.max(windowStartMs, Math.min(r.startMs, r.endMs)),
5672
+ endMs: Math.min(windowEndMs, Math.max(r.startMs, r.endMs))
5673
+ })).filter((r) => r.endMs > r.startMs).sort((a, b) => a.startMs - b.startMs);
5674
+ let coveredMs = 0;
5675
+ let curStart = null;
5676
+ let curEnd = 0;
5677
+ for (const r of clamped) {
5678
+ if (curStart === null) {
5679
+ curStart = r.startMs;
5680
+ curEnd = r.endMs;
5681
+ continue;
5682
+ }
5683
+ if (r.startMs <= curEnd) {
5684
+ curEnd = Math.max(curEnd, r.endMs);
5685
+ continue;
5686
+ }
5687
+ coveredMs += curEnd - curStart;
5688
+ curStart = r.startMs;
5689
+ curEnd = r.endMs;
5690
+ }
5691
+ if (curStart !== null) coveredMs += curEnd - curStart;
5692
+ const fraction = coveredMs / windowMs;
5693
+ return {
5694
+ fraction,
5695
+ percent: Math.round(fraction * 100),
5696
+ coveredMs,
5697
+ windowMs,
5698
+ complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
5699
+ };
5700
+ }
5701
+ /** How long a scheduled timelapse's file is kept by the export engine. */
5702
+ var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
5703
+ var TimelapseRenderError = class extends Error {
5704
+ reason;
5705
+ constructor(reason, message) {
5706
+ super(message);
5707
+ this.name = "TimelapseRenderError";
5708
+ this.reason = reason;
5709
+ }
5710
+ };
5711
+ /** The profile with the most footage over the window; ties broken by name so
5712
+ * the same window always renders from the same source. */
5713
+ function pickProfile(ranges, window) {
5714
+ const byProfile = /* @__PURE__ */ new Map();
5715
+ for (const r of ranges) {
5716
+ const list = byProfile.get(r.profile);
5717
+ if (list === void 0) byProfile.set(r.profile, [r]);
5718
+ else list.push(r);
5719
+ }
5720
+ let best = null;
5721
+ for (const [profile, rows] of [...byProfile].sort((a, b) => a[0].localeCompare(b[0]))) {
5722
+ const coverage = footageCoverage(rows, window.startMs, window.endMs);
5723
+ if (best === null || coverage.coveredMs > best.coverage.coveredMs) best = {
5724
+ profile,
5725
+ coverage
5726
+ };
5727
+ }
5728
+ return best;
5729
+ }
5730
+ /** `HH:MM`, for the export title an operator sees in the exports list. */
5731
+ function clockOf$1(atMs) {
5732
+ const d = new Date(atMs);
5733
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
5734
+ }
5735
+ var TimelapseRenderer = class {
5736
+ ports;
5737
+ pollMs;
5738
+ timeoutMs;
5739
+ constructor(ports) {
5740
+ this.ports = ports;
5741
+ this.pollMs = ports.pollMs ?? 5e3;
5742
+ this.timeoutMs = ports.timeoutMs ?? 18e5;
5743
+ }
5744
+ async render(request) {
5745
+ const { deviceId, window } = request;
5746
+ const tags = { deviceId };
5747
+ const chosen = pickProfile(await this.ports.getAvailability({
5748
+ deviceId,
5749
+ fromMs: window.startMs,
5750
+ toMs: window.endMs
5751
+ }), window);
5752
+ if (chosen === null || chosen.coverage.coveredMs === 0) {
5753
+ this.ports.logger.warn("timelapse skipped: no footage covers the window", {
5754
+ tags,
5755
+ meta: {
5756
+ ruleId: request.ruleId,
5757
+ windowStart: window.startMs,
5758
+ windowEnd: window.endMs
5759
+ }
5760
+ });
5761
+ throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
5762
+ }
5763
+ const plan = planDenseRanges({
5764
+ tracks: await this.ports.listTracks({
5765
+ deviceId,
5766
+ since: window.startMs,
5767
+ until: window.endMs
5768
+ }).catch((err) => {
5769
+ this.ports.logger.warn("timelapse dense plan unavailable — rendering uniform", {
5770
+ tags,
5771
+ meta: {
5772
+ ruleId: request.ruleId,
5773
+ error: String(err)
5774
+ }
5775
+ });
5776
+ return [];
5777
+ }),
5778
+ windowStartMs: window.startMs,
5779
+ windowEndMs: window.endMs,
5780
+ padSec: 5,
5781
+ baseCadenceSec: request.cadenceSec
5782
+ });
5783
+ const everyMs = request.cadenceSec * 1e3;
5784
+ const denseEveryMs = Math.max(1e3, Math.round(everyMs / 10));
5785
+ const dense = plan.ranges.length > 0 && denseEveryMs < everyMs ? {
5786
+ everyMs: denseEveryMs,
5787
+ ranges: [...plan.ranges]
5788
+ } : void 0;
5789
+ if (plan.capped) this.ports.logger.info("timelapse dense plan exceeded the range ceiling — uniform cadence", {
5790
+ tags,
5791
+ meta: {
5792
+ ruleId: request.ruleId,
5793
+ mergedRanges: plan.mergedCount
5794
+ }
5795
+ });
5796
+ const queued = await this.ports.createExport({
5797
+ deviceId,
5798
+ profile: chosen.profile,
5799
+ fromMs: window.startMs,
5800
+ toMs: window.endMs,
5801
+ options: {
5802
+ includeAudio: false,
5803
+ maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
5804
+ deleteAfterDownload: false,
5805
+ title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
5806
+ timelapse: {
5807
+ everyMs,
5808
+ outputFps: request.framerate,
5809
+ ...dense !== void 0 ? { dense } : {}
5810
+ }
5811
+ }
5812
+ });
5813
+ this.ports.logger.info("timelapse export queued", {
5814
+ tags,
5815
+ meta: {
5816
+ ruleId: request.ruleId,
5817
+ exportId: queued.id,
5818
+ profile: chosen.profile,
5819
+ everyMs,
5820
+ denseRanges: plan.ranges.length,
5821
+ coveragePct: chosen.coverage.percent
5822
+ }
5823
+ });
5824
+ const finished = await this.awaitReady(queued, tags);
5825
+ const artifact = await this.publish(finished.id, deviceId, tags);
5826
+ const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
5827
+ return {
5828
+ exportId: finished.id,
5829
+ profile: chosen.profile,
5830
+ coverage: chosen.coverage,
5831
+ plan,
5832
+ artifact,
5833
+ downloadUrl: downloadUrl ?? null,
5834
+ fileBytes: finished.fileBytes
5835
+ };
5836
+ }
5837
+ /** Poll to a terminal state, under a bound. */
5838
+ async awaitReady(queued, tags) {
5839
+ if (queued.state === "ready") return queued;
5840
+ const deadline = this.ports.now() + this.timeoutMs;
5841
+ let current = queued;
5842
+ while (this.ports.now() < deadline) {
5843
+ await this.ports.sleep(this.pollMs);
5844
+ current = await this.ports.getExport({ exportId: queued.id });
5845
+ if (current.state === "ready") return current;
5846
+ if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
5847
+ this.ports.logger.warn("timelapse export did not finish", {
5848
+ tags,
5849
+ meta: {
5850
+ exportId: queued.id,
5851
+ state: current.state,
5852
+ error: current.error
5853
+ }
5854
+ });
5855
+ throw new TimelapseRenderError("export-failed", current.error ?? `export ${queued.id} ended ${current.state}`);
5856
+ }
5857
+ }
5858
+ this.ports.logger.warn("timelapse export timed out", {
5859
+ tags,
5860
+ meta: {
5861
+ exportId: queued.id,
5862
+ timeoutMs: this.timeoutMs,
5863
+ lastState: current.state
5864
+ }
5865
+ });
5866
+ throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
5867
+ }
5868
+ /**
5869
+ * Mint the public link, or say why there is none.
5870
+ *
5871
+ * Never throws: a video that exists and cannot be linked is a degraded
5872
+ * notification, not a failed window — and failing here would leave the pair
5873
+ * unstamped and re-render the same eight hours on the next tick.
5874
+ */
5875
+ async publish(exportId, deviceId, tags) {
5876
+ const publish = this.ports.publishArtifact;
5877
+ if (publish === void 0) {
5878
+ this.ports.logger.info("timelapse artifact publishing is not wired — link-less delivery", {
5879
+ tags,
5880
+ meta: { exportId }
5881
+ });
5882
+ return null;
5883
+ }
5884
+ try {
5885
+ const ref = await publish({
5886
+ exportId,
5887
+ deviceId
5888
+ });
5889
+ if (ref === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
5890
+ tags,
5891
+ meta: { exportId }
5892
+ });
5893
+ return ref;
5894
+ } catch (err) {
5895
+ this.ports.logger.warn("timelapse artifact publish threw — link-less delivery", {
5896
+ tags,
5897
+ meta: {
5898
+ exportId,
5899
+ error: String(err)
5900
+ }
5901
+ });
5902
+ return null;
5903
+ }
5904
+ }
5905
+ };
5906
+ //#endregion
5907
+ //#region src/notification-center/timelapse/timelapse-delivery.ts
5908
+ /** `HH:MM` in the host timezone — the two numbers a body actually needs. */
5909
+ function clockOf(atMs) {
5910
+ const d = new Date(atMs);
5911
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
5912
+ }
5913
+ /** Template vocabulary. Deliberately the same NAMES the rule templates use
5914
+ * (`camera`, `rule`, `time`) plus the timelapse-only ones. */
5915
+ function templateVars(input) {
5916
+ return {
5917
+ camera: input.deviceName ?? `camera ${input.deviceId}`,
5918
+ rule: input.ruleName,
5919
+ from: clockOf(input.window.startMs),
5920
+ to: clockOf(input.window.endMs),
5921
+ time: clockOf(input.generatedAt),
5922
+ coverage: String(input.coverage.percent),
5923
+ events: String(input.denseRanges)
5924
+ };
5925
+ }
5926
+ /**
5927
+ * The body an operator reads when he did not author one.
5928
+ *
5929
+ * The COVERAGE sentence is the load-bearing part and only appears when it
5930
+ * carries information: a video assembled from two of the eight hours it claims
5931
+ * to show is visually indistinguishable from a very quiet night, and shipping
5932
+ * it silently is how a three-hour recorder outage looks like nothing happened.
5933
+ * A complete window says nothing — a line every night is a line nobody reads.
5934
+ */
5935
+ function derivedBody(input) {
5936
+ const vars = templateVars(input);
5937
+ const parts = [`${vars.rule} — ${vars.from}→${vars.to}`];
5938
+ if (input.denseCapped === true) parts.push("troppi eventi per il campionamento denso: video a cadenza uniforme");
5939
+ else if (input.denseRanges > 0) parts.push(`${input.denseRanges} intervalli ripresi a cadenza fitta`);
5940
+ if (!input.coverage.complete) parts.push(`ATTENZIONE: registrazione disponibile solo per il ${vars.coverage}% della finestra`);
5941
+ return parts.join(" · ");
5942
+ }
5943
+ /** Per-target params with the derived `clickUrl` filled in where absent. */
5944
+ function paramsFor(target, clickUrl) {
5945
+ if (clickUrl === void 0) return target.params;
5946
+ if (target.params === void 0) return { clickUrl };
5947
+ if (typeof target.params["clickUrl"] === "string") return target.params;
5948
+ return {
5949
+ ...target.params,
5950
+ clickUrl
5951
+ };
5952
+ }
5953
+ /** Stable, retry-safe outbox record id for one (device, closed window). */
5954
+ function timelapseRecordId(deviceId, windowEndMs) {
5955
+ return `timelapse:${deviceId}:${windowEndMs}`;
5956
+ }
5957
+ /**
5958
+ * One outbox row per (still-enabled) target.
5959
+ *
5960
+ * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
5961
+ */
5962
+ function buildTimelapseOutboxInputs(input) {
5963
+ const vars = templateVars(input);
5964
+ const optedOut = new Set(input.disabledTargetIds ?? []);
5965
+ const title = renderTemplate(input.template?.title, vars) ?? `Timelapse — ${vars.camera}`;
5966
+ const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input);
5967
+ const recordId = timelapseRecordId(input.deviceId, input.window.endMs);
5968
+ return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
5969
+ const payload = {
5970
+ ruleName: input.ruleName,
5971
+ delivery: "timelapse",
5972
+ priority: input.priority,
5973
+ template: {
5974
+ title,
5975
+ body
5976
+ },
5977
+ media: "none",
5978
+ ...input.artifact !== void 0 ? { artifact: {
5979
+ mediaType: "video",
5980
+ ...input.artifact
5981
+ } } : {},
5982
+ ...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
5983
+ subject: {
5984
+ deviceId: input.deviceId,
5985
+ className: "timelapse",
5986
+ zones: [],
5987
+ timestamp: input.window.endMs
5988
+ }
5989
+ };
5990
+ return {
5991
+ ruleId: input.ruleId,
5992
+ targetId: target.targetId,
5993
+ deviceId: input.deviceId,
5994
+ recordKind: "timelapse",
5995
+ recordId,
5996
+ payload
5997
+ };
5998
+ });
5999
+ }
6000
+ /**
6001
+ * The most recent window that had already CLOSED at `nowMs`.
6002
+ *
6003
+ * Standing INSIDE a window returns the PREVIOUS one — a scheduler that
6004
+ * answered "the one I am in" would render half a night and burn the guard on
6005
+ * it.
6006
+ *
6007
+ * @returns `null` when no close falls inside `lookbackMs`, when the schedule
6008
+ * never closes (always active), OR when the close was found but its OPEN
6009
+ * predates the lookback floor. The last case is null and not a clamped
6010
+ * window on purpose: reporting the scan floor as the start ships a video
6011
+ * that silently begins hours late.
6012
+ */
6013
+ function findLastClosedWindow(input) {
6014
+ const step = Math.max(1e3, input.stepMs ?? 6e4);
6015
+ const from = Math.floor(input.nowMs / step) * step;
6016
+ const floor = input.nowMs - input.lookbackMs;
6017
+ const active = (t) => require_dist.isScheduleActive(input.schedule, t);
6018
+ let endMs = null;
6019
+ for (let t = from; t - step >= floor; t -= step) if (!active(t) && active(t - step)) {
6020
+ endMs = t;
6021
+ break;
6022
+ }
6023
+ if (endMs === null) return null;
6024
+ for (let t = endMs - step; t - step >= floor; t -= step) if (!active(t - step)) return {
6025
+ startMs: t,
6026
+ endMs
6027
+ };
6028
+ return null;
6029
+ }
6030
+ var TimelapseScheduler = class {
6031
+ deps;
6032
+ timer = null;
6033
+ tickInFlight = false;
6034
+ settleMs;
6035
+ lookbackMs;
6036
+ tickMs;
6037
+ constructor(deps) {
6038
+ this.deps = deps;
6039
+ this.settleMs = deps.settleMs ?? 3e5;
6040
+ this.lookbackMs = deps.lookbackMs ?? 6912e5;
6041
+ this.tickMs = deps.tickMs ?? 6e4;
6042
+ }
6043
+ /**
6044
+ * Boot: one tick (the back-fill) and then the interval.
6045
+ *
6046
+ * The back-fill needs no special path — it IS a tick. A window that closed
6047
+ * while the process was down is simply a window whose pair has no stamp.
6048
+ */
6049
+ async start() {
6050
+ await this.tick();
6051
+ this.timer = setInterval(() => {
6052
+ this.tick();
6053
+ }, this.tickMs);
6054
+ }
6055
+ stop() {
6056
+ if (this.timer !== null) {
6057
+ clearInterval(this.timer);
6058
+ this.timer = null;
6059
+ }
6060
+ }
6061
+ /** One pass over every enabled rule × camera. Single-flight. */
6062
+ async tick() {
6063
+ if (this.tickInFlight) return {
6064
+ produced: 0,
6065
+ waiting: 0,
6066
+ alreadyDone: 0,
6067
+ failed: 0,
6068
+ idle: 0
6069
+ };
6070
+ this.tickInFlight = true;
6071
+ const report = {
6072
+ produced: 0,
6073
+ waiting: 0,
6074
+ alreadyDone: 0,
6075
+ failed: 0,
6076
+ idle: 0
6077
+ };
6078
+ try {
6079
+ const now = this.deps.now();
6080
+ for (const rule of this.deps.store.listEnabled()) {
6081
+ const window = findLastClosedWindow({
6082
+ schedule: rule.schedule,
6083
+ nowMs: now,
6084
+ lookbackMs: this.lookbackMs
6085
+ });
6086
+ if (window === null) {
6087
+ report.idle += rule.deviceIds.length;
6088
+ continue;
6089
+ }
6090
+ if (now < window.endMs + this.settleMs) {
6091
+ report.waiting += rule.deviceIds.length;
6092
+ continue;
6093
+ }
6094
+ for (const deviceId of rule.deviceIds) {
6095
+ if (require_dist.readTimelapseGeneratedAt(rule, deviceId) >= window.endMs) {
6096
+ report.alreadyDone += 1;
6097
+ continue;
6098
+ }
6099
+ if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
6100
+ else report.failed += 1;
6101
+ }
6102
+ }
6103
+ } finally {
6104
+ this.tickInFlight = false;
6105
+ }
6106
+ return report;
6107
+ }
6108
+ /**
6109
+ * The operator's Test button: produce the last closed window for one camera
6110
+ * of one rule, ignoring the settle delay and the guard.
6111
+ *
6112
+ * It DELIVERS — same as `nc.injectTestEvent`, and for the same reason: a
6113
+ * test that stops short of the notifier does not test the thing that breaks.
6114
+ * It deliberately does NOT stamp: pressing Test at noon must not cancel
6115
+ * tonight's scheduled run.
6116
+ */
6117
+ async runNow(input) {
6118
+ const rule = this.deps.store.get(input.ruleId);
6119
+ if (rule === null) throw new Error(`timelapse rule not found: ${input.ruleId}`);
6120
+ if (!rule.deviceIds.includes(input.deviceId)) throw new Error(`rule ${rule.name} does not cover camera ${input.deviceId}`);
6121
+ const window = findLastClosedWindow({
6122
+ schedule: rule.schedule,
6123
+ nowMs: this.deps.now(),
6124
+ lookbackMs: this.lookbackMs
6125
+ });
6126
+ if (window === null) return {
6127
+ produced: 0,
6128
+ window: null,
6129
+ error: "no schedule window has closed recently"
6130
+ };
6131
+ return this.produce(rule, input.deviceId, window, { stamp: false });
6132
+ }
6133
+ /**
6134
+ * Render → enqueue → (optionally) stamp. Never throws.
6135
+ *
6136
+ * The stamp is LAST and conditional on both steps: a render that failed, or
6137
+ * a queue that refused the rows, must leave the pair eligible. A window does
6138
+ * not come back.
6139
+ */
6140
+ async produce(rule, deviceId, window, opts) {
6141
+ const tags = { deviceId };
6142
+ try {
6143
+ const outcome = await this.deps.render({
6144
+ ruleId: rule.id,
6145
+ ruleName: rule.name,
6146
+ deviceId,
6147
+ window,
6148
+ cadenceSec: rule.cadenceSec,
6149
+ framerate: rule.framerate
6150
+ });
6151
+ const deviceName = await this.resolveDeviceName(deviceId);
6152
+ const rows = buildTimelapseOutboxInputs({
6153
+ ruleId: rule.id,
6154
+ ruleName: rule.name,
6155
+ deviceId,
6156
+ ...deviceName !== null ? { deviceName } : {},
6157
+ window,
6158
+ targets: rule.targets,
6159
+ ...this.deps.disabledTargetIds !== void 0 ? { disabledTargetIds: this.deps.disabledTargetIds(rule.id) } : {},
6160
+ priority: rule.priority,
6161
+ coverage: outcome.coverage,
6162
+ ...outcome.artifact !== null ? { artifact: outcome.artifact } : {},
6163
+ ...outcome.downloadUrl !== null ? { clickUrl: outcome.downloadUrl } : {},
6164
+ exportId: outcome.exportId,
6165
+ denseRanges: outcome.plan.ranges.length,
6166
+ ...outcome.plan.capped ? { denseCapped: true } : {},
6167
+ generatedAt: this.deps.now(),
6168
+ ...rule.template !== void 0 ? { template: rule.template } : {}
6169
+ });
6170
+ const enqueued = await this.deps.enqueue(rows);
6171
+ if (opts.stamp) await this.deps.store.markGenerated(rule.id, deviceId, this.deps.now());
6172
+ this.deps.logger.info("timelapse produced", {
6173
+ tags,
6174
+ meta: {
6175
+ ruleId: rule.id,
6176
+ exportId: outcome.exportId,
6177
+ windowStart: window.startMs,
6178
+ windowEnd: window.endMs,
6179
+ coveragePct: outcome.coverage.percent,
6180
+ denseRanges: outcome.plan.ranges.length,
6181
+ enqueued,
6182
+ stamped: opts.stamp,
6183
+ ...outcome.artifact === null ? { artifact: "none" } : {}
6184
+ }
6185
+ });
6186
+ return {
6187
+ produced: 1,
6188
+ window,
6189
+ exportId: outcome.exportId,
6190
+ coveragePercent: outcome.coverage.percent,
6191
+ enqueued
6192
+ };
6193
+ } catch (err) {
6194
+ this.deps.logger.warn("timelapse not produced — the window stays eligible", {
6195
+ tags,
6196
+ meta: {
6197
+ ruleId: rule.id,
6198
+ windowStart: window.startMs,
6199
+ windowEnd: window.endMs,
6200
+ error: String(err)
6201
+ }
6202
+ });
6203
+ return {
6204
+ produced: 0,
6205
+ window,
6206
+ error: err instanceof Error ? err.message : String(err)
6207
+ };
6208
+ }
6209
+ }
6210
+ async resolveDeviceName(deviceId) {
6211
+ const get = this.deps.getDeviceName;
6212
+ if (get === void 0) return null;
6213
+ return get(deviceId).catch(() => null);
6214
+ }
6215
+ };
6216
+ //#endregion
5497
6217
  //#region src/notification-center/index.ts
5498
6218
  /**
5499
6219
  * NotificationCenter — the P1 core of the Notification Center (spec
@@ -5623,16 +6343,21 @@ function packagePhaseOf(ev) {
5623
6343
  * never disagree. `ruleName` + `subject` come from the intent snapshot
5624
6344
  * frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
5625
6345
  */
6346
+ function historyRecordKind(kind) {
6347
+ if (kind === "audio-event") return "object-event";
6348
+ if (kind === "timelapse") return "system-event";
6349
+ return kind;
6350
+ }
5626
6351
  function outboxEntryToHistory(entry) {
5627
6352
  const s = entry.payload.subject;
5628
6353
  return {
5629
6354
  id: entry.id,
5630
6355
  ruleId: entry.ruleId,
5631
6356
  ruleName: entry.payload.ruleName,
5632
- delivery: entry.payload.delivery,
6357
+ delivery: entry.payload.delivery === "timelapse" ? "system-event" : entry.payload.delivery,
5633
6358
  targetId: entry.targetId,
5634
6359
  deviceId: entry.deviceId,
5635
- recordKind: entry.recordKind === "audio-event" ? "object-event" : entry.recordKind,
6360
+ recordKind: historyRecordKind(entry.recordKind),
5636
6361
  recordId: entry.recordId,
5637
6362
  ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
5638
6363
  status: entry.status,
@@ -5675,6 +6400,13 @@ var NotificationCenter = class NotificationCenter {
5675
6400
  /** Grants behind the tap-through buttons. In-process and single-use — read
5676
6401
  * `action-token.ts` before assuming anything about what a tap proves. */
5677
6402
  actionTokens = new NcActionTokenRegistry();
6403
+ /**
6404
+ * The scheduled-timelapse rule set. Held on EVERY node so the bridge actions
6405
+ * can serve CRUD from wherever they are asked (same reasoning as the rule
6406
+ * store); only the hub, with `deps.timelapse` wired, gets a scheduler.
6407
+ */
6408
+ timelapseRules;
6409
+ timelapseScheduler = null;
5678
6410
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
5679
6411
  * the watcher is idle (zero per-frame cost) otherwise. */
5680
6412
  occupancyEnabled = false;
@@ -5864,6 +6596,47 @@ var NotificationCenter = class NotificationCenter {
5864
6596
  deliver: (entry) => this.dispatcher.deliver(entry),
5865
6597
  ...deps.now !== void 0 ? { now: deps.now } : {}
5866
6598
  });
6599
+ this.timelapseRules = new TimelapseStore({
6600
+ store: deps.store,
6601
+ logger: this.logger.child("timelapse"),
6602
+ ...deps.now !== void 0 ? { now: deps.now } : {}
6603
+ });
6604
+ const timelapsePorts = deps.timelapse;
6605
+ if (timelapsePorts !== void 0) {
6606
+ const logger = this.logger.child("timelapse");
6607
+ const renderer = new TimelapseRenderer({
6608
+ ...timelapsePorts,
6609
+ logger,
6610
+ now: this.now,
6611
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
6612
+ });
6613
+ this.timelapseScheduler = new TimelapseScheduler({
6614
+ logger,
6615
+ now: this.now,
6616
+ store: this.timelapseRules,
6617
+ render: (request) => renderer.render(request),
6618
+ enqueue: (rows) => this.outbox.enqueue(rows),
6619
+ getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId)
6620
+ });
6621
+ }
6622
+ }
6623
+ /**
6624
+ * The durable timelapse rule set — exposed for the `nc.*Timelapse*` bridge
6625
+ * actions, exactly as {@link ruleStore} is for the detection rules.
6626
+ */
6627
+ get timelapseStore() {
6628
+ return this.timelapseRules;
6629
+ }
6630
+ /**
6631
+ * Produce the last closed window for one camera of one timelapse rule, now.
6632
+ *
6633
+ * Throws when this node hosts no producer — an honest refusal beats a
6634
+ * `produced: 0` an operator reads as "there was nothing to make".
6635
+ */
6636
+ async runTimelapseNow(input) {
6637
+ const scheduler = this.timelapseScheduler;
6638
+ if (scheduler === null) throw new Error("this node does not host the timelapse producer — produce it on the hub");
6639
+ return scheduler.runNow(input);
5867
6640
  }
5868
6641
  /**
5869
6642
  * The durable rule store — exposed so the hub-only `nc.*` bridge actions
@@ -5900,6 +6673,7 @@ var NotificationCenter = class NotificationCenter {
5900
6673
  await this.rules.load();
5901
6674
  await this.snoozes.load();
5902
6675
  await this.deviceMutes.load();
6676
+ await this.timelapseRules.load();
5903
6677
  this.refreshOccupancyWatch();
5904
6678
  if (!opts.evaluation) return;
5905
6679
  await this.deviceStates.refresh();
@@ -5915,9 +6689,14 @@ var NotificationCenter = class NotificationCenter {
5915
6689
  this.reloadTimer = setInterval(() => {
5916
6690
  this.reloadRules();
5917
6691
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
6692
+ if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
6693
+ this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
6694
+ });
5918
6695
  this.logger.info("notification center started", { meta: {
5919
6696
  rules: this.rules.list().length,
5920
- pendingOutbox: this.outbox.pendingCount()
6697
+ pendingOutbox: this.outbox.pendingCount(),
6698
+ timelapseRules: this.timelapseRules.list().length,
6699
+ timelapseProducer: this.timelapseScheduler !== null
5921
6700
  } });
5922
6701
  }
5923
6702
  async stop() {
@@ -5929,6 +6708,7 @@ var NotificationCenter = class NotificationCenter {
5929
6708
  clearInterval(this.reloadTimer);
5930
6709
  this.reloadTimer = null;
5931
6710
  }
6711
+ this.timelapseScheduler?.stop();
5932
6712
  this.evaluationActive = false;
5933
6713
  }
5934
6714
  /**
@@ -6727,6 +7507,7 @@ var NotificationCenter = class NotificationCenter {
6727
7507
  * recompute the occupancy watched set + prune stale durable rows. */
6728
7508
  async reloadRules() {
6729
7509
  await this.rules.load();
7510
+ await this.timelapseRules.load();
6730
7511
  this.refreshOccupancyWatch();
6731
7512
  if (!this.evaluationActive) return;
6732
7513
  await this.deviceStates.refresh();
@@ -6965,6 +7746,75 @@ var NotificationCenter = class NotificationCenter {
6965
7746
  }
6966
7747
  };
6967
7748
  //#endregion
7749
+ //#region src/notification-center/timelapse/artifact-publish.ts
7750
+ /**
7751
+ * Build the renderer's `publishArtifact` port.
7752
+ *
7753
+ * @returns a function answering the artifact ref, or `null` with a logged
7754
+ * reason. It never rejects.
7755
+ */
7756
+ function buildTimelapseArtifactPublisher(ports) {
7757
+ return async ({ exportId, deviceId }) => {
7758
+ const tags = { deviceId };
7759
+ let read;
7760
+ try {
7761
+ read = await ports.readExportBytes({ exportId });
7762
+ } catch (err) {
7763
+ ports.logger.warn("timelapse artifact not published: export bytes unavailable", {
7764
+ tags,
7765
+ meta: {
7766
+ exportId,
7767
+ error: String(err)
7768
+ }
7769
+ });
7770
+ return null;
7771
+ }
7772
+ if (read.bytes > 52428800) {
7773
+ ports.logger.warn("timelapse artifact not published: video too large to republish", {
7774
+ tags,
7775
+ meta: {
7776
+ exportId,
7777
+ bytes: read.bytes,
7778
+ maxBytes: require_dist.RECORDING_EXPORT_MAX_READ_BYTES
7779
+ }
7780
+ });
7781
+ return null;
7782
+ }
7783
+ try {
7784
+ const url = await ports.publish(Buffer.from(read.base64, "base64"), read.contentType);
7785
+ if (url === null) {
7786
+ ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
7787
+ tags,
7788
+ meta: { exportId }
7789
+ });
7790
+ return null;
7791
+ }
7792
+ ports.logger.info("timelapse artifact published", {
7793
+ tags,
7794
+ meta: {
7795
+ exportId,
7796
+ bytes: read.bytes,
7797
+ name: read.name
7798
+ }
7799
+ });
7800
+ return {
7801
+ url,
7802
+ mime: read.contentType,
7803
+ name: read.name
7804
+ };
7805
+ } catch (err) {
7806
+ ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
7807
+ tags,
7808
+ meta: {
7809
+ exportId,
7810
+ error: String(err)
7811
+ }
7812
+ });
7813
+ return null;
7814
+ }
7815
+ };
7816
+ }
7817
+ //#endregion
6968
7818
  //#region src/notification-center/nc-actions.ts
6969
7819
  /**
6970
7820
  * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
@@ -6992,6 +7842,8 @@ var NotificationCenter = class NotificationCenter {
6992
7842
  */
6993
7843
  /** A rule as served to the viewer — carries the server's `readOnly` verdict. */
6994
7844
  var NcViewerRuleSchema = require_dist.NcRuleSchema.extend({ readOnly: require_dist.boolean() });
7845
+ /** Same verdict, for a timelapse rule. */
7846
+ var NcViewerTimelapseRuleSchema = require_dist.TimelapseRuleSchema.extend({ readOnly: require_dist.boolean() });
6995
7847
  /**
6996
7848
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
6997
7849
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
@@ -7059,6 +7911,54 @@ var ncActions = require_dist.defineCustomActions({
7059
7911
  kind: "mutation",
7060
7912
  auth: "admin",
7061
7913
  caller: "required"
7914
+ }),
7915
+ "nc.listTimelapseRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
7916
+ /**
7917
+ * ONE action for create AND update, unlike the detection-rule pair.
7918
+ *
7919
+ * A timelapse rule is edited as a whole in a single form (schedule +
7920
+ * cameras + targets), and the guards a create must pass — every camera
7921
+ * actually records, every target is owned — are the SAME ones a patch that
7922
+ * adds a camera must pass. Two entry points would be two places to forget
7923
+ * the recording check, and forgetting it is invisible until 06:05.
7924
+ *
7925
+ * `ruleId` absent ⇒ create from `rule`; `ruleId` present ⇒ patch.
7926
+ */
7927
+ "nc.upsertTimelapseRule": require_dist.customAction(require_dist.object({
7928
+ ruleId: require_dist.string().optional(),
7929
+ rule: require_dist.TimelapseRuleInputSchema.optional(),
7930
+ patch: require_dist.TimelapseRulePatchSchema.optional()
7931
+ }), require_dist.object({ rule: require_dist.TimelapseRuleSchema }), {
7932
+ kind: "mutation",
7933
+ caller: "required"
7934
+ }),
7935
+ "nc.deleteTimelapseRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
7936
+ kind: "mutation",
7937
+ caller: "required"
7938
+ }),
7939
+ /**
7940
+ * Produce the LAST CLOSED window for one camera of one rule, right now.
7941
+ *
7942
+ * `auth: 'admin'` for the same reason as `nc.injectTestEvent`: it DELIVERS,
7943
+ * to every target the rule addresses, on other people's phones. It does not
7944
+ * consume the schedule guard — pressing Test at noon must not cancel
7945
+ * tonight's run.
7946
+ */
7947
+ "nc.testTimelapseRule": require_dist.customAction(require_dist.object({
7948
+ ruleId: require_dist.string(),
7949
+ deviceId: require_dist.number()
7950
+ }), require_dist.object({
7951
+ produced: require_dist.number().int(),
7952
+ exportId: require_dist.string().optional(),
7953
+ coveragePercent: require_dist.number().optional(),
7954
+ enqueued: require_dist.number().int().optional(),
7955
+ windowStartMs: require_dist.number().optional(),
7956
+ windowEndMs: require_dist.number().optional(),
7957
+ error: require_dist.string().optional()
7958
+ }), {
7959
+ kind: "mutation",
7960
+ auth: "admin",
7961
+ caller: "required"
7062
7962
  })
7063
7963
  });
7064
7964
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -7097,6 +7997,44 @@ function makeNcActionHandlers(deps) {
7097
7997
  if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
7098
7998
  return rule;
7099
7999
  };
8000
+ /** The producer half, or an honest refusal. */
8001
+ const requireTimelapse = () => {
8002
+ const tl = deps.timelapse;
8003
+ if (tl === void 0) throw new Error("this node does not host the timelapse producer — use the hub");
8004
+ return tl;
8005
+ };
8006
+ /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
8007
+ const assertTimelapseEditable = (ruleId, caller, tl) => {
8008
+ const rule = tl.store.get(ruleId);
8009
+ if (rule === null) throw new Error(`forbidden: timelapse rule not found: ${ruleId}`);
8010
+ if (caller.isAdmin) return rule;
8011
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: timelapse rule not owned: ${ruleId}`);
8012
+ return rule;
8013
+ };
8014
+ /**
8015
+ * THE RECORDING GUARD.
8016
+ *
8017
+ * A post-hoc timelapse renders RECORDED footage — a camera that is not
8018
+ * recording can never produce one, no matter how the rule is written. Save
8019
+ * time is the only moment an operator is present to be told: at 06:05 the
8020
+ * next morning the window is closed, the footage was never written, and the
8021
+ * only signal available is a log line nobody is reading.
8022
+ *
8023
+ * The authority is `RecordingConfig` through `deriveRecordingMode` — the
8024
+ * same pair camera-status reads. Never a second opinion, and never a guess:
8025
+ * `enabled: true` with no bands records NOTHING, which is exactly the case a
8026
+ * naive `config.enabled` check would wave through.
8027
+ *
8028
+ * FAIL-CLOSED on an unreadable config: a read that failed is not permission
8029
+ * to save.
8030
+ */
8031
+ const assertCamerasRecord = async (deviceIds, tl) => {
8032
+ for (const deviceId of deviceIds) {
8033
+ const config = await tl.getRecordingConfig(deviceId).catch(() => null);
8034
+ if (config === null) throw new Error(`camera ${deviceId}: recording configuration could not be read — cannot save a timelapse rule for it`);
8035
+ 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`);
8036
+ }
8037
+ };
7100
8038
  const assertTargetsOwned = async (targetIds, caller) => {
7101
8039
  if (caller.isAdmin) return;
7102
8040
  const owned = new Set(await deps.listCallerTargetIds(caller.userId));
@@ -7189,6 +8127,83 @@ function makeNcActionHandlers(deps) {
7189
8127
  });
7190
8128
  return inject(parsed);
7191
8129
  },
8130
+ "nc.listTimelapseRules": async (_input, caller) => {
8131
+ const c = requireCaller(caller);
8132
+ const tl = requireTimelapse();
8133
+ return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
8134
+ ...r,
8135
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
8136
+ })) };
8137
+ },
8138
+ "nc.upsertTimelapseRule": async (input, caller) => {
8139
+ const c = requireCaller(caller);
8140
+ const tl = requireTimelapse();
8141
+ if (input.ruleId === void 0) {
8142
+ if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
8143
+ const parsed = require_dist.TimelapseRuleInputSchema.parse(input.rule);
8144
+ await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
8145
+ await assertCamerasRecord(parsed.deviceIds, tl);
8146
+ const rule = await tl.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
8147
+ deps.logger.info("timelapse rule created", { meta: {
8148
+ ruleId: rule.id,
8149
+ owner: c.userId,
8150
+ devices: rule.deviceIds.join(",")
8151
+ } });
8152
+ return { rule };
8153
+ }
8154
+ const existing = assertTimelapseEditable(input.ruleId, c, tl);
8155
+ if (input.patch === void 0) throw new Error("upsertTimelapseRule: patch required");
8156
+ const patch = require_dist.TimelapseRulePatchSchema.parse(input.patch);
8157
+ if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
8158
+ if (patch.deviceIds !== void 0) {
8159
+ const known = new Set(existing.deviceIds);
8160
+ await assertCamerasRecord(patch.deviceIds.filter((id) => !known.has(id)), tl);
8161
+ }
8162
+ const rule = await tl.store.update(input.ruleId, patch);
8163
+ deps.logger.info("timelapse rule updated", { meta: {
8164
+ ruleId: rule.id,
8165
+ owner: c.userId
8166
+ } });
8167
+ return { rule };
8168
+ },
8169
+ "nc.deleteTimelapseRule": async (input, caller) => {
8170
+ const c = requireCaller(caller);
8171
+ const tl = requireTimelapse();
8172
+ assertTimelapseEditable(input.ruleId, c, tl);
8173
+ await tl.store.delete(input.ruleId);
8174
+ deps.logger.info("timelapse rule deleted", { meta: {
8175
+ ruleId: input.ruleId,
8176
+ owner: c.userId
8177
+ } });
8178
+ return { success: true };
8179
+ },
8180
+ "nc.testTimelapseRule": async (input, caller) => {
8181
+ const c = requireCaller(caller);
8182
+ if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
8183
+ const tl = requireTimelapse();
8184
+ deps.logger.info("timelapse test requested", {
8185
+ tags: { deviceId: input.deviceId },
8186
+ meta: {
8187
+ by: c.userId,
8188
+ ruleId: input.ruleId
8189
+ }
8190
+ });
8191
+ const out = await tl.runNow({
8192
+ ruleId: input.ruleId,
8193
+ deviceId: input.deviceId
8194
+ });
8195
+ return {
8196
+ produced: out.produced,
8197
+ ...out.exportId !== void 0 ? { exportId: out.exportId } : {},
8198
+ ...out.coveragePercent !== void 0 ? { coveragePercent: out.coveragePercent } : {},
8199
+ ...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
8200
+ ...out.window !== null ? {
8201
+ windowStartMs: out.window.startMs,
8202
+ windowEndMs: out.window.endMs
8203
+ } : {},
8204
+ ...out.error !== void 0 ? { error: out.error } : {}
8205
+ };
8206
+ },
7192
8207
  "nc.setRuleTargetEnabled": async (input, caller) => {
7193
8208
  const c = requireCaller(caller);
7194
8209
  assertRuleVisible(input.ruleId, c);
@@ -32060,7 +33075,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
32060
33075
  listCallerTargetIds: async (userId) => {
32061
33076
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
32062
33077
  },
32063
- injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
33078
+ injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
33079
+ timelapse: {
33080
+ store: center.timelapseStore,
33081
+ getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
33082
+ runNow: (input) => center.runTimelapseNow(input)
33083
+ }
32064
33084
  });
32065
33085
  }
32066
33086
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -32673,12 +33693,91 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
32673
33693
  limit,
32674
33694
  projection: "slim"
32675
33695
  })).tracks;
32676
- }
33696
+ },
33697
+ ...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
32677
33698
  });
32678
33699
  this.wireAlarmPanel(this.notificationCenter);
32679
33700
  await this.serveNcActionPlane(this.notificationCenter);
32680
33701
  await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
32681
33702
  }
33703
+ /**
33704
+ * The scheduled-timelapse producer's ports.
33705
+ *
33706
+ * Every one of them is something that already exists: `recording` for the
33707
+ * footage index, the LOCAL track store for the history (no self-cap call, and
33708
+ * `projection: 'slim'` because the planner reads two timestamps per track and
33709
+ * nothing else), `recordingExport` for the render.
33710
+ *
33711
+ * TWO links, two audiences, and they are not interchangeable:
33712
+ *
33713
+ * - `publishArtifact` produces the one a notifier BACKEND fetches. It reads
33714
+ * the finished mp4 once through `recordingExport.readExportBytes` and
33715
+ * republishes it on the NC artifact plane — the same signed, expiring,
33716
+ * public surface a detection rule's still and clip already ride. Bounded
33717
+ * at `RECORDING_EXPORT_MAX_READ_BYTES`; above it there is no attachment
33718
+ * and a log line saying so.
33719
+ * - `resolveDownloadUrl` produces the one the RECIPIENT taps. The export's
33720
+ * own download route is served `access: 'authenticated'`, so it is a
33721
+ * perfectly good `clickUrl` and a useless attachment URL — a backend
33722
+ * fetching it holds no session and gets a 401.
33723
+ */
33724
+ buildTimelapsePorts(api, stores) {
33725
+ return {
33726
+ getAvailability: async ({ deviceId, fromMs, toMs }) => {
33727
+ return (await api.recording.getAvailability.query({
33728
+ deviceId,
33729
+ fromMs,
33730
+ toMs
33731
+ })).ranges;
33732
+ },
33733
+ listTracks: async ({ deviceId, since, until }) => {
33734
+ return (await stores.trackStore.queryHistorical({
33735
+ deviceId,
33736
+ since,
33737
+ until,
33738
+ projection: "slim"
33739
+ })).map((t) => ({
33740
+ firstSeen: t.firstSeen,
33741
+ lastSeen: t.lastSeen
33742
+ }));
33743
+ },
33744
+ createExport: (input) => api.recordingExport.createExport.mutate({
33745
+ deviceId: input.deviceId,
33746
+ profile: input.profile,
33747
+ fromMs: input.fromMs,
33748
+ toMs: input.toMs,
33749
+ options: {
33750
+ includeAudio: input.options.includeAudio,
33751
+ maxLifeMs: input.options.maxLifeMs,
33752
+ deleteAfterDownload: input.options.deleteAfterDownload,
33753
+ title: input.options.title,
33754
+ timelapse: {
33755
+ everyMs: input.options.timelapse.everyMs,
33756
+ outputFps: input.options.timelapse.outputFps,
33757
+ ...input.options.timelapse.dense !== void 0 ? { dense: {
33758
+ everyMs: input.options.timelapse.dense.everyMs,
33759
+ ranges: input.options.timelapse.dense.ranges.map((r) => ({
33760
+ fromSec: r.fromSec,
33761
+ toSec: r.toSec
33762
+ }))
33763
+ } } : {}
33764
+ }
33765
+ }
33766
+ }),
33767
+ getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
33768
+ publishArtifact: buildTimelapseArtifactPublisher({
33769
+ logger: this.ctx.logger.child("Timelapse"),
33770
+ readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
33771
+ publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
33772
+ }),
33773
+ resolveDownloadUrl: async ({ exportId }) => {
33774
+ const plane = this.ncArtifactPlane;
33775
+ if (plane === null) return null;
33776
+ const download = await api.recordingExport.getDownloadUrl.query({ exportId });
33777
+ return plane.resolveExternalUrl(download.url);
33778
+ }
33779
+ };
33780
+ }
32682
33781
  /** Event-media data-plane: serve thumbnail JPEGs at
32683
33782
  * `/addon/<addonId>/event-media/<eventId>` through the hub's port.
32684
33783
  * Mirror the recorder playback pattern: try/warn, store handle for dispose.