@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.
- package/dist/{dist-BQ-M7jn9.js → dist-DKsqvmSl.js} +255 -17
- package/dist/{dist-CSTtfEOC.mjs → dist-DPzTTt9R.mjs} +232 -18
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-lSMqbbY-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BdNH8u64.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-EHTrRb5u.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DO3ZGk1V.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-D5rpu5pY.mjs → hostInit-D8a3hjwW.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1205 -14
- package/dist/pipeline-analytics/index.mjs +1205 -14
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
|
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
|
|
5444
|
-
* durable state behind the
|
|
5445
|
-
* `updatedAt` (generation is not an edit of the rule
|
|
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
|
-
|
|
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
|
|
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.
|