@camstack/addon-post-analysis 1.2.63 → 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.
- package/dist/{dist-CdqESw05.mjs → dist-4dhFDogy.mjs} +509 -46
- package/dist/{dist-D4fGaA71.js → dist-DTClXZLm.js} +532 -45
- 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-DrgptocJ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Ck9Z2cZR.mjs} +3 -3
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DJwl-swi.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-C8v4WVLy.mjs → hostInit-DvCGVFX8.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1383 -65
- package/dist/pipeline-analytics/index.mjs +1383 -65
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +2 -2
|
@@ -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-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
|
|
5444
|
-
* durable state behind the
|
|
5445
|
-
* `updatedAt` (generation is not an edit of the rule
|
|
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
|
-
|
|
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
|
|
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);
|
|
@@ -15784,6 +16799,7 @@ function resolveFaceSettings(raw) {
|
|
|
15784
16799
|
*/
|
|
15785
16800
|
function createLocationAwareMediaStorage(deps) {
|
|
15786
16801
|
const roots = /* @__PURE__ */ new Map();
|
|
16802
|
+
let defaultBypassInvalidated = false;
|
|
15787
16803
|
const rootOf = async (locationId) => {
|
|
15788
16804
|
const cached = roots.get(locationId);
|
|
15789
16805
|
if (cached !== void 0) return cached;
|
|
@@ -15794,21 +16810,55 @@ function createLocationAwareMediaStorage(deps) {
|
|
|
15794
16810
|
const absOf = async (locationId, relativePath) => node_path.default.join(await rootOf(locationId), relativePath);
|
|
15795
16811
|
return {
|
|
15796
16812
|
write: async (input) => {
|
|
15797
|
-
if (input.location === deps.defaultLocation) return deps.base.write(input);
|
|
16813
|
+
if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.write(input);
|
|
15798
16814
|
const abs = await absOf(input.location, input.relativePath);
|
|
15799
16815
|
await node_fs.promises.mkdir(node_path.default.dirname(abs), { recursive: true });
|
|
15800
16816
|
await node_fs.promises.writeFile(abs, input.data);
|
|
15801
16817
|
},
|
|
15802
16818
|
read: async (input) => {
|
|
15803
|
-
if (input.location === deps.defaultLocation) return deps.base.read(input);
|
|
16819
|
+
if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.read(input);
|
|
15804
16820
|
return node_fs.promises.readFile(await absOf(input.location, input.relativePath));
|
|
15805
16821
|
},
|
|
15806
16822
|
delete: async (input) => {
|
|
15807
|
-
if (input.location === deps.defaultLocation) return deps.base.delete(input);
|
|
16823
|
+
if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.delete(input);
|
|
15808
16824
|
await node_fs.promises.rm(await absOf(input.location, input.relativePath), { force: true });
|
|
16825
|
+
},
|
|
16826
|
+
invalidateRoots: () => {
|
|
16827
|
+
roots.clear();
|
|
16828
|
+
defaultBypassInvalidated = true;
|
|
15809
16829
|
}
|
|
15810
16830
|
};
|
|
15811
16831
|
}
|
|
16832
|
+
//#endregion
|
|
16833
|
+
//#region src/pipeline-analytics/media-write-gate.ts
|
|
16834
|
+
/**
|
|
16835
|
+
* Reversible writer gate for storage maintenance. `pause()` closes admission
|
|
16836
|
+
* first, then waits for writes already admitted to settle; callers never
|
|
16837
|
+
* mutate device bindings or analytics configuration to quiesce media.
|
|
16838
|
+
*/
|
|
16839
|
+
var MediaWriteGate = class {
|
|
16840
|
+
paused = false;
|
|
16841
|
+
active = 0;
|
|
16842
|
+
drainedWaiters = [];
|
|
16843
|
+
async run(write) {
|
|
16844
|
+
if (this.paused) throw new Error("event-media writes are paused for storage migration");
|
|
16845
|
+
this.active++;
|
|
16846
|
+
try {
|
|
16847
|
+
return await write();
|
|
16848
|
+
} finally {
|
|
16849
|
+
this.active--;
|
|
16850
|
+
if (this.active === 0) for (const resolve of this.drainedWaiters.splice(0)) resolve();
|
|
16851
|
+
}
|
|
16852
|
+
}
|
|
16853
|
+
pause() {
|
|
16854
|
+
this.paused = true;
|
|
16855
|
+
if (this.active === 0) return Promise.resolve();
|
|
16856
|
+
return new Promise((resolve) => this.drainedWaiters.push(resolve));
|
|
16857
|
+
}
|
|
16858
|
+
resume() {
|
|
16859
|
+
this.paused = false;
|
|
16860
|
+
}
|
|
16861
|
+
};
|
|
15812
16862
|
var MACRO_SET = new Set([
|
|
15813
16863
|
"person",
|
|
15814
16864
|
"vehicle",
|
|
@@ -16679,7 +17729,14 @@ var RETRAIN_FRAME_COLUMNS = [
|
|
|
16679
17729
|
name: "copiedAt",
|
|
16680
17730
|
type: "INTEGER",
|
|
16681
17731
|
notNull: true
|
|
16682
|
-
}
|
|
17732
|
+
},
|
|
17733
|
+
(
|
|
17734
|
+
/** NULL = current eventMedia default; stamped when migration moves a copy
|
|
17735
|
+
* before the logical default is repointed. */
|
|
17736
|
+
{
|
|
17737
|
+
name: "locationId",
|
|
17738
|
+
type: "TEXT"
|
|
17739
|
+
})
|
|
16683
17740
|
];
|
|
16684
17741
|
var RETRAIN_FRAME_INDEXES = [{
|
|
16685
17742
|
name: "idx_retrain_frames_track",
|
|
@@ -16708,12 +17765,14 @@ var RetrainFrameStore = class {
|
|
|
16708
17765
|
logger;
|
|
16709
17766
|
readSource;
|
|
16710
17767
|
now;
|
|
17768
|
+
writeGate;
|
|
16711
17769
|
constructor(deps) {
|
|
16712
17770
|
this.store = deps.store;
|
|
16713
17771
|
this.storage = deps.storage;
|
|
16714
17772
|
this.logger = deps.logger;
|
|
16715
17773
|
this.readSource = deps.readSource;
|
|
16716
17774
|
this.now = deps.now;
|
|
17775
|
+
this.writeGate = deps.writeGate;
|
|
16717
17776
|
}
|
|
16718
17777
|
static async declare(store) {
|
|
16719
17778
|
await store.declareCollection.mutate({
|
|
@@ -16732,7 +17791,8 @@ var RetrainFrameStore = class {
|
|
|
16732
17791
|
sizeBytes: Number(data["sizeBytes"] ?? 0),
|
|
16733
17792
|
width: Number(data["width"] ?? 0),
|
|
16734
17793
|
height: Number(data["height"] ?? 0),
|
|
16735
|
-
copiedAt: Number(data["copiedAt"] ?? 0)
|
|
17794
|
+
copiedAt: Number(data["copiedAt"] ?? 0),
|
|
17795
|
+
locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
|
|
16736
17796
|
};
|
|
16737
17797
|
}
|
|
16738
17798
|
/** The copy with this id, or `null`. Index read only — no blob is touched. */
|
|
@@ -16800,7 +17860,7 @@ var RetrainFrameStore = class {
|
|
|
16800
17860
|
async readFrame(frame) {
|
|
16801
17861
|
try {
|
|
16802
17862
|
return await this.storage.read({
|
|
16803
|
-
location:
|
|
17863
|
+
location: frame.locationId ?? "eventMedia",
|
|
16804
17864
|
relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
|
|
16805
17865
|
});
|
|
16806
17866
|
} catch (err) {
|
|
@@ -16880,9 +17940,10 @@ var RetrainFrameStore = class {
|
|
|
16880
17940
|
sizeBytes: bytes.length,
|
|
16881
17941
|
width: size.width,
|
|
16882
17942
|
height: size.height,
|
|
16883
|
-
copiedAt: this.now()
|
|
17943
|
+
copiedAt: this.now(),
|
|
17944
|
+
locationId: null
|
|
16884
17945
|
};
|
|
16885
|
-
|
|
17946
|
+
const persist = async () => {
|
|
16886
17947
|
await this.storage.write({
|
|
16887
17948
|
location: RETRAIN_MEDIA_LOCATION,
|
|
16888
17949
|
relativePath: path,
|
|
@@ -16900,9 +17961,14 @@ var RetrainFrameStore = class {
|
|
|
16900
17961
|
sizeBytes: frame.sizeBytes,
|
|
16901
17962
|
width: frame.width,
|
|
16902
17963
|
height: frame.height,
|
|
16903
|
-
copiedAt: frame.copiedAt
|
|
17964
|
+
copiedAt: frame.copiedAt,
|
|
17965
|
+
locationId: null
|
|
16904
17966
|
}
|
|
16905
17967
|
});
|
|
17968
|
+
};
|
|
17969
|
+
try {
|
|
17970
|
+
if (this.writeGate) await this.writeGate.run(persist);
|
|
17971
|
+
else await persist();
|
|
16906
17972
|
} catch (err) {
|
|
16907
17973
|
this.logger.error("retrain copy failed to persist", {
|
|
16908
17974
|
tags: { deviceId: input.deviceId },
|
|
@@ -16943,26 +18009,30 @@ var RetrainFrameStore = class {
|
|
|
16943
18009
|
* best-effort write is how a half-deleted annotation set happens.
|
|
16944
18010
|
*/
|
|
16945
18011
|
async deleteFrame(frame) {
|
|
16946
|
-
|
|
16947
|
-
|
|
16948
|
-
|
|
16949
|
-
|
|
16950
|
-
|
|
16951
|
-
|
|
16952
|
-
|
|
16953
|
-
|
|
16954
|
-
|
|
16955
|
-
|
|
16956
|
-
|
|
16957
|
-
|
|
16958
|
-
|
|
16959
|
-
|
|
16960
|
-
|
|
16961
|
-
|
|
16962
|
-
|
|
16963
|
-
|
|
16964
|
-
|
|
16965
|
-
|
|
18012
|
+
const remove = async () => {
|
|
18013
|
+
try {
|
|
18014
|
+
await this.storage.delete({
|
|
18015
|
+
location: frame.locationId ?? "eventMedia",
|
|
18016
|
+
relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
|
|
18017
|
+
});
|
|
18018
|
+
} catch {}
|
|
18019
|
+
try {
|
|
18020
|
+
await this.store.delete.mutate({
|
|
18021
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
18022
|
+
key: frame.frameId
|
|
18023
|
+
});
|
|
18024
|
+
} catch (err) {
|
|
18025
|
+
this.logger.warn("RetrainFrameStore.deleteFrame row delete failed", {
|
|
18026
|
+
tags: { deviceId: frame.deviceId },
|
|
18027
|
+
meta: {
|
|
18028
|
+
frameId: frame.frameId,
|
|
18029
|
+
error: String(err)
|
|
18030
|
+
}
|
|
18031
|
+
});
|
|
18032
|
+
}
|
|
18033
|
+
};
|
|
18034
|
+
if (this.writeGate) await this.writeGate.run(remove);
|
|
18035
|
+
else await remove();
|
|
16966
18036
|
}
|
|
16967
18037
|
};
|
|
16968
18038
|
//#endregion
|
|
@@ -18001,10 +19071,15 @@ var MediaStore = class {
|
|
|
18001
19071
|
storage;
|
|
18002
19072
|
store;
|
|
18003
19073
|
logger;
|
|
19074
|
+
writeGate;
|
|
18004
19075
|
constructor(deps) {
|
|
18005
19076
|
this.storage = deps.storage;
|
|
18006
19077
|
this.store = deps.store;
|
|
18007
19078
|
this.logger = deps.logger;
|
|
19079
|
+
this.writeGate = deps.writeGate;
|
|
19080
|
+
}
|
|
19081
|
+
gated(mutation) {
|
|
19082
|
+
return this.writeGate ? this.writeGate.run(mutation) : mutation();
|
|
18008
19083
|
}
|
|
18009
19084
|
static async declare(store) {
|
|
18010
19085
|
await store.declareCollection.mutate({
|
|
@@ -18016,6 +19091,10 @@ var MediaStore = class {
|
|
|
18016
19091
|
/** Persist a media blob and register its metadata row. Returns the
|
|
18017
19092
|
* collection key (MediaFile.key) that callers store as reference. */
|
|
18018
19093
|
async put(params) {
|
|
19094
|
+
if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
|
|
19095
|
+
return this.putUnchecked(params);
|
|
19096
|
+
}
|
|
19097
|
+
async putUnchecked(params) {
|
|
18019
19098
|
const key = buildKey(params);
|
|
18020
19099
|
const path = buildPath(params);
|
|
18021
19100
|
const record = {
|
|
@@ -18086,6 +19165,9 @@ var MediaStore = class {
|
|
|
18086
19165
|
* scheme (transition back-compat); once migrated it is a no-op.
|
|
18087
19166
|
*/
|
|
18088
19167
|
async putReplacing(params) {
|
|
19168
|
+
return this.gated(() => this.putReplacingUnchecked(params));
|
|
19169
|
+
}
|
|
19170
|
+
async putReplacingUnchecked(params) {
|
|
18089
19171
|
const existing = await this.store.query.query({
|
|
18090
19172
|
collection: MEDIA_COLLECTION,
|
|
18091
19173
|
filter: { where: {
|
|
@@ -18094,7 +19176,7 @@ var MediaStore = class {
|
|
|
18094
19176
|
kind: params.kind
|
|
18095
19177
|
} }
|
|
18096
19178
|
});
|
|
18097
|
-
const newKey = await this.
|
|
19179
|
+
const newKey = await this.putUnchecked(params);
|
|
18098
19180
|
for (const row of existing) {
|
|
18099
19181
|
if (row.id === newKey) continue;
|
|
18100
19182
|
const rowData = row.data;
|
|
@@ -18137,8 +19219,11 @@ var MediaStore = class {
|
|
|
18137
19219
|
* own kind.
|
|
18138
19220
|
*/
|
|
18139
19221
|
async promoteToLastFrame(input) {
|
|
19222
|
+
return this.gated(() => this.promoteToLastFrameUnchecked(input));
|
|
19223
|
+
}
|
|
19224
|
+
async promoteToLastFrameUnchecked(input) {
|
|
18140
19225
|
const data = Buffer.from(input.snapshot.base64, "base64");
|
|
18141
|
-
const newKey = await this.
|
|
19226
|
+
const newKey = await this.putReplacingUnchecked({
|
|
18142
19227
|
deviceId: input.deviceId,
|
|
18143
19228
|
ownerKind: "track",
|
|
18144
19229
|
ownerId: input.trackId,
|
|
@@ -18146,7 +19231,7 @@ var MediaStore = class {
|
|
|
18146
19231
|
timestamp: input.snapshot.timestamp,
|
|
18147
19232
|
data
|
|
18148
19233
|
});
|
|
18149
|
-
if (input.keepSource !== true) await this.
|
|
19234
|
+
if (input.keepSource !== true) await this.deleteByKeyUnchecked(input.snapshot.key);
|
|
18150
19235
|
return newKey;
|
|
18151
19236
|
}
|
|
18152
19237
|
/**
|
|
@@ -18184,6 +19269,9 @@ var MediaStore = class {
|
|
|
18184
19269
|
* Deletes the blob from storage (best-effort) and removes the index row.
|
|
18185
19270
|
*/
|
|
18186
19271
|
async deleteByKey(key) {
|
|
19272
|
+
await this.gated(() => this.deleteByKeyUnchecked(key));
|
|
19273
|
+
}
|
|
19274
|
+
async deleteByKeyUnchecked(key) {
|
|
18187
19275
|
const row = await this.store.get.query({
|
|
18188
19276
|
collection: MEDIA_COLLECTION,
|
|
18189
19277
|
key
|
|
@@ -18294,6 +19382,9 @@ var MediaStore = class {
|
|
|
18294
19382
|
* Best-effort per row; returns the number of index rows removed.
|
|
18295
19383
|
*/
|
|
18296
19384
|
async deleteForOwner(ownerKind, ownerIds) {
|
|
19385
|
+
return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
|
|
19386
|
+
}
|
|
19387
|
+
async deleteForOwnerUnchecked(ownerKind, ownerIds) {
|
|
18297
19388
|
let removed = 0;
|
|
18298
19389
|
for (const ownerId of ownerIds) {
|
|
18299
19390
|
const rows = await this.store.query.query({
|
|
@@ -18375,6 +19466,9 @@ var MediaStore = class {
|
|
|
18375
19466
|
* The old-entry deletion is best-effort: on failure a warning is logged
|
|
18376
19467
|
* and the method still returns the new key. */
|
|
18377
19468
|
async reown(input) {
|
|
19469
|
+
return this.gated(() => this.reownUnchecked(input));
|
|
19470
|
+
}
|
|
19471
|
+
async reownUnchecked(input) {
|
|
18378
19472
|
const { fromOwnerKind, fromOwnerId, toOwnerKind, toOwnerId, key } = input;
|
|
18379
19473
|
const row = (await this.store.query.query({
|
|
18380
19474
|
collection: MEDIA_COLLECTION,
|
|
@@ -18408,7 +19502,7 @@ var MediaStore = class {
|
|
|
18408
19502
|
relativePath: oldPath
|
|
18409
19503
|
})
|
|
18410
19504
|
};
|
|
18411
|
-
const newKey = await this.
|
|
19505
|
+
const newKey = await this.putUnchecked(newParams);
|
|
18412
19506
|
try {
|
|
18413
19507
|
await this.storage.delete({
|
|
18414
19508
|
location: mediaRowLocation(meta),
|
|
@@ -18462,8 +19556,10 @@ var MediaStore = class {
|
|
|
18462
19556
|
* batch.
|
|
18463
19557
|
*/
|
|
18464
19558
|
async deleteByTracks(trackIds) {
|
|
18465
|
-
await this.
|
|
18466
|
-
|
|
19559
|
+
await this.gated(async () => {
|
|
19560
|
+
await this.deleteForOwnerUnchecked("track", trackIds);
|
|
19561
|
+
await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
19562
|
+
});
|
|
18467
19563
|
await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
|
|
18468
19564
|
}
|
|
18469
19565
|
/**
|
|
@@ -18505,7 +19601,7 @@ function snapshot(j) {
|
|
|
18505
19601
|
fromLocationId: j.fromLocationId,
|
|
18506
19602
|
toLocationId: j.toLocationId,
|
|
18507
19603
|
deviceId: j.deviceId,
|
|
18508
|
-
entities: ["media"],
|
|
19604
|
+
entities: ["media", "retrainFrames"],
|
|
18509
19605
|
filesMoved: j.filesMoved,
|
|
18510
19606
|
bytesMoved: j.bytesMoved,
|
|
18511
19607
|
filesTotal: null,
|
|
@@ -18536,7 +19632,7 @@ var MediaRelocateEngine = class {
|
|
|
18536
19632
|
state: "running",
|
|
18537
19633
|
fromLocationId: "*",
|
|
18538
19634
|
toLocationId: input.toLocationId,
|
|
18539
|
-
deviceId:
|
|
19635
|
+
deviceId: null,
|
|
18540
19636
|
filesMoved: 0,
|
|
18541
19637
|
bytesMoved: 0,
|
|
18542
19638
|
startedAt: this.deps.now(),
|
|
@@ -18553,24 +19649,25 @@ var MediaRelocateEngine = class {
|
|
|
18553
19649
|
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
18554
19650
|
try {
|
|
18555
19651
|
await this.deps.resolveTargetRoot(job.toLocationId);
|
|
19652
|
+
let failedRows = 0;
|
|
18556
19653
|
let cursor = 0;
|
|
18557
|
-
let
|
|
19654
|
+
let offsetAtCursor = 0;
|
|
18558
19655
|
for (;;) {
|
|
18559
19656
|
if (job.cancelRequested) break;
|
|
18560
|
-
const
|
|
19657
|
+
const rows = await this.deps.store.query.query({
|
|
18561
19658
|
collection: MEDIA_COLLECTION,
|
|
18562
19659
|
filter: {
|
|
18563
|
-
...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
|
|
18564
19660
|
whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
18565
19661
|
orderBy: {
|
|
18566
19662
|
field: "timestamp",
|
|
18567
19663
|
direction: "asc"
|
|
18568
19664
|
},
|
|
18569
|
-
limit: PAGE_SIZE
|
|
19665
|
+
limit: PAGE_SIZE,
|
|
19666
|
+
offset: offsetAtCursor
|
|
18570
19667
|
}
|
|
18571
|
-
})
|
|
18572
|
-
if (
|
|
18573
|
-
for (const row of
|
|
19668
|
+
});
|
|
19669
|
+
if (rows.length === 0) break;
|
|
19670
|
+
for (const row of rows) {
|
|
18574
19671
|
if (job.cancelRequested) break;
|
|
18575
19672
|
const data = row.data;
|
|
18576
19673
|
const from = mediaRowLocation(data);
|
|
@@ -18578,6 +19675,17 @@ var MediaRelocateEngine = class {
|
|
|
18578
19675
|
const relativePath = String(data["path"] ?? "");
|
|
18579
19676
|
if (relativePath.length === 0) continue;
|
|
18580
19677
|
try {
|
|
19678
|
+
if (await this.samePhysicalLocation(from, job.toLocationId)) {
|
|
19679
|
+
await this.deps.store.set.mutate({
|
|
19680
|
+
collection: MEDIA_COLLECTION,
|
|
19681
|
+
key: row.id,
|
|
19682
|
+
value: {
|
|
19683
|
+
...data,
|
|
19684
|
+
locationId: job.toLocationId
|
|
19685
|
+
}
|
|
19686
|
+
});
|
|
19687
|
+
continue;
|
|
19688
|
+
}
|
|
18581
19689
|
const bytes = await this.deps.storage.read({
|
|
18582
19690
|
location: from,
|
|
18583
19691
|
relativePath
|
|
@@ -18587,6 +19695,10 @@ var MediaRelocateEngine = class {
|
|
|
18587
19695
|
relativePath,
|
|
18588
19696
|
data: bytes
|
|
18589
19697
|
});
|
|
19698
|
+
if ((await this.deps.storage.read({
|
|
19699
|
+
location: job.toLocationId,
|
|
19700
|
+
relativePath
|
|
19701
|
+
})).length !== bytes.length) throw new Error(`relocated media size mismatch for ${relativePath}`);
|
|
18590
19702
|
await this.deps.store.set.mutate({
|
|
18591
19703
|
collection: MEDIA_COLLECTION,
|
|
18592
19704
|
key: row.id,
|
|
@@ -18603,20 +19715,99 @@ var MediaRelocateEngine = class {
|
|
|
18603
19715
|
job.bytesMoved += bytes.length;
|
|
18604
19716
|
await sleep(bytes.length / bytesPerMs);
|
|
18605
19717
|
} catch (err) {
|
|
19718
|
+
failedRows++;
|
|
18606
19719
|
this.deps.logger.debug("media relocate row failed", { meta: {
|
|
18607
19720
|
key: row.id,
|
|
18608
19721
|
error: String(err)
|
|
18609
19722
|
} });
|
|
18610
19723
|
}
|
|
18611
19724
|
}
|
|
18612
|
-
const last =
|
|
19725
|
+
const last = rows[rows.length - 1];
|
|
18613
19726
|
const lastTs = Number(last.data["timestamp"] ?? cursor);
|
|
18614
|
-
if (lastTs === cursor)
|
|
19727
|
+
if (lastTs === cursor) offsetAtCursor += rows.length;
|
|
18615
19728
|
else {
|
|
18616
19729
|
cursor = lastTs;
|
|
18617
|
-
|
|
19730
|
+
offsetAtCursor = rows.filter((row) => Number(row.data["timestamp"]) === lastTs).length;
|
|
18618
19731
|
}
|
|
19732
|
+
if (rows.length < PAGE_SIZE) break;
|
|
18619
19733
|
}
|
|
19734
|
+
let retrainOffset = 0;
|
|
19735
|
+
for (;;) {
|
|
19736
|
+
if (job.cancelRequested) break;
|
|
19737
|
+
const rows = await this.deps.store.query.query({
|
|
19738
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
19739
|
+
filter: {
|
|
19740
|
+
orderBy: {
|
|
19741
|
+
field: "copiedAt",
|
|
19742
|
+
direction: "asc"
|
|
19743
|
+
},
|
|
19744
|
+
limit: PAGE_SIZE,
|
|
19745
|
+
offset: retrainOffset
|
|
19746
|
+
}
|
|
19747
|
+
});
|
|
19748
|
+
if (rows.length === 0) break;
|
|
19749
|
+
for (const row of rows) {
|
|
19750
|
+
if (job.cancelRequested) break;
|
|
19751
|
+
const data = row.data;
|
|
19752
|
+
const from = mediaRowLocation(data);
|
|
19753
|
+
if (from === job.toLocationId) continue;
|
|
19754
|
+
const relativePath = String(data["path"] ?? "");
|
|
19755
|
+
if (relativePath.length === 0) {
|
|
19756
|
+
failedRows++;
|
|
19757
|
+
continue;
|
|
19758
|
+
}
|
|
19759
|
+
try {
|
|
19760
|
+
if (await this.samePhysicalLocation(from, job.toLocationId)) {
|
|
19761
|
+
await this.deps.store.set.mutate({
|
|
19762
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
19763
|
+
key: row.id,
|
|
19764
|
+
value: {
|
|
19765
|
+
...data,
|
|
19766
|
+
locationId: job.toLocationId
|
|
19767
|
+
}
|
|
19768
|
+
});
|
|
19769
|
+
continue;
|
|
19770
|
+
}
|
|
19771
|
+
const bytes = await this.deps.storage.read({
|
|
19772
|
+
location: from,
|
|
19773
|
+
relativePath
|
|
19774
|
+
});
|
|
19775
|
+
await this.deps.storage.write({
|
|
19776
|
+
location: job.toLocationId,
|
|
19777
|
+
relativePath,
|
|
19778
|
+
data: bytes
|
|
19779
|
+
});
|
|
19780
|
+
if ((await this.deps.storage.read({
|
|
19781
|
+
location: job.toLocationId,
|
|
19782
|
+
relativePath
|
|
19783
|
+
})).length !== bytes.length) throw new Error(`relocated retrain frame size mismatch for ${relativePath}`);
|
|
19784
|
+
await this.deps.store.set.mutate({
|
|
19785
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
19786
|
+
key: row.id,
|
|
19787
|
+
value: {
|
|
19788
|
+
...data,
|
|
19789
|
+
locationId: job.toLocationId
|
|
19790
|
+
}
|
|
19791
|
+
});
|
|
19792
|
+
await this.deps.storage.delete({
|
|
19793
|
+
location: from,
|
|
19794
|
+
relativePath
|
|
19795
|
+
});
|
|
19796
|
+
job.filesMoved++;
|
|
19797
|
+
job.bytesMoved += bytes.length;
|
|
19798
|
+
await sleep(bytes.length / bytesPerMs);
|
|
19799
|
+
} catch (err) {
|
|
19800
|
+
failedRows++;
|
|
19801
|
+
this.deps.logger.debug("retrain frame relocate row failed", { meta: {
|
|
19802
|
+
key: row.id,
|
|
19803
|
+
error: String(err)
|
|
19804
|
+
} });
|
|
19805
|
+
}
|
|
19806
|
+
}
|
|
19807
|
+
retrainOffset += rows.length;
|
|
19808
|
+
if (rows.length < PAGE_SIZE) break;
|
|
19809
|
+
}
|
|
19810
|
+
if (failedRows > 0) throw new Error(`event-media relocation left ${failedRows} row(s) unmoved`);
|
|
18620
19811
|
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
18621
19812
|
} catch (err) {
|
|
18622
19813
|
job.state = "failed";
|
|
@@ -18630,6 +19821,18 @@ var MediaRelocateEngine = class {
|
|
|
18630
19821
|
this.deps.onFinished?.(snapshot(job));
|
|
18631
19822
|
}
|
|
18632
19823
|
}
|
|
19824
|
+
async samePhysicalLocation(from, to) {
|
|
19825
|
+
if (from === to) return true;
|
|
19826
|
+
try {
|
|
19827
|
+
const [fromRoot, toRoot] = await Promise.all([this.deps.resolveTargetRoot(from), this.deps.resolveTargetRoot(to)]);
|
|
19828
|
+
const [fromStat, toStat] = await Promise.all([node_fs.promises.stat(fromRoot), node_fs.promises.stat(toRoot)]);
|
|
19829
|
+
if (fromStat.dev === toStat.dev && fromStat.ino === toStat.ino) return true;
|
|
19830
|
+
const [fromReal, toReal] = await Promise.all([node_fs.promises.realpath(node_path.default.resolve(fromRoot)), node_fs.promises.realpath(node_path.default.resolve(toRoot))]);
|
|
19831
|
+
return fromReal === toReal;
|
|
19832
|
+
} catch {
|
|
19833
|
+
return false;
|
|
19834
|
+
}
|
|
19835
|
+
}
|
|
18633
19836
|
};
|
|
18634
19837
|
//#endregion
|
|
18635
19838
|
//#region src/pipeline-analytics/media-settings.ts
|
|
@@ -31182,6 +32385,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31182
32385
|
eventsOpsLog = null;
|
|
31183
32386
|
/** Event-media relocation engine (entity-routing Phase 4). */
|
|
31184
32387
|
mediaRelocate = null;
|
|
32388
|
+
mediaLocationStorage = null;
|
|
32389
|
+
mediaWriteGate = new MediaWriteGate();
|
|
32390
|
+
storageMigrationLeaseId = null;
|
|
31185
32391
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
31186
32392
|
sensorEventStore = null;
|
|
31187
32393
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
@@ -31869,7 +33075,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31869
33075
|
listCallerTargetIds: async (userId) => {
|
|
31870
33076
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
31871
33077
|
},
|
|
31872
|
-
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
|
+
}
|
|
31873
33084
|
});
|
|
31874
33085
|
}
|
|
31875
33086
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
@@ -31982,7 +33193,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31982
33193
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
31983
33194
|
}
|
|
31984
33195
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
31985
|
-
|
|
33196
|
+
const routed = createLocationAwareMediaStorage({
|
|
31986
33197
|
base: storage,
|
|
31987
33198
|
defaultLocation: "eventMedia",
|
|
31988
33199
|
resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
|
|
@@ -31990,6 +33201,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31990
33201
|
relativePath: ""
|
|
31991
33202
|
})
|
|
31992
33203
|
});
|
|
33204
|
+
this.mediaLocationStorage = routed;
|
|
33205
|
+
return routed;
|
|
31993
33206
|
}
|
|
31994
33207
|
/** Constructs every SQLite-backed store plus the stationary/package-drop/
|
|
31995
33208
|
* sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
|
|
@@ -32034,7 +33247,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
32034
33247
|
const mediaStore = new MediaStore({
|
|
32035
33248
|
storage,
|
|
32036
33249
|
store: api.settingsStore,
|
|
32037
|
-
logger: logger.child("MediaStore")
|
|
33250
|
+
logger: logger.child("MediaStore"),
|
|
33251
|
+
writeGate: this.mediaWriteGate
|
|
32038
33252
|
});
|
|
32039
33253
|
this.mediaStore = mediaStore;
|
|
32040
33254
|
const retrainFrames = new RetrainFrameStore({
|
|
@@ -32042,6 +33256,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
32042
33256
|
storage,
|
|
32043
33257
|
logger: logger.child("RetrainFrames"),
|
|
32044
33258
|
now: () => Date.now(),
|
|
33259
|
+
writeGate: this.mediaWriteGate,
|
|
32045
33260
|
readSource: async (mediaKey) => {
|
|
32046
33261
|
const file = await mediaStore.getByKey(mediaKey);
|
|
32047
33262
|
return file === null ? null : Buffer.from(file.base64, "base64");
|
|
@@ -32478,12 +33693,91 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
32478
33693
|
limit,
|
|
32479
33694
|
projection: "slim"
|
|
32480
33695
|
})).tracks;
|
|
32481
|
-
}
|
|
33696
|
+
},
|
|
33697
|
+
...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
|
|
32482
33698
|
});
|
|
32483
33699
|
this.wireAlarmPanel(this.notificationCenter);
|
|
32484
33700
|
await this.serveNcActionPlane(this.notificationCenter);
|
|
32485
33701
|
await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
|
|
32486
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
|
+
}
|
|
32487
33781
|
/** Event-media data-plane: serve thumbnail JPEGs at
|
|
32488
33782
|
* `/addon/<addonId>/event-media/<eventId>` through the hub's port.
|
|
32489
33783
|
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
@@ -36283,14 +37577,38 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
36283
37577
|
return this.queryFacade.deleteDeviceEvents(input);
|
|
36284
37578
|
}
|
|
36285
37579
|
/** The events ops-log rows (newest-first), optionally scoped to one camera. */
|
|
36286
|
-
async
|
|
37580
|
+
async pauseForStorageMigration(input) {
|
|
37581
|
+
if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media writes are already paused by another storage migration");
|
|
37582
|
+
this.storageMigrationLeaseId = input.leaseId;
|
|
37583
|
+
try {
|
|
37584
|
+
await this.mediaWriteGate.pause();
|
|
37585
|
+
return { paused: true };
|
|
37586
|
+
} catch (err) {
|
|
37587
|
+
this.storageMigrationLeaseId = null;
|
|
37588
|
+
throw err;
|
|
37589
|
+
}
|
|
37590
|
+
}
|
|
37591
|
+
async resumeForStorageMigration(input) {
|
|
37592
|
+
if (this.storageMigrationLeaseId === null) return { resumed: true };
|
|
37593
|
+
if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
|
|
37594
|
+
this.mediaWriteGate.resume();
|
|
37595
|
+
this.storageMigrationLeaseId = null;
|
|
37596
|
+
return { resumed: true };
|
|
37597
|
+
}
|
|
37598
|
+
async refreshStorageLocationsForMigration(input) {
|
|
37599
|
+
if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
|
|
37600
|
+
this.mediaLocationStorage?.invalidateRoots();
|
|
37601
|
+
return { refreshed: true };
|
|
37602
|
+
}
|
|
37603
|
+
async startStorageMigrationMove(input) {
|
|
36287
37604
|
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
37605
|
+
if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
|
|
36288
37606
|
return { jobId: this.mediaRelocate.start(input) };
|
|
36289
37607
|
}
|
|
36290
|
-
async
|
|
36291
|
-
return this.mediaRelocate?.list() ??
|
|
37608
|
+
async getStorageMigrationMoveStatus(input) {
|
|
37609
|
+
return this.mediaRelocate?.list().find((job) => job.jobId === input.jobId) ?? null;
|
|
36292
37610
|
}
|
|
36293
|
-
async
|
|
37611
|
+
async cancelStorageMigrationMove(input) {
|
|
36294
37612
|
return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
|
|
36295
37613
|
}
|
|
36296
37614
|
async listOpsLog(input) {
|