@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as isDeviceScopedCap, A as customAction, B as pipelineAnalyticsCapability, C as TimelapseRuleSchema, D as audioMetricsCapability, E as alarmPanelCapability, F as faceGalleryCapability, G as vectorDimFromBase64, H as readDeviceStateFrom, J as errMsg, K as videoclipsCapability, L as isScheduleActive, M as deriveRecordingMode, O as buildEventKindDescriptor, P as encodeVectorBase64, Q as hydrateSchema, R as kebabToCamel, S as TimelapseRulePatchSchema, T as addonWidgetsSourceCapability, U as readTimelapseGeneratedAt, V as plateGalleryCapability, W as subKindsOf, X as DeviceType, Y as BaseAddon, Z as createEvent, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as literal, b as RetrainStatusSchema, c as NC_CONDITION_CATALOG, ct as record, d as NcRuleInputSchema, dt as EventCategory, et as nodePin, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as boolean, j as defineCustomActions, k as cosineSimilarity, l as NC_TAXONOMY, lt as string, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as _enum, o as LabelAttributionSchema, ot as number, p as NcRuleSchema, q as zoneAnalyticsCapability, r as DeclaredDevices, rt as array, s as MACRO_LABELS, st as object, t as BaseDevice, tt as sleep, u as NcConditionDescriptorSchema, ut as unknown, v as OpsLogEntrySchema, w as TrackSourceSchema, x as TimelapseRuleInputSchema, y as RECORDING_EXPORT_MAX_READ_BYTES, z as notificationRulesCapability } from "../dist-4dhFDogy.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -1114,6 +1114,25 @@ var NcArtifactPlane = class {
|
|
|
1114
1114
|
return null;
|
|
1115
1115
|
}
|
|
1116
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Absolutize a path against the SAME base URL an artifact link would use.
|
|
1119
|
+
*
|
|
1120
|
+
* For links this plane does not host but a RECIPIENT still has to open — a
|
|
1121
|
+
* recorder export download, served `access: 'authenticated'`. It is not
|
|
1122
|
+
* signed and it is not public: it is a tap-through the person opens where
|
|
1123
|
+
* they already hold a session, which is why it goes on `clickUrl` and never
|
|
1124
|
+
* on an attachment.
|
|
1125
|
+
*
|
|
1126
|
+
* Reuses `resolveBaseUrl` rather than re-deriving one, because "which
|
|
1127
|
+
* address can the recipient actually reach" is a question this repo has
|
|
1128
|
+
* already answered wrongly once (the first artifact link went out on the LAN
|
|
1129
|
+
* address while a tunnel was connected).
|
|
1130
|
+
*/
|
|
1131
|
+
async resolveExternalUrl(pathname) {
|
|
1132
|
+
const baseUrl = await this.resolveBaseUrl().catch(() => null);
|
|
1133
|
+
if (baseUrl === null) return null;
|
|
1134
|
+
return `${baseUrl.replace(/\/+$/, "")}/${pathname.replace(/^\/+/, "")}`;
|
|
1135
|
+
}
|
|
1117
1136
|
/** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
|
|
1118
1137
|
handler = async (req, res) => {
|
|
1119
1138
|
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
@@ -2728,7 +2747,7 @@ var NcDispatcher = class {
|
|
|
2728
2747
|
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
2729
2748
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? systemEvent?.title ?? entry.payload.ruleName;
|
|
2730
2749
|
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
2731
|
-
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
2750
|
+
const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
|
|
2732
2751
|
const params = pickParams(entry.payload.params);
|
|
2733
2752
|
const actions = await this.mintActions(entry, target);
|
|
2734
2753
|
return {
|
|
@@ -2996,6 +3015,33 @@ function occupancyOpWord(occupied) {
|
|
|
2996
3015
|
return occupied ? "occupied" : "free";
|
|
2997
3016
|
}
|
|
2998
3017
|
/** `{{var}}` interpolation; missing vars render empty. Null template → null. */
|
|
3018
|
+
/**
|
|
3019
|
+
* The attachment a row was CREATED with, if any.
|
|
3020
|
+
*
|
|
3021
|
+
* Url-only by construction: the bytes were never in this process (a timelapse
|
|
3022
|
+
* is rendered by the recorder and republished on the artifact plane), and a
|
|
3023
|
+
* durable outbox row must never carry an mp4. The degrade engine turns this
|
|
3024
|
+
* into an upload for a bytes-only backend and a plain link for a url-mode one
|
|
3025
|
+
* — which is the whole reason a url-mode target now receives the video at all.
|
|
3026
|
+
*/
|
|
3027
|
+
function frozenArtifactAttachments(entry) {
|
|
3028
|
+
const artifact = entry.payload.artifact;
|
|
3029
|
+
if (artifact === void 0) return [];
|
|
3030
|
+
return [{
|
|
3031
|
+
mediaType: artifact.mediaType,
|
|
3032
|
+
url: artifact.url,
|
|
3033
|
+
mime: artifact.mime,
|
|
3034
|
+
name: artifact.name
|
|
3035
|
+
}];
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* `{{var}}` substitution over a frozen variable map. An unknown name renders
|
|
3039
|
+
* EMPTY rather than leaving the braces on screen.
|
|
3040
|
+
*
|
|
3041
|
+
* Exported so the timelapse producer renders its title/body with the SAME
|
|
3042
|
+
* vocabulary and the same missing-var rule. A second `{{}}` renderer in this
|
|
3043
|
+
* package would be free to disagree about exactly that.
|
|
3044
|
+
*/
|
|
2999
3045
|
function renderTemplate(template, vars) {
|
|
3000
3046
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
3001
3047
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
@@ -5417,6 +5463,7 @@ var TimelapseStore = class {
|
|
|
5417
5463
|
id: existing.id,
|
|
5418
5464
|
ownerUserId: existing.ownerUserId,
|
|
5419
5465
|
lastGeneratedAt: existing.lastGeneratedAt,
|
|
5466
|
+
generatedByDevice: existing.generatedByDevice,
|
|
5420
5467
|
createdBy: existing.createdBy,
|
|
5421
5468
|
createdAt: existing.createdAt,
|
|
5422
5469
|
updatedAt: this.now()
|
|
@@ -5427,16 +5474,39 @@ var TimelapseStore = class {
|
|
|
5427
5474
|
return this.update(ruleId, { enabled });
|
|
5428
5475
|
}
|
|
5429
5476
|
/**
|
|
5430
|
-
* Record a successful generation. `at` is the
|
|
5431
|
-
* durable state behind the
|
|
5432
|
-
* `updatedAt` (generation is not an edit of the rule
|
|
5477
|
+
* Record a successful generation for ONE camera of the rule. `at` is the
|
|
5478
|
+
* generation epoch-ms — the durable state behind the re-generation guard.
|
|
5479
|
+
* Does NOT bump `updatedAt` (a generation is not an edit of the rule).
|
|
5480
|
+
*
|
|
5481
|
+
* PER-DEVICE, and that is the whole point: a rule covering three cameras
|
|
5482
|
+
* renders three videos, and stamping the rule when the first one succeeds
|
|
5483
|
+
* tells the other two they are already done. Their night is then gone for
|
|
5484
|
+
* good — a closed window does not come back. Call this ONLY after the export
|
|
5485
|
+
* reached `ready` and the notification was enqueued; a failed render must
|
|
5486
|
+
* leave the pair unstamped so the next tick can retry it.
|
|
5487
|
+
*
|
|
5488
|
+
* The rule-wide `lastGeneratedAt` is kept as the NEWEST of the stamps (what
|
|
5489
|
+
* a UI shows) and never regresses when an older device generation lands.
|
|
5490
|
+
*
|
|
5491
|
+
* A rule that still carries only the LEGACY rule-wide stamp has its map
|
|
5492
|
+
* SEEDED from it for every camera the rule covers, before the new stamp is
|
|
5493
|
+
* applied — writing `{ [deviceId]: at }` alone would drop the other cameras
|
|
5494
|
+
* to "never generated" and re-render them once.
|
|
5433
5495
|
*/
|
|
5434
|
-
async markGenerated(ruleId, at) {
|
|
5496
|
+
async markGenerated(ruleId, deviceId, at) {
|
|
5435
5497
|
const existing = this.byId.get(ruleId);
|
|
5436
5498
|
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
5499
|
+
const seeded = {};
|
|
5500
|
+
if (existing.generatedByDevice === void 0 && existing.lastGeneratedAt !== void 0) for (const id of existing.deviceIds) seeded[String(id)] = existing.lastGeneratedAt;
|
|
5501
|
+
const generatedByDevice = {
|
|
5502
|
+
...seeded,
|
|
5503
|
+
...existing.generatedByDevice,
|
|
5504
|
+
[String(deviceId)]: at
|
|
5505
|
+
};
|
|
5437
5506
|
return this.write({
|
|
5438
5507
|
...existing,
|
|
5439
|
-
|
|
5508
|
+
generatedByDevice,
|
|
5509
|
+
lastGeneratedAt: Math.max(existing.lastGeneratedAt ?? 0, at)
|
|
5440
5510
|
});
|
|
5441
5511
|
}
|
|
5442
5512
|
/** Idempotent delete — unknown ids are a no-op. */
|
|
@@ -5481,6 +5551,656 @@ var TimelapseStore = class {
|
|
|
5481
5551
|
}
|
|
5482
5552
|
};
|
|
5483
5553
|
//#endregion
|
|
5554
|
+
//#region src/notification-center/timelapse/dense-ranges.ts
|
|
5555
|
+
/**
|
|
5556
|
+
* The POST-HOC timelapse's render plan: which seconds of the closed window
|
|
5557
|
+
* deserve the fast cadence.
|
|
5558
|
+
*
|
|
5559
|
+
* Pure — no clock, no I/O, no store. The scheduler hands over the tracks the
|
|
5560
|
+
* window actually contained (`pipelineAnalytics.listTracks`, which carry
|
|
5561
|
+
* `firstSeen`/`lastSeen`) and gets back ranges expressed as SECONDS FROM THE
|
|
5562
|
+
* WINDOW START, which is exactly the coordinate system
|
|
5563
|
+
* `ExportTimelapseSchema.dense.ranges` speaks.
|
|
5564
|
+
*
|
|
5565
|
+
* Why post-hoc and not a live sampler: the footage already exists, the tracks
|
|
5566
|
+
* already exist, and the recorder already knows how to render a range. A live
|
|
5567
|
+
* sampler would hold a frame ring open for eight hours per camera to
|
|
5568
|
+
* reconstruct information the database already has.
|
|
5569
|
+
*
|
|
5570
|
+
* The coalescing rule is `2 × base cadence`: two tracks closer together than
|
|
5571
|
+
* two base frames would render as one continuous busy stretch anyway, so
|
|
5572
|
+
* splitting them buys nothing and costs a range out of the budget.
|
|
5573
|
+
*/
|
|
5574
|
+
/** Re-exported so a caller plans and validates against the SAME ceiling the
|
|
5575
|
+
* cap enforces — two constants would be free to drift, and the drift would
|
|
5576
|
+
* surface as a rejected export at render time, hours after the window. */
|
|
5577
|
+
var DENSE_MAX_RANGES = 200;
|
|
5578
|
+
/** Clamp a track to the window and pad it, dropping what falls outside. */
|
|
5579
|
+
function clampTrack(track, windowStartMs, windowEndMs, padMs) {
|
|
5580
|
+
const rawFrom = Math.min(track.firstSeen, track.lastSeen);
|
|
5581
|
+
const rawTo = Math.max(track.firstSeen, track.lastSeen);
|
|
5582
|
+
if (rawTo < windowStartMs || rawFrom > windowEndMs) return null;
|
|
5583
|
+
const fromMs = Math.max(windowStartMs, rawFrom - padMs);
|
|
5584
|
+
const toMs = Math.min(windowEndMs, rawTo + padMs);
|
|
5585
|
+
return toMs > fromMs ? {
|
|
5586
|
+
fromMs,
|
|
5587
|
+
toMs
|
|
5588
|
+
} : null;
|
|
5589
|
+
}
|
|
5590
|
+
/**
|
|
5591
|
+
* Merge the window's tracks into dense ranges.
|
|
5592
|
+
*
|
|
5593
|
+
* @returns ranges in seconds from `windowStartMs`, already coalesced, sorted
|
|
5594
|
+
* and clamped — or an empty capped plan.
|
|
5595
|
+
*/
|
|
5596
|
+
function planDenseRanges(input) {
|
|
5597
|
+
const maxRanges = Math.max(1, input.maxRanges ?? DENSE_MAX_RANGES);
|
|
5598
|
+
const padMs = Math.max(0, input.padSec) * 1e3;
|
|
5599
|
+
const gapMs = Math.max(1, input.baseCadenceSec) * 2e3;
|
|
5600
|
+
const clamped = [];
|
|
5601
|
+
for (const track of input.tracks) {
|
|
5602
|
+
const r = clampTrack(track, input.windowStartMs, input.windowEndMs, padMs);
|
|
5603
|
+
if (r !== null) clamped.push(r);
|
|
5604
|
+
}
|
|
5605
|
+
clamped.sort((a, b) => a.fromMs - b.fromMs);
|
|
5606
|
+
const merged = [];
|
|
5607
|
+
for (const r of clamped) {
|
|
5608
|
+
const last = merged[merged.length - 1];
|
|
5609
|
+
if (last !== void 0 && r.fromMs - last.toMs < gapMs) {
|
|
5610
|
+
merged[merged.length - 1] = {
|
|
5611
|
+
fromMs: last.fromMs,
|
|
5612
|
+
toMs: Math.max(last.toMs, r.toMs)
|
|
5613
|
+
};
|
|
5614
|
+
continue;
|
|
5615
|
+
}
|
|
5616
|
+
merged.push(r);
|
|
5617
|
+
}
|
|
5618
|
+
if (merged.length > maxRanges) return {
|
|
5619
|
+
ranges: [],
|
|
5620
|
+
capped: true,
|
|
5621
|
+
mergedCount: merged.length,
|
|
5622
|
+
denseSec: 0
|
|
5623
|
+
};
|
|
5624
|
+
const ranges = merged.map((r) => ({
|
|
5625
|
+
fromSec: (r.fromMs - input.windowStartMs) / 1e3,
|
|
5626
|
+
toSec: (r.toMs - input.windowStartMs) / 1e3
|
|
5627
|
+
}));
|
|
5628
|
+
const denseSec = ranges.reduce((sum, r) => sum + (r.toSec - r.fromSec), 0);
|
|
5629
|
+
return {
|
|
5630
|
+
ranges,
|
|
5631
|
+
capped: false,
|
|
5632
|
+
mergedCount: merged.length,
|
|
5633
|
+
denseSec
|
|
5634
|
+
};
|
|
5635
|
+
}
|
|
5636
|
+
//#endregion
|
|
5637
|
+
//#region src/notification-center/timelapse/footage-coverage.ts
|
|
5638
|
+
/**
|
|
5639
|
+
* Slack at the window edges before a window counts as incomplete.
|
|
5640
|
+
*
|
|
5641
|
+
* Recorded footage is segment-granular, so the last segment almost never lands
|
|
5642
|
+
* exactly on the boundary. Without slack every complete night reports "99% of
|
|
5643
|
+
* the window" — noise the operator learns to skip, which is how the genuinely
|
|
5644
|
+
* short video gets skipped too.
|
|
5645
|
+
*/
|
|
5646
|
+
var COVERAGE_COMPLETE_SLACK_MS = 3e4;
|
|
5647
|
+
/** Merge + clamp + sum. */
|
|
5648
|
+
function footageCoverage(ranges, windowStartMs, windowEndMs) {
|
|
5649
|
+
const windowMs = Math.max(0, windowEndMs - windowStartMs);
|
|
5650
|
+
if (windowMs === 0) return {
|
|
5651
|
+
fraction: 0,
|
|
5652
|
+
percent: 0,
|
|
5653
|
+
coveredMs: 0,
|
|
5654
|
+
windowMs: 0,
|
|
5655
|
+
complete: false
|
|
5656
|
+
};
|
|
5657
|
+
const clamped = ranges.map((r) => ({
|
|
5658
|
+
startMs: Math.max(windowStartMs, Math.min(r.startMs, r.endMs)),
|
|
5659
|
+
endMs: Math.min(windowEndMs, Math.max(r.startMs, r.endMs))
|
|
5660
|
+
})).filter((r) => r.endMs > r.startMs).sort((a, b) => a.startMs - b.startMs);
|
|
5661
|
+
let coveredMs = 0;
|
|
5662
|
+
let curStart = null;
|
|
5663
|
+
let curEnd = 0;
|
|
5664
|
+
for (const r of clamped) {
|
|
5665
|
+
if (curStart === null) {
|
|
5666
|
+
curStart = r.startMs;
|
|
5667
|
+
curEnd = r.endMs;
|
|
5668
|
+
continue;
|
|
5669
|
+
}
|
|
5670
|
+
if (r.startMs <= curEnd) {
|
|
5671
|
+
curEnd = Math.max(curEnd, r.endMs);
|
|
5672
|
+
continue;
|
|
5673
|
+
}
|
|
5674
|
+
coveredMs += curEnd - curStart;
|
|
5675
|
+
curStart = r.startMs;
|
|
5676
|
+
curEnd = r.endMs;
|
|
5677
|
+
}
|
|
5678
|
+
if (curStart !== null) coveredMs += curEnd - curStart;
|
|
5679
|
+
const fraction = coveredMs / windowMs;
|
|
5680
|
+
return {
|
|
5681
|
+
fraction,
|
|
5682
|
+
percent: Math.round(fraction * 100),
|
|
5683
|
+
coveredMs,
|
|
5684
|
+
windowMs,
|
|
5685
|
+
complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
|
|
5686
|
+
};
|
|
5687
|
+
}
|
|
5688
|
+
/** How long a scheduled timelapse's file is kept by the export engine. */
|
|
5689
|
+
var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
|
|
5690
|
+
var TimelapseRenderError = class extends Error {
|
|
5691
|
+
reason;
|
|
5692
|
+
constructor(reason, message) {
|
|
5693
|
+
super(message);
|
|
5694
|
+
this.name = "TimelapseRenderError";
|
|
5695
|
+
this.reason = reason;
|
|
5696
|
+
}
|
|
5697
|
+
};
|
|
5698
|
+
/** The profile with the most footage over the window; ties broken by name so
|
|
5699
|
+
* the same window always renders from the same source. */
|
|
5700
|
+
function pickProfile(ranges, window) {
|
|
5701
|
+
const byProfile = /* @__PURE__ */ new Map();
|
|
5702
|
+
for (const r of ranges) {
|
|
5703
|
+
const list = byProfile.get(r.profile);
|
|
5704
|
+
if (list === void 0) byProfile.set(r.profile, [r]);
|
|
5705
|
+
else list.push(r);
|
|
5706
|
+
}
|
|
5707
|
+
let best = null;
|
|
5708
|
+
for (const [profile, rows] of [...byProfile].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
5709
|
+
const coverage = footageCoverage(rows, window.startMs, window.endMs);
|
|
5710
|
+
if (best === null || coverage.coveredMs > best.coverage.coveredMs) best = {
|
|
5711
|
+
profile,
|
|
5712
|
+
coverage
|
|
5713
|
+
};
|
|
5714
|
+
}
|
|
5715
|
+
return best;
|
|
5716
|
+
}
|
|
5717
|
+
/** `HH:MM`, for the export title an operator sees in the exports list. */
|
|
5718
|
+
function clockOf$1(atMs) {
|
|
5719
|
+
const d = new Date(atMs);
|
|
5720
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
5721
|
+
}
|
|
5722
|
+
var TimelapseRenderer = class {
|
|
5723
|
+
ports;
|
|
5724
|
+
pollMs;
|
|
5725
|
+
timeoutMs;
|
|
5726
|
+
constructor(ports) {
|
|
5727
|
+
this.ports = ports;
|
|
5728
|
+
this.pollMs = ports.pollMs ?? 5e3;
|
|
5729
|
+
this.timeoutMs = ports.timeoutMs ?? 18e5;
|
|
5730
|
+
}
|
|
5731
|
+
async render(request) {
|
|
5732
|
+
const { deviceId, window } = request;
|
|
5733
|
+
const tags = { deviceId };
|
|
5734
|
+
const chosen = pickProfile(await this.ports.getAvailability({
|
|
5735
|
+
deviceId,
|
|
5736
|
+
fromMs: window.startMs,
|
|
5737
|
+
toMs: window.endMs
|
|
5738
|
+
}), window);
|
|
5739
|
+
if (chosen === null || chosen.coverage.coveredMs === 0) {
|
|
5740
|
+
this.ports.logger.warn("timelapse skipped: no footage covers the window", {
|
|
5741
|
+
tags,
|
|
5742
|
+
meta: {
|
|
5743
|
+
ruleId: request.ruleId,
|
|
5744
|
+
windowStart: window.startMs,
|
|
5745
|
+
windowEnd: window.endMs
|
|
5746
|
+
}
|
|
5747
|
+
});
|
|
5748
|
+
throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
|
|
5749
|
+
}
|
|
5750
|
+
const plan = planDenseRanges({
|
|
5751
|
+
tracks: await this.ports.listTracks({
|
|
5752
|
+
deviceId,
|
|
5753
|
+
since: window.startMs,
|
|
5754
|
+
until: window.endMs
|
|
5755
|
+
}).catch((err) => {
|
|
5756
|
+
this.ports.logger.warn("timelapse dense plan unavailable — rendering uniform", {
|
|
5757
|
+
tags,
|
|
5758
|
+
meta: {
|
|
5759
|
+
ruleId: request.ruleId,
|
|
5760
|
+
error: String(err)
|
|
5761
|
+
}
|
|
5762
|
+
});
|
|
5763
|
+
return [];
|
|
5764
|
+
}),
|
|
5765
|
+
windowStartMs: window.startMs,
|
|
5766
|
+
windowEndMs: window.endMs,
|
|
5767
|
+
padSec: 5,
|
|
5768
|
+
baseCadenceSec: request.cadenceSec
|
|
5769
|
+
});
|
|
5770
|
+
const everyMs = request.cadenceSec * 1e3;
|
|
5771
|
+
const denseEveryMs = Math.max(1e3, Math.round(everyMs / 10));
|
|
5772
|
+
const dense = plan.ranges.length > 0 && denseEveryMs < everyMs ? {
|
|
5773
|
+
everyMs: denseEveryMs,
|
|
5774
|
+
ranges: [...plan.ranges]
|
|
5775
|
+
} : void 0;
|
|
5776
|
+
if (plan.capped) this.ports.logger.info("timelapse dense plan exceeded the range ceiling — uniform cadence", {
|
|
5777
|
+
tags,
|
|
5778
|
+
meta: {
|
|
5779
|
+
ruleId: request.ruleId,
|
|
5780
|
+
mergedRanges: plan.mergedCount
|
|
5781
|
+
}
|
|
5782
|
+
});
|
|
5783
|
+
const queued = await this.ports.createExport({
|
|
5784
|
+
deviceId,
|
|
5785
|
+
profile: chosen.profile,
|
|
5786
|
+
fromMs: window.startMs,
|
|
5787
|
+
toMs: window.endMs,
|
|
5788
|
+
options: {
|
|
5789
|
+
includeAudio: false,
|
|
5790
|
+
maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
|
|
5791
|
+
deleteAfterDownload: false,
|
|
5792
|
+
title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
|
|
5793
|
+
timelapse: {
|
|
5794
|
+
everyMs,
|
|
5795
|
+
outputFps: request.framerate,
|
|
5796
|
+
...dense !== void 0 ? { dense } : {}
|
|
5797
|
+
}
|
|
5798
|
+
}
|
|
5799
|
+
});
|
|
5800
|
+
this.ports.logger.info("timelapse export queued", {
|
|
5801
|
+
tags,
|
|
5802
|
+
meta: {
|
|
5803
|
+
ruleId: request.ruleId,
|
|
5804
|
+
exportId: queued.id,
|
|
5805
|
+
profile: chosen.profile,
|
|
5806
|
+
everyMs,
|
|
5807
|
+
denseRanges: plan.ranges.length,
|
|
5808
|
+
coveragePct: chosen.coverage.percent
|
|
5809
|
+
}
|
|
5810
|
+
});
|
|
5811
|
+
const finished = await this.awaitReady(queued, tags);
|
|
5812
|
+
const artifact = await this.publish(finished.id, deviceId, tags);
|
|
5813
|
+
const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
|
|
5814
|
+
return {
|
|
5815
|
+
exportId: finished.id,
|
|
5816
|
+
profile: chosen.profile,
|
|
5817
|
+
coverage: chosen.coverage,
|
|
5818
|
+
plan,
|
|
5819
|
+
artifact,
|
|
5820
|
+
downloadUrl: downloadUrl ?? null,
|
|
5821
|
+
fileBytes: finished.fileBytes
|
|
5822
|
+
};
|
|
5823
|
+
}
|
|
5824
|
+
/** Poll to a terminal state, under a bound. */
|
|
5825
|
+
async awaitReady(queued, tags) {
|
|
5826
|
+
if (queued.state === "ready") return queued;
|
|
5827
|
+
const deadline = this.ports.now() + this.timeoutMs;
|
|
5828
|
+
let current = queued;
|
|
5829
|
+
while (this.ports.now() < deadline) {
|
|
5830
|
+
await this.ports.sleep(this.pollMs);
|
|
5831
|
+
current = await this.ports.getExport({ exportId: queued.id });
|
|
5832
|
+
if (current.state === "ready") return current;
|
|
5833
|
+
if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
|
|
5834
|
+
this.ports.logger.warn("timelapse export did not finish", {
|
|
5835
|
+
tags,
|
|
5836
|
+
meta: {
|
|
5837
|
+
exportId: queued.id,
|
|
5838
|
+
state: current.state,
|
|
5839
|
+
error: current.error
|
|
5840
|
+
}
|
|
5841
|
+
});
|
|
5842
|
+
throw new TimelapseRenderError("export-failed", current.error ?? `export ${queued.id} ended ${current.state}`);
|
|
5843
|
+
}
|
|
5844
|
+
}
|
|
5845
|
+
this.ports.logger.warn("timelapse export timed out", {
|
|
5846
|
+
tags,
|
|
5847
|
+
meta: {
|
|
5848
|
+
exportId: queued.id,
|
|
5849
|
+
timeoutMs: this.timeoutMs,
|
|
5850
|
+
lastState: current.state
|
|
5851
|
+
}
|
|
5852
|
+
});
|
|
5853
|
+
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
|
|
5854
|
+
}
|
|
5855
|
+
/**
|
|
5856
|
+
* Mint the public link, or say why there is none.
|
|
5857
|
+
*
|
|
5858
|
+
* Never throws: a video that exists and cannot be linked is a degraded
|
|
5859
|
+
* notification, not a failed window — and failing here would leave the pair
|
|
5860
|
+
* unstamped and re-render the same eight hours on the next tick.
|
|
5861
|
+
*/
|
|
5862
|
+
async publish(exportId, deviceId, tags) {
|
|
5863
|
+
const publish = this.ports.publishArtifact;
|
|
5864
|
+
if (publish === void 0) {
|
|
5865
|
+
this.ports.logger.info("timelapse artifact publishing is not wired — link-less delivery", {
|
|
5866
|
+
tags,
|
|
5867
|
+
meta: { exportId }
|
|
5868
|
+
});
|
|
5869
|
+
return null;
|
|
5870
|
+
}
|
|
5871
|
+
try {
|
|
5872
|
+
const ref = await publish({
|
|
5873
|
+
exportId,
|
|
5874
|
+
deviceId
|
|
5875
|
+
});
|
|
5876
|
+
if (ref === null) this.ports.logger.warn("timelapse artifact could not be published — link-less delivery", {
|
|
5877
|
+
tags,
|
|
5878
|
+
meta: { exportId }
|
|
5879
|
+
});
|
|
5880
|
+
return ref;
|
|
5881
|
+
} catch (err) {
|
|
5882
|
+
this.ports.logger.warn("timelapse artifact publish threw — link-less delivery", {
|
|
5883
|
+
tags,
|
|
5884
|
+
meta: {
|
|
5885
|
+
exportId,
|
|
5886
|
+
error: String(err)
|
|
5887
|
+
}
|
|
5888
|
+
});
|
|
5889
|
+
return null;
|
|
5890
|
+
}
|
|
5891
|
+
}
|
|
5892
|
+
};
|
|
5893
|
+
//#endregion
|
|
5894
|
+
//#region src/notification-center/timelapse/timelapse-delivery.ts
|
|
5895
|
+
/** `HH:MM` in the host timezone — the two numbers a body actually needs. */
|
|
5896
|
+
function clockOf(atMs) {
|
|
5897
|
+
const d = new Date(atMs);
|
|
5898
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
5899
|
+
}
|
|
5900
|
+
/** Template vocabulary. Deliberately the same NAMES the rule templates use
|
|
5901
|
+
* (`camera`, `rule`, `time`) plus the timelapse-only ones. */
|
|
5902
|
+
function templateVars(input) {
|
|
5903
|
+
return {
|
|
5904
|
+
camera: input.deviceName ?? `camera ${input.deviceId}`,
|
|
5905
|
+
rule: input.ruleName,
|
|
5906
|
+
from: clockOf(input.window.startMs),
|
|
5907
|
+
to: clockOf(input.window.endMs),
|
|
5908
|
+
time: clockOf(input.generatedAt),
|
|
5909
|
+
coverage: String(input.coverage.percent),
|
|
5910
|
+
events: String(input.denseRanges)
|
|
5911
|
+
};
|
|
5912
|
+
}
|
|
5913
|
+
/**
|
|
5914
|
+
* The body an operator reads when he did not author one.
|
|
5915
|
+
*
|
|
5916
|
+
* The COVERAGE sentence is the load-bearing part and only appears when it
|
|
5917
|
+
* carries information: a video assembled from two of the eight hours it claims
|
|
5918
|
+
* to show is visually indistinguishable from a very quiet night, and shipping
|
|
5919
|
+
* it silently is how a three-hour recorder outage looks like nothing happened.
|
|
5920
|
+
* A complete window says nothing — a line every night is a line nobody reads.
|
|
5921
|
+
*/
|
|
5922
|
+
function derivedBody(input) {
|
|
5923
|
+
const vars = templateVars(input);
|
|
5924
|
+
const parts = [`${vars.rule} — ${vars.from}→${vars.to}`];
|
|
5925
|
+
if (input.denseCapped === true) parts.push("troppi eventi per il campionamento denso: video a cadenza uniforme");
|
|
5926
|
+
else if (input.denseRanges > 0) parts.push(`${input.denseRanges} intervalli ripresi a cadenza fitta`);
|
|
5927
|
+
if (!input.coverage.complete) parts.push(`ATTENZIONE: registrazione disponibile solo per il ${vars.coverage}% della finestra`);
|
|
5928
|
+
return parts.join(" · ");
|
|
5929
|
+
}
|
|
5930
|
+
/** Per-target params with the derived `clickUrl` filled in where absent. */
|
|
5931
|
+
function paramsFor(target, clickUrl) {
|
|
5932
|
+
if (clickUrl === void 0) return target.params;
|
|
5933
|
+
if (target.params === void 0) return { clickUrl };
|
|
5934
|
+
if (typeof target.params["clickUrl"] === "string") return target.params;
|
|
5935
|
+
return {
|
|
5936
|
+
...target.params,
|
|
5937
|
+
clickUrl
|
|
5938
|
+
};
|
|
5939
|
+
}
|
|
5940
|
+
/** Stable, retry-safe outbox record id for one (device, closed window). */
|
|
5941
|
+
function timelapseRecordId(deviceId, windowEndMs) {
|
|
5942
|
+
return `timelapse:${deviceId}:${windowEndMs}`;
|
|
5943
|
+
}
|
|
5944
|
+
/**
|
|
5945
|
+
* One outbox row per (still-enabled) target.
|
|
5946
|
+
*
|
|
5947
|
+
* @returns rows ready for `NcOutbox.enqueue` — never sent from here.
|
|
5948
|
+
*/
|
|
5949
|
+
function buildTimelapseOutboxInputs(input) {
|
|
5950
|
+
const vars = templateVars(input);
|
|
5951
|
+
const optedOut = new Set(input.disabledTargetIds ?? []);
|
|
5952
|
+
const title = renderTemplate(input.template?.title, vars) ?? `Timelapse — ${vars.camera}`;
|
|
5953
|
+
const body = renderTemplate(input.template?.body, vars) ?? derivedBody(input);
|
|
5954
|
+
const recordId = timelapseRecordId(input.deviceId, input.window.endMs);
|
|
5955
|
+
return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
|
|
5956
|
+
const payload = {
|
|
5957
|
+
ruleName: input.ruleName,
|
|
5958
|
+
delivery: "timelapse",
|
|
5959
|
+
priority: input.priority,
|
|
5960
|
+
template: {
|
|
5961
|
+
title,
|
|
5962
|
+
body
|
|
5963
|
+
},
|
|
5964
|
+
media: "none",
|
|
5965
|
+
...input.artifact !== void 0 ? { artifact: {
|
|
5966
|
+
mediaType: "video",
|
|
5967
|
+
...input.artifact
|
|
5968
|
+
} } : {},
|
|
5969
|
+
...paramsFor(target, input.clickUrl) !== void 0 ? { params: paramsFor(target, input.clickUrl) } : {},
|
|
5970
|
+
subject: {
|
|
5971
|
+
deviceId: input.deviceId,
|
|
5972
|
+
className: "timelapse",
|
|
5973
|
+
zones: [],
|
|
5974
|
+
timestamp: input.window.endMs
|
|
5975
|
+
}
|
|
5976
|
+
};
|
|
5977
|
+
return {
|
|
5978
|
+
ruleId: input.ruleId,
|
|
5979
|
+
targetId: target.targetId,
|
|
5980
|
+
deviceId: input.deviceId,
|
|
5981
|
+
recordKind: "timelapse",
|
|
5982
|
+
recordId,
|
|
5983
|
+
payload
|
|
5984
|
+
};
|
|
5985
|
+
});
|
|
5986
|
+
}
|
|
5987
|
+
/**
|
|
5988
|
+
* The most recent window that had already CLOSED at `nowMs`.
|
|
5989
|
+
*
|
|
5990
|
+
* Standing INSIDE a window returns the PREVIOUS one — a scheduler that
|
|
5991
|
+
* answered "the one I am in" would render half a night and burn the guard on
|
|
5992
|
+
* it.
|
|
5993
|
+
*
|
|
5994
|
+
* @returns `null` when no close falls inside `lookbackMs`, when the schedule
|
|
5995
|
+
* never closes (always active), OR when the close was found but its OPEN
|
|
5996
|
+
* predates the lookback floor. The last case is null and not a clamped
|
|
5997
|
+
* window on purpose: reporting the scan floor as the start ships a video
|
|
5998
|
+
* that silently begins hours late.
|
|
5999
|
+
*/
|
|
6000
|
+
function findLastClosedWindow(input) {
|
|
6001
|
+
const step = Math.max(1e3, input.stepMs ?? 6e4);
|
|
6002
|
+
const from = Math.floor(input.nowMs / step) * step;
|
|
6003
|
+
const floor = input.nowMs - input.lookbackMs;
|
|
6004
|
+
const active = (t) => isScheduleActive(input.schedule, t);
|
|
6005
|
+
let endMs = null;
|
|
6006
|
+
for (let t = from; t - step >= floor; t -= step) if (!active(t) && active(t - step)) {
|
|
6007
|
+
endMs = t;
|
|
6008
|
+
break;
|
|
6009
|
+
}
|
|
6010
|
+
if (endMs === null) return null;
|
|
6011
|
+
for (let t = endMs - step; t - step >= floor; t -= step) if (!active(t - step)) return {
|
|
6012
|
+
startMs: t,
|
|
6013
|
+
endMs
|
|
6014
|
+
};
|
|
6015
|
+
return null;
|
|
6016
|
+
}
|
|
6017
|
+
var TimelapseScheduler = class {
|
|
6018
|
+
deps;
|
|
6019
|
+
timer = null;
|
|
6020
|
+
tickInFlight = false;
|
|
6021
|
+
settleMs;
|
|
6022
|
+
lookbackMs;
|
|
6023
|
+
tickMs;
|
|
6024
|
+
constructor(deps) {
|
|
6025
|
+
this.deps = deps;
|
|
6026
|
+
this.settleMs = deps.settleMs ?? 3e5;
|
|
6027
|
+
this.lookbackMs = deps.lookbackMs ?? 6912e5;
|
|
6028
|
+
this.tickMs = deps.tickMs ?? 6e4;
|
|
6029
|
+
}
|
|
6030
|
+
/**
|
|
6031
|
+
* Boot: one tick (the back-fill) and then the interval.
|
|
6032
|
+
*
|
|
6033
|
+
* The back-fill needs no special path — it IS a tick. A window that closed
|
|
6034
|
+
* while the process was down is simply a window whose pair has no stamp.
|
|
6035
|
+
*/
|
|
6036
|
+
async start() {
|
|
6037
|
+
await this.tick();
|
|
6038
|
+
this.timer = setInterval(() => {
|
|
6039
|
+
this.tick();
|
|
6040
|
+
}, this.tickMs);
|
|
6041
|
+
}
|
|
6042
|
+
stop() {
|
|
6043
|
+
if (this.timer !== null) {
|
|
6044
|
+
clearInterval(this.timer);
|
|
6045
|
+
this.timer = null;
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
6048
|
+
/** One pass over every enabled rule × camera. Single-flight. */
|
|
6049
|
+
async tick() {
|
|
6050
|
+
if (this.tickInFlight) return {
|
|
6051
|
+
produced: 0,
|
|
6052
|
+
waiting: 0,
|
|
6053
|
+
alreadyDone: 0,
|
|
6054
|
+
failed: 0,
|
|
6055
|
+
idle: 0
|
|
6056
|
+
};
|
|
6057
|
+
this.tickInFlight = true;
|
|
6058
|
+
const report = {
|
|
6059
|
+
produced: 0,
|
|
6060
|
+
waiting: 0,
|
|
6061
|
+
alreadyDone: 0,
|
|
6062
|
+
failed: 0,
|
|
6063
|
+
idle: 0
|
|
6064
|
+
};
|
|
6065
|
+
try {
|
|
6066
|
+
const now = this.deps.now();
|
|
6067
|
+
for (const rule of this.deps.store.listEnabled()) {
|
|
6068
|
+
const window = findLastClosedWindow({
|
|
6069
|
+
schedule: rule.schedule,
|
|
6070
|
+
nowMs: now,
|
|
6071
|
+
lookbackMs: this.lookbackMs
|
|
6072
|
+
});
|
|
6073
|
+
if (window === null) {
|
|
6074
|
+
report.idle += rule.deviceIds.length;
|
|
6075
|
+
continue;
|
|
6076
|
+
}
|
|
6077
|
+
if (now < window.endMs + this.settleMs) {
|
|
6078
|
+
report.waiting += rule.deviceIds.length;
|
|
6079
|
+
continue;
|
|
6080
|
+
}
|
|
6081
|
+
for (const deviceId of rule.deviceIds) {
|
|
6082
|
+
if (readTimelapseGeneratedAt(rule, deviceId) >= window.endMs) {
|
|
6083
|
+
report.alreadyDone += 1;
|
|
6084
|
+
continue;
|
|
6085
|
+
}
|
|
6086
|
+
if ((await this.produce(rule, deviceId, window, { stamp: true })).produced > 0) report.produced += 1;
|
|
6087
|
+
else report.failed += 1;
|
|
6088
|
+
}
|
|
6089
|
+
}
|
|
6090
|
+
} finally {
|
|
6091
|
+
this.tickInFlight = false;
|
|
6092
|
+
}
|
|
6093
|
+
return report;
|
|
6094
|
+
}
|
|
6095
|
+
/**
|
|
6096
|
+
* The operator's Test button: produce the last closed window for one camera
|
|
6097
|
+
* of one rule, ignoring the settle delay and the guard.
|
|
6098
|
+
*
|
|
6099
|
+
* It DELIVERS — same as `nc.injectTestEvent`, and for the same reason: a
|
|
6100
|
+
* test that stops short of the notifier does not test the thing that breaks.
|
|
6101
|
+
* It deliberately does NOT stamp: pressing Test at noon must not cancel
|
|
6102
|
+
* tonight's scheduled run.
|
|
6103
|
+
*/
|
|
6104
|
+
async runNow(input) {
|
|
6105
|
+
const rule = this.deps.store.get(input.ruleId);
|
|
6106
|
+
if (rule === null) throw new Error(`timelapse rule not found: ${input.ruleId}`);
|
|
6107
|
+
if (!rule.deviceIds.includes(input.deviceId)) throw new Error(`rule ${rule.name} does not cover camera ${input.deviceId}`);
|
|
6108
|
+
const window = findLastClosedWindow({
|
|
6109
|
+
schedule: rule.schedule,
|
|
6110
|
+
nowMs: this.deps.now(),
|
|
6111
|
+
lookbackMs: this.lookbackMs
|
|
6112
|
+
});
|
|
6113
|
+
if (window === null) return {
|
|
6114
|
+
produced: 0,
|
|
6115
|
+
window: null,
|
|
6116
|
+
error: "no schedule window has closed recently"
|
|
6117
|
+
};
|
|
6118
|
+
return this.produce(rule, input.deviceId, window, { stamp: false });
|
|
6119
|
+
}
|
|
6120
|
+
/**
|
|
6121
|
+
* Render → enqueue → (optionally) stamp. Never throws.
|
|
6122
|
+
*
|
|
6123
|
+
* The stamp is LAST and conditional on both steps: a render that failed, or
|
|
6124
|
+
* a queue that refused the rows, must leave the pair eligible. A window does
|
|
6125
|
+
* not come back.
|
|
6126
|
+
*/
|
|
6127
|
+
async produce(rule, deviceId, window, opts) {
|
|
6128
|
+
const tags = { deviceId };
|
|
6129
|
+
try {
|
|
6130
|
+
const outcome = await this.deps.render({
|
|
6131
|
+
ruleId: rule.id,
|
|
6132
|
+
ruleName: rule.name,
|
|
6133
|
+
deviceId,
|
|
6134
|
+
window,
|
|
6135
|
+
cadenceSec: rule.cadenceSec,
|
|
6136
|
+
framerate: rule.framerate
|
|
6137
|
+
});
|
|
6138
|
+
const deviceName = await this.resolveDeviceName(deviceId);
|
|
6139
|
+
const rows = buildTimelapseOutboxInputs({
|
|
6140
|
+
ruleId: rule.id,
|
|
6141
|
+
ruleName: rule.name,
|
|
6142
|
+
deviceId,
|
|
6143
|
+
...deviceName !== null ? { deviceName } : {},
|
|
6144
|
+
window,
|
|
6145
|
+
targets: rule.targets,
|
|
6146
|
+
...this.deps.disabledTargetIds !== void 0 ? { disabledTargetIds: this.deps.disabledTargetIds(rule.id) } : {},
|
|
6147
|
+
priority: rule.priority,
|
|
6148
|
+
coverage: outcome.coverage,
|
|
6149
|
+
...outcome.artifact !== null ? { artifact: outcome.artifact } : {},
|
|
6150
|
+
...outcome.downloadUrl !== null ? { clickUrl: outcome.downloadUrl } : {},
|
|
6151
|
+
exportId: outcome.exportId,
|
|
6152
|
+
denseRanges: outcome.plan.ranges.length,
|
|
6153
|
+
...outcome.plan.capped ? { denseCapped: true } : {},
|
|
6154
|
+
generatedAt: this.deps.now(),
|
|
6155
|
+
...rule.template !== void 0 ? { template: rule.template } : {}
|
|
6156
|
+
});
|
|
6157
|
+
const enqueued = await this.deps.enqueue(rows);
|
|
6158
|
+
if (opts.stamp) await this.deps.store.markGenerated(rule.id, deviceId, this.deps.now());
|
|
6159
|
+
this.deps.logger.info("timelapse produced", {
|
|
6160
|
+
tags,
|
|
6161
|
+
meta: {
|
|
6162
|
+
ruleId: rule.id,
|
|
6163
|
+
exportId: outcome.exportId,
|
|
6164
|
+
windowStart: window.startMs,
|
|
6165
|
+
windowEnd: window.endMs,
|
|
6166
|
+
coveragePct: outcome.coverage.percent,
|
|
6167
|
+
denseRanges: outcome.plan.ranges.length,
|
|
6168
|
+
enqueued,
|
|
6169
|
+
stamped: opts.stamp,
|
|
6170
|
+
...outcome.artifact === null ? { artifact: "none" } : {}
|
|
6171
|
+
}
|
|
6172
|
+
});
|
|
6173
|
+
return {
|
|
6174
|
+
produced: 1,
|
|
6175
|
+
window,
|
|
6176
|
+
exportId: outcome.exportId,
|
|
6177
|
+
coveragePercent: outcome.coverage.percent,
|
|
6178
|
+
enqueued
|
|
6179
|
+
};
|
|
6180
|
+
} catch (err) {
|
|
6181
|
+
this.deps.logger.warn("timelapse not produced — the window stays eligible", {
|
|
6182
|
+
tags,
|
|
6183
|
+
meta: {
|
|
6184
|
+
ruleId: rule.id,
|
|
6185
|
+
windowStart: window.startMs,
|
|
6186
|
+
windowEnd: window.endMs,
|
|
6187
|
+
error: String(err)
|
|
6188
|
+
}
|
|
6189
|
+
});
|
|
6190
|
+
return {
|
|
6191
|
+
produced: 0,
|
|
6192
|
+
window,
|
|
6193
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6194
|
+
};
|
|
6195
|
+
}
|
|
6196
|
+
}
|
|
6197
|
+
async resolveDeviceName(deviceId) {
|
|
6198
|
+
const get = this.deps.getDeviceName;
|
|
6199
|
+
if (get === void 0) return null;
|
|
6200
|
+
return get(deviceId).catch(() => null);
|
|
6201
|
+
}
|
|
6202
|
+
};
|
|
6203
|
+
//#endregion
|
|
5484
6204
|
//#region src/notification-center/index.ts
|
|
5485
6205
|
/**
|
|
5486
6206
|
* NotificationCenter — the P1 core of the Notification Center (spec
|
|
@@ -5610,16 +6330,21 @@ function packagePhaseOf(ev) {
|
|
|
5610
6330
|
* never disagree. `ruleName` + `subject` come from the intent snapshot
|
|
5611
6331
|
* frozen at enqueue; `error` surfaces only on a dead row (`lastError`).
|
|
5612
6332
|
*/
|
|
6333
|
+
function historyRecordKind(kind) {
|
|
6334
|
+
if (kind === "audio-event") return "object-event";
|
|
6335
|
+
if (kind === "timelapse") return "system-event";
|
|
6336
|
+
return kind;
|
|
6337
|
+
}
|
|
5613
6338
|
function outboxEntryToHistory(entry) {
|
|
5614
6339
|
const s = entry.payload.subject;
|
|
5615
6340
|
return {
|
|
5616
6341
|
id: entry.id,
|
|
5617
6342
|
ruleId: entry.ruleId,
|
|
5618
6343
|
ruleName: entry.payload.ruleName,
|
|
5619
|
-
delivery: entry.payload.delivery,
|
|
6344
|
+
delivery: entry.payload.delivery === "timelapse" ? "system-event" : entry.payload.delivery,
|
|
5620
6345
|
targetId: entry.targetId,
|
|
5621
6346
|
deviceId: entry.deviceId,
|
|
5622
|
-
recordKind: entry.recordKind
|
|
6347
|
+
recordKind: historyRecordKind(entry.recordKind),
|
|
5623
6348
|
recordId: entry.recordId,
|
|
5624
6349
|
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {},
|
|
5625
6350
|
status: entry.status,
|
|
@@ -5662,6 +6387,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5662
6387
|
/** Grants behind the tap-through buttons. In-process and single-use — read
|
|
5663
6388
|
* `action-token.ts` before assuming anything about what a tap proves. */
|
|
5664
6389
|
actionTokens = new NcActionTokenRegistry();
|
|
6390
|
+
/**
|
|
6391
|
+
* The scheduled-timelapse rule set. Held on EVERY node so the bridge actions
|
|
6392
|
+
* can serve CRUD from wherever they are asked (same reasoning as the rule
|
|
6393
|
+
* store); only the hub, with `deps.timelapse` wired, gets a scheduler.
|
|
6394
|
+
*/
|
|
6395
|
+
timelapseRules;
|
|
6396
|
+
timelapseScheduler = null;
|
|
5665
6397
|
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
5666
6398
|
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
5667
6399
|
occupancyEnabled = false;
|
|
@@ -5851,6 +6583,47 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5851
6583
|
deliver: (entry) => this.dispatcher.deliver(entry),
|
|
5852
6584
|
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
5853
6585
|
});
|
|
6586
|
+
this.timelapseRules = new TimelapseStore({
|
|
6587
|
+
store: deps.store,
|
|
6588
|
+
logger: this.logger.child("timelapse"),
|
|
6589
|
+
...deps.now !== void 0 ? { now: deps.now } : {}
|
|
6590
|
+
});
|
|
6591
|
+
const timelapsePorts = deps.timelapse;
|
|
6592
|
+
if (timelapsePorts !== void 0) {
|
|
6593
|
+
const logger = this.logger.child("timelapse");
|
|
6594
|
+
const renderer = new TimelapseRenderer({
|
|
6595
|
+
...timelapsePorts,
|
|
6596
|
+
logger,
|
|
6597
|
+
now: this.now,
|
|
6598
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
6599
|
+
});
|
|
6600
|
+
this.timelapseScheduler = new TimelapseScheduler({
|
|
6601
|
+
logger,
|
|
6602
|
+
now: this.now,
|
|
6603
|
+
store: this.timelapseRules,
|
|
6604
|
+
render: (request) => renderer.render(request),
|
|
6605
|
+
enqueue: (rows) => this.outbox.enqueue(rows),
|
|
6606
|
+
getDeviceName: (deviceId) => deps.dispatcher.getDeviceName(deviceId)
|
|
6607
|
+
});
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
/**
|
|
6611
|
+
* The durable timelapse rule set — exposed for the `nc.*Timelapse*` bridge
|
|
6612
|
+
* actions, exactly as {@link ruleStore} is for the detection rules.
|
|
6613
|
+
*/
|
|
6614
|
+
get timelapseStore() {
|
|
6615
|
+
return this.timelapseRules;
|
|
6616
|
+
}
|
|
6617
|
+
/**
|
|
6618
|
+
* Produce the last closed window for one camera of one timelapse rule, now.
|
|
6619
|
+
*
|
|
6620
|
+
* Throws when this node hosts no producer — an honest refusal beats a
|
|
6621
|
+
* `produced: 0` an operator reads as "there was nothing to make".
|
|
6622
|
+
*/
|
|
6623
|
+
async runTimelapseNow(input) {
|
|
6624
|
+
const scheduler = this.timelapseScheduler;
|
|
6625
|
+
if (scheduler === null) throw new Error("this node does not host the timelapse producer — produce it on the hub");
|
|
6626
|
+
return scheduler.runNow(input);
|
|
5854
6627
|
}
|
|
5855
6628
|
/**
|
|
5856
6629
|
* The durable rule store — exposed so the hub-only `nc.*` bridge actions
|
|
@@ -5887,6 +6660,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5887
6660
|
await this.rules.load();
|
|
5888
6661
|
await this.snoozes.load();
|
|
5889
6662
|
await this.deviceMutes.load();
|
|
6663
|
+
await this.timelapseRules.load();
|
|
5890
6664
|
this.refreshOccupancyWatch();
|
|
5891
6665
|
if (!opts.evaluation) return;
|
|
5892
6666
|
await this.deviceStates.refresh();
|
|
@@ -5902,9 +6676,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5902
6676
|
this.reloadTimer = setInterval(() => {
|
|
5903
6677
|
this.reloadRules();
|
|
5904
6678
|
}, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
|
|
6679
|
+
if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
|
|
6680
|
+
this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
|
|
6681
|
+
});
|
|
5905
6682
|
this.logger.info("notification center started", { meta: {
|
|
5906
6683
|
rules: this.rules.list().length,
|
|
5907
|
-
pendingOutbox: this.outbox.pendingCount()
|
|
6684
|
+
pendingOutbox: this.outbox.pendingCount(),
|
|
6685
|
+
timelapseRules: this.timelapseRules.list().length,
|
|
6686
|
+
timelapseProducer: this.timelapseScheduler !== null
|
|
5908
6687
|
} });
|
|
5909
6688
|
}
|
|
5910
6689
|
async stop() {
|
|
@@ -5916,6 +6695,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5916
6695
|
clearInterval(this.reloadTimer);
|
|
5917
6696
|
this.reloadTimer = null;
|
|
5918
6697
|
}
|
|
6698
|
+
this.timelapseScheduler?.stop();
|
|
5919
6699
|
this.evaluationActive = false;
|
|
5920
6700
|
}
|
|
5921
6701
|
/**
|
|
@@ -6714,6 +7494,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6714
7494
|
* recompute the occupancy watched set + prune stale durable rows. */
|
|
6715
7495
|
async reloadRules() {
|
|
6716
7496
|
await this.rules.load();
|
|
7497
|
+
await this.timelapseRules.load();
|
|
6717
7498
|
this.refreshOccupancyWatch();
|
|
6718
7499
|
if (!this.evaluationActive) return;
|
|
6719
7500
|
await this.deviceStates.refresh();
|
|
@@ -6952,6 +7733,75 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6952
7733
|
}
|
|
6953
7734
|
};
|
|
6954
7735
|
//#endregion
|
|
7736
|
+
//#region src/notification-center/timelapse/artifact-publish.ts
|
|
7737
|
+
/**
|
|
7738
|
+
* Build the renderer's `publishArtifact` port.
|
|
7739
|
+
*
|
|
7740
|
+
* @returns a function answering the artifact ref, or `null` with a logged
|
|
7741
|
+
* reason. It never rejects.
|
|
7742
|
+
*/
|
|
7743
|
+
function buildTimelapseArtifactPublisher(ports) {
|
|
7744
|
+
return async ({ exportId, deviceId }) => {
|
|
7745
|
+
const tags = { deviceId };
|
|
7746
|
+
let read;
|
|
7747
|
+
try {
|
|
7748
|
+
read = await ports.readExportBytes({ exportId });
|
|
7749
|
+
} catch (err) {
|
|
7750
|
+
ports.logger.warn("timelapse artifact not published: export bytes unavailable", {
|
|
7751
|
+
tags,
|
|
7752
|
+
meta: {
|
|
7753
|
+
exportId,
|
|
7754
|
+
error: String(err)
|
|
7755
|
+
}
|
|
7756
|
+
});
|
|
7757
|
+
return null;
|
|
7758
|
+
}
|
|
7759
|
+
if (read.bytes > 52428800) {
|
|
7760
|
+
ports.logger.warn("timelapse artifact not published: video too large to republish", {
|
|
7761
|
+
tags,
|
|
7762
|
+
meta: {
|
|
7763
|
+
exportId,
|
|
7764
|
+
bytes: read.bytes,
|
|
7765
|
+
maxBytes: RECORDING_EXPORT_MAX_READ_BYTES
|
|
7766
|
+
}
|
|
7767
|
+
});
|
|
7768
|
+
return null;
|
|
7769
|
+
}
|
|
7770
|
+
try {
|
|
7771
|
+
const url = await ports.publish(Buffer.from(read.base64, "base64"), read.contentType);
|
|
7772
|
+
if (url === null) {
|
|
7773
|
+
ports.logger.warn("timelapse artifact not published: no reachable public endpoint", {
|
|
7774
|
+
tags,
|
|
7775
|
+
meta: { exportId }
|
|
7776
|
+
});
|
|
7777
|
+
return null;
|
|
7778
|
+
}
|
|
7779
|
+
ports.logger.info("timelapse artifact published", {
|
|
7780
|
+
tags,
|
|
7781
|
+
meta: {
|
|
7782
|
+
exportId,
|
|
7783
|
+
bytes: read.bytes,
|
|
7784
|
+
name: read.name
|
|
7785
|
+
}
|
|
7786
|
+
});
|
|
7787
|
+
return {
|
|
7788
|
+
url,
|
|
7789
|
+
mime: read.contentType,
|
|
7790
|
+
name: read.name
|
|
7791
|
+
};
|
|
7792
|
+
} catch (err) {
|
|
7793
|
+
ports.logger.warn("timelapse artifact not published: artifact plane refused the bytes", {
|
|
7794
|
+
tags,
|
|
7795
|
+
meta: {
|
|
7796
|
+
exportId,
|
|
7797
|
+
error: String(err)
|
|
7798
|
+
}
|
|
7799
|
+
});
|
|
7800
|
+
return null;
|
|
7801
|
+
}
|
|
7802
|
+
};
|
|
7803
|
+
}
|
|
7804
|
+
//#endregion
|
|
6955
7805
|
//#region src/notification-center/nc-actions.ts
|
|
6956
7806
|
/**
|
|
6957
7807
|
* nc-actions — the `pipeline-analytics` rule-bridge action catalog.
|
|
@@ -6979,6 +7829,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6979
7829
|
*/
|
|
6980
7830
|
/** A rule as served to the viewer — carries the server's `readOnly` verdict. */
|
|
6981
7831
|
var NcViewerRuleSchema = NcRuleSchema.extend({ readOnly: boolean() });
|
|
7832
|
+
/** Same verdict, for a timelapse rule. */
|
|
7833
|
+
var NcViewerTimelapseRuleSchema = TimelapseRuleSchema.extend({ readOnly: boolean() });
|
|
6982
7834
|
/**
|
|
6983
7835
|
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
6984
7836
|
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
@@ -7046,6 +7898,54 @@ var ncActions = defineCustomActions({
|
|
|
7046
7898
|
kind: "mutation",
|
|
7047
7899
|
auth: "admin",
|
|
7048
7900
|
caller: "required"
|
|
7901
|
+
}),
|
|
7902
|
+
"nc.listTimelapseRules": customAction(object({}), object({ rules: array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
|
|
7903
|
+
/**
|
|
7904
|
+
* ONE action for create AND update, unlike the detection-rule pair.
|
|
7905
|
+
*
|
|
7906
|
+
* A timelapse rule is edited as a whole in a single form (schedule +
|
|
7907
|
+
* cameras + targets), and the guards a create must pass — every camera
|
|
7908
|
+
* actually records, every target is owned — are the SAME ones a patch that
|
|
7909
|
+
* adds a camera must pass. Two entry points would be two places to forget
|
|
7910
|
+
* the recording check, and forgetting it is invisible until 06:05.
|
|
7911
|
+
*
|
|
7912
|
+
* `ruleId` absent ⇒ create from `rule`; `ruleId` present ⇒ patch.
|
|
7913
|
+
*/
|
|
7914
|
+
"nc.upsertTimelapseRule": customAction(object({
|
|
7915
|
+
ruleId: string().optional(),
|
|
7916
|
+
rule: TimelapseRuleInputSchema.optional(),
|
|
7917
|
+
patch: TimelapseRulePatchSchema.optional()
|
|
7918
|
+
}), object({ rule: TimelapseRuleSchema }), {
|
|
7919
|
+
kind: "mutation",
|
|
7920
|
+
caller: "required"
|
|
7921
|
+
}),
|
|
7922
|
+
"nc.deleteTimelapseRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
|
|
7923
|
+
kind: "mutation",
|
|
7924
|
+
caller: "required"
|
|
7925
|
+
}),
|
|
7926
|
+
/**
|
|
7927
|
+
* Produce the LAST CLOSED window for one camera of one rule, right now.
|
|
7928
|
+
*
|
|
7929
|
+
* `auth: 'admin'` for the same reason as `nc.injectTestEvent`: it DELIVERS,
|
|
7930
|
+
* to every target the rule addresses, on other people's phones. It does not
|
|
7931
|
+
* consume the schedule guard — pressing Test at noon must not cancel
|
|
7932
|
+
* tonight's run.
|
|
7933
|
+
*/
|
|
7934
|
+
"nc.testTimelapseRule": customAction(object({
|
|
7935
|
+
ruleId: string(),
|
|
7936
|
+
deviceId: number()
|
|
7937
|
+
}), object({
|
|
7938
|
+
produced: number().int(),
|
|
7939
|
+
exportId: string().optional(),
|
|
7940
|
+
coveragePercent: number().optional(),
|
|
7941
|
+
enqueued: number().int().optional(),
|
|
7942
|
+
windowStartMs: number().optional(),
|
|
7943
|
+
windowEndMs: number().optional(),
|
|
7944
|
+
error: string().optional()
|
|
7945
|
+
}), {
|
|
7946
|
+
kind: "mutation",
|
|
7947
|
+
auth: "admin",
|
|
7948
|
+
caller: "required"
|
|
7049
7949
|
})
|
|
7050
7950
|
});
|
|
7051
7951
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
@@ -7084,6 +7984,44 @@ function makeNcActionHandlers(deps) {
|
|
|
7084
7984
|
if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
7085
7985
|
return rule;
|
|
7086
7986
|
};
|
|
7987
|
+
/** The producer half, or an honest refusal. */
|
|
7988
|
+
const requireTimelapse = () => {
|
|
7989
|
+
const tl = deps.timelapse;
|
|
7990
|
+
if (tl === void 0) throw new Error("this node does not host the timelapse producer — use the hub");
|
|
7991
|
+
return tl;
|
|
7992
|
+
};
|
|
7993
|
+
/** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
|
|
7994
|
+
const assertTimelapseEditable = (ruleId, caller, tl) => {
|
|
7995
|
+
const rule = tl.store.get(ruleId);
|
|
7996
|
+
if (rule === null) throw new Error(`forbidden: timelapse rule not found: ${ruleId}`);
|
|
7997
|
+
if (caller.isAdmin) return rule;
|
|
7998
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: timelapse rule not owned: ${ruleId}`);
|
|
7999
|
+
return rule;
|
|
8000
|
+
};
|
|
8001
|
+
/**
|
|
8002
|
+
* THE RECORDING GUARD.
|
|
8003
|
+
*
|
|
8004
|
+
* A post-hoc timelapse renders RECORDED footage — a camera that is not
|
|
8005
|
+
* recording can never produce one, no matter how the rule is written. Save
|
|
8006
|
+
* time is the only moment an operator is present to be told: at 06:05 the
|
|
8007
|
+
* next morning the window is closed, the footage was never written, and the
|
|
8008
|
+
* only signal available is a log line nobody is reading.
|
|
8009
|
+
*
|
|
8010
|
+
* The authority is `RecordingConfig` through `deriveRecordingMode` — the
|
|
8011
|
+
* same pair camera-status reads. Never a second opinion, and never a guess:
|
|
8012
|
+
* `enabled: true` with no bands records NOTHING, which is exactly the case a
|
|
8013
|
+
* naive `config.enabled` check would wave through.
|
|
8014
|
+
*
|
|
8015
|
+
* FAIL-CLOSED on an unreadable config: a read that failed is not permission
|
|
8016
|
+
* to save.
|
|
8017
|
+
*/
|
|
8018
|
+
const assertCamerasRecord = async (deviceIds, tl) => {
|
|
8019
|
+
for (const deviceId of deviceIds) {
|
|
8020
|
+
const config = await tl.getRecordingConfig(deviceId).catch(() => null);
|
|
8021
|
+
if (config === null) throw new Error(`camera ${deviceId}: recording configuration could not be read — cannot save a timelapse rule for it`);
|
|
8022
|
+
if (deriveRecordingMode(config) === "off") throw new Error(`camera ${deviceId}: recording is not enabled — a timelapse renders recorded footage, so enable recording for this camera first`);
|
|
8023
|
+
}
|
|
8024
|
+
};
|
|
7087
8025
|
const assertTargetsOwned = async (targetIds, caller) => {
|
|
7088
8026
|
if (caller.isAdmin) return;
|
|
7089
8027
|
const owned = new Set(await deps.listCallerTargetIds(caller.userId));
|
|
@@ -7176,6 +8114,83 @@ function makeNcActionHandlers(deps) {
|
|
|
7176
8114
|
});
|
|
7177
8115
|
return inject(parsed);
|
|
7178
8116
|
},
|
|
8117
|
+
"nc.listTimelapseRules": async (_input, caller) => {
|
|
8118
|
+
const c = requireCaller(caller);
|
|
8119
|
+
const tl = requireTimelapse();
|
|
8120
|
+
return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
|
|
8121
|
+
...r,
|
|
8122
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
8123
|
+
})) };
|
|
8124
|
+
},
|
|
8125
|
+
"nc.upsertTimelapseRule": async (input, caller) => {
|
|
8126
|
+
const c = requireCaller(caller);
|
|
8127
|
+
const tl = requireTimelapse();
|
|
8128
|
+
if (input.ruleId === void 0) {
|
|
8129
|
+
if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
|
|
8130
|
+
const parsed = TimelapseRuleInputSchema.parse(input.rule);
|
|
8131
|
+
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
8132
|
+
await assertCamerasRecord(parsed.deviceIds, tl);
|
|
8133
|
+
const rule = await tl.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
|
|
8134
|
+
deps.logger.info("timelapse rule created", { meta: {
|
|
8135
|
+
ruleId: rule.id,
|
|
8136
|
+
owner: c.userId,
|
|
8137
|
+
devices: rule.deviceIds.join(",")
|
|
8138
|
+
} });
|
|
8139
|
+
return { rule };
|
|
8140
|
+
}
|
|
8141
|
+
const existing = assertTimelapseEditable(input.ruleId, c, tl);
|
|
8142
|
+
if (input.patch === void 0) throw new Error("upsertTimelapseRule: patch required");
|
|
8143
|
+
const patch = TimelapseRulePatchSchema.parse(input.patch);
|
|
8144
|
+
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
8145
|
+
if (patch.deviceIds !== void 0) {
|
|
8146
|
+
const known = new Set(existing.deviceIds);
|
|
8147
|
+
await assertCamerasRecord(patch.deviceIds.filter((id) => !known.has(id)), tl);
|
|
8148
|
+
}
|
|
8149
|
+
const rule = await tl.store.update(input.ruleId, patch);
|
|
8150
|
+
deps.logger.info("timelapse rule updated", { meta: {
|
|
8151
|
+
ruleId: rule.id,
|
|
8152
|
+
owner: c.userId
|
|
8153
|
+
} });
|
|
8154
|
+
return { rule };
|
|
8155
|
+
},
|
|
8156
|
+
"nc.deleteTimelapseRule": async (input, caller) => {
|
|
8157
|
+
const c = requireCaller(caller);
|
|
8158
|
+
const tl = requireTimelapse();
|
|
8159
|
+
assertTimelapseEditable(input.ruleId, c, tl);
|
|
8160
|
+
await tl.store.delete(input.ruleId);
|
|
8161
|
+
deps.logger.info("timelapse rule deleted", { meta: {
|
|
8162
|
+
ruleId: input.ruleId,
|
|
8163
|
+
owner: c.userId
|
|
8164
|
+
} });
|
|
8165
|
+
return { success: true };
|
|
8166
|
+
},
|
|
8167
|
+
"nc.testTimelapseRule": async (input, caller) => {
|
|
8168
|
+
const c = requireCaller(caller);
|
|
8169
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
|
|
8170
|
+
const tl = requireTimelapse();
|
|
8171
|
+
deps.logger.info("timelapse test requested", {
|
|
8172
|
+
tags: { deviceId: input.deviceId },
|
|
8173
|
+
meta: {
|
|
8174
|
+
by: c.userId,
|
|
8175
|
+
ruleId: input.ruleId
|
|
8176
|
+
}
|
|
8177
|
+
});
|
|
8178
|
+
const out = await tl.runNow({
|
|
8179
|
+
ruleId: input.ruleId,
|
|
8180
|
+
deviceId: input.deviceId
|
|
8181
|
+
});
|
|
8182
|
+
return {
|
|
8183
|
+
produced: out.produced,
|
|
8184
|
+
...out.exportId !== void 0 ? { exportId: out.exportId } : {},
|
|
8185
|
+
...out.coveragePercent !== void 0 ? { coveragePercent: out.coveragePercent } : {},
|
|
8186
|
+
...out.enqueued !== void 0 ? { enqueued: out.enqueued } : {},
|
|
8187
|
+
...out.window !== null ? {
|
|
8188
|
+
windowStartMs: out.window.startMs,
|
|
8189
|
+
windowEndMs: out.window.endMs
|
|
8190
|
+
} : {},
|
|
8191
|
+
...out.error !== void 0 ? { error: out.error } : {}
|
|
8192
|
+
};
|
|
8193
|
+
},
|
|
7179
8194
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
7180
8195
|
const c = requireCaller(caller);
|
|
7181
8196
|
assertRuleVisible(input.ruleId, c);
|
|
@@ -15771,6 +16786,7 @@ function resolveFaceSettings(raw) {
|
|
|
15771
16786
|
*/
|
|
15772
16787
|
function createLocationAwareMediaStorage(deps) {
|
|
15773
16788
|
const roots = /* @__PURE__ */ new Map();
|
|
16789
|
+
let defaultBypassInvalidated = false;
|
|
15774
16790
|
const rootOf = async (locationId) => {
|
|
15775
16791
|
const cached = roots.get(locationId);
|
|
15776
16792
|
if (cached !== void 0) return cached;
|
|
@@ -15781,21 +16797,55 @@ function createLocationAwareMediaStorage(deps) {
|
|
|
15781
16797
|
const absOf = async (locationId, relativePath) => path.join(await rootOf(locationId), relativePath);
|
|
15782
16798
|
return {
|
|
15783
16799
|
write: async (input) => {
|
|
15784
|
-
if (input.location === deps.defaultLocation) return deps.base.write(input);
|
|
16800
|
+
if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.write(input);
|
|
15785
16801
|
const abs = await absOf(input.location, input.relativePath);
|
|
15786
16802
|
await promises.mkdir(path.dirname(abs), { recursive: true });
|
|
15787
16803
|
await promises.writeFile(abs, input.data);
|
|
15788
16804
|
},
|
|
15789
16805
|
read: async (input) => {
|
|
15790
|
-
if (input.location === deps.defaultLocation) return deps.base.read(input);
|
|
16806
|
+
if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.read(input);
|
|
15791
16807
|
return promises.readFile(await absOf(input.location, input.relativePath));
|
|
15792
16808
|
},
|
|
15793
16809
|
delete: async (input) => {
|
|
15794
|
-
if (input.location === deps.defaultLocation) return deps.base.delete(input);
|
|
16810
|
+
if (input.location === deps.defaultLocation && !defaultBypassInvalidated) return deps.base.delete(input);
|
|
15795
16811
|
await promises.rm(await absOf(input.location, input.relativePath), { force: true });
|
|
16812
|
+
},
|
|
16813
|
+
invalidateRoots: () => {
|
|
16814
|
+
roots.clear();
|
|
16815
|
+
defaultBypassInvalidated = true;
|
|
15796
16816
|
}
|
|
15797
16817
|
};
|
|
15798
16818
|
}
|
|
16819
|
+
//#endregion
|
|
16820
|
+
//#region src/pipeline-analytics/media-write-gate.ts
|
|
16821
|
+
/**
|
|
16822
|
+
* Reversible writer gate for storage maintenance. `pause()` closes admission
|
|
16823
|
+
* first, then waits for writes already admitted to settle; callers never
|
|
16824
|
+
* mutate device bindings or analytics configuration to quiesce media.
|
|
16825
|
+
*/
|
|
16826
|
+
var MediaWriteGate = class {
|
|
16827
|
+
paused = false;
|
|
16828
|
+
active = 0;
|
|
16829
|
+
drainedWaiters = [];
|
|
16830
|
+
async run(write) {
|
|
16831
|
+
if (this.paused) throw new Error("event-media writes are paused for storage migration");
|
|
16832
|
+
this.active++;
|
|
16833
|
+
try {
|
|
16834
|
+
return await write();
|
|
16835
|
+
} finally {
|
|
16836
|
+
this.active--;
|
|
16837
|
+
if (this.active === 0) for (const resolve of this.drainedWaiters.splice(0)) resolve();
|
|
16838
|
+
}
|
|
16839
|
+
}
|
|
16840
|
+
pause() {
|
|
16841
|
+
this.paused = true;
|
|
16842
|
+
if (this.active === 0) return Promise.resolve();
|
|
16843
|
+
return new Promise((resolve) => this.drainedWaiters.push(resolve));
|
|
16844
|
+
}
|
|
16845
|
+
resume() {
|
|
16846
|
+
this.paused = false;
|
|
16847
|
+
}
|
|
16848
|
+
};
|
|
15799
16849
|
var MACRO_SET = new Set([
|
|
15800
16850
|
"person",
|
|
15801
16851
|
"vehicle",
|
|
@@ -16666,7 +17716,14 @@ var RETRAIN_FRAME_COLUMNS = [
|
|
|
16666
17716
|
name: "copiedAt",
|
|
16667
17717
|
type: "INTEGER",
|
|
16668
17718
|
notNull: true
|
|
16669
|
-
}
|
|
17719
|
+
},
|
|
17720
|
+
(
|
|
17721
|
+
/** NULL = current eventMedia default; stamped when migration moves a copy
|
|
17722
|
+
* before the logical default is repointed. */
|
|
17723
|
+
{
|
|
17724
|
+
name: "locationId",
|
|
17725
|
+
type: "TEXT"
|
|
17726
|
+
})
|
|
16670
17727
|
];
|
|
16671
17728
|
var RETRAIN_FRAME_INDEXES = [{
|
|
16672
17729
|
name: "idx_retrain_frames_track",
|
|
@@ -16695,12 +17752,14 @@ var RetrainFrameStore = class {
|
|
|
16695
17752
|
logger;
|
|
16696
17753
|
readSource;
|
|
16697
17754
|
now;
|
|
17755
|
+
writeGate;
|
|
16698
17756
|
constructor(deps) {
|
|
16699
17757
|
this.store = deps.store;
|
|
16700
17758
|
this.storage = deps.storage;
|
|
16701
17759
|
this.logger = deps.logger;
|
|
16702
17760
|
this.readSource = deps.readSource;
|
|
16703
17761
|
this.now = deps.now;
|
|
17762
|
+
this.writeGate = deps.writeGate;
|
|
16704
17763
|
}
|
|
16705
17764
|
static async declare(store) {
|
|
16706
17765
|
await store.declareCollection.mutate({
|
|
@@ -16719,7 +17778,8 @@ var RetrainFrameStore = class {
|
|
|
16719
17778
|
sizeBytes: Number(data["sizeBytes"] ?? 0),
|
|
16720
17779
|
width: Number(data["width"] ?? 0),
|
|
16721
17780
|
height: Number(data["height"] ?? 0),
|
|
16722
|
-
copiedAt: Number(data["copiedAt"] ?? 0)
|
|
17781
|
+
copiedAt: Number(data["copiedAt"] ?? 0),
|
|
17782
|
+
locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
|
|
16723
17783
|
};
|
|
16724
17784
|
}
|
|
16725
17785
|
/** The copy with this id, or `null`. Index read only — no blob is touched. */
|
|
@@ -16787,7 +17847,7 @@ var RetrainFrameStore = class {
|
|
|
16787
17847
|
async readFrame(frame) {
|
|
16788
17848
|
try {
|
|
16789
17849
|
return await this.storage.read({
|
|
16790
|
-
location:
|
|
17850
|
+
location: frame.locationId ?? "eventMedia",
|
|
16791
17851
|
relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
|
|
16792
17852
|
});
|
|
16793
17853
|
} catch (err) {
|
|
@@ -16867,9 +17927,10 @@ var RetrainFrameStore = class {
|
|
|
16867
17927
|
sizeBytes: bytes.length,
|
|
16868
17928
|
width: size.width,
|
|
16869
17929
|
height: size.height,
|
|
16870
|
-
copiedAt: this.now()
|
|
17930
|
+
copiedAt: this.now(),
|
|
17931
|
+
locationId: null
|
|
16871
17932
|
};
|
|
16872
|
-
|
|
17933
|
+
const persist = async () => {
|
|
16873
17934
|
await this.storage.write({
|
|
16874
17935
|
location: RETRAIN_MEDIA_LOCATION,
|
|
16875
17936
|
relativePath: path,
|
|
@@ -16887,9 +17948,14 @@ var RetrainFrameStore = class {
|
|
|
16887
17948
|
sizeBytes: frame.sizeBytes,
|
|
16888
17949
|
width: frame.width,
|
|
16889
17950
|
height: frame.height,
|
|
16890
|
-
copiedAt: frame.copiedAt
|
|
17951
|
+
copiedAt: frame.copiedAt,
|
|
17952
|
+
locationId: null
|
|
16891
17953
|
}
|
|
16892
17954
|
});
|
|
17955
|
+
};
|
|
17956
|
+
try {
|
|
17957
|
+
if (this.writeGate) await this.writeGate.run(persist);
|
|
17958
|
+
else await persist();
|
|
16893
17959
|
} catch (err) {
|
|
16894
17960
|
this.logger.error("retrain copy failed to persist", {
|
|
16895
17961
|
tags: { deviceId: input.deviceId },
|
|
@@ -16930,26 +17996,30 @@ var RetrainFrameStore = class {
|
|
|
16930
17996
|
* best-effort write is how a half-deleted annotation set happens.
|
|
16931
17997
|
*/
|
|
16932
17998
|
async deleteFrame(frame) {
|
|
16933
|
-
|
|
16934
|
-
|
|
16935
|
-
|
|
16936
|
-
|
|
16937
|
-
|
|
16938
|
-
|
|
16939
|
-
|
|
16940
|
-
|
|
16941
|
-
|
|
16942
|
-
|
|
16943
|
-
|
|
16944
|
-
|
|
16945
|
-
|
|
16946
|
-
|
|
16947
|
-
|
|
16948
|
-
|
|
16949
|
-
|
|
16950
|
-
|
|
16951
|
-
|
|
16952
|
-
|
|
17999
|
+
const remove = async () => {
|
|
18000
|
+
try {
|
|
18001
|
+
await this.storage.delete({
|
|
18002
|
+
location: frame.locationId ?? "eventMedia",
|
|
18003
|
+
relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
|
|
18004
|
+
});
|
|
18005
|
+
} catch {}
|
|
18006
|
+
try {
|
|
18007
|
+
await this.store.delete.mutate({
|
|
18008
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
18009
|
+
key: frame.frameId
|
|
18010
|
+
});
|
|
18011
|
+
} catch (err) {
|
|
18012
|
+
this.logger.warn("RetrainFrameStore.deleteFrame row delete failed", {
|
|
18013
|
+
tags: { deviceId: frame.deviceId },
|
|
18014
|
+
meta: {
|
|
18015
|
+
frameId: frame.frameId,
|
|
18016
|
+
error: String(err)
|
|
18017
|
+
}
|
|
18018
|
+
});
|
|
18019
|
+
}
|
|
18020
|
+
};
|
|
18021
|
+
if (this.writeGate) await this.writeGate.run(remove);
|
|
18022
|
+
else await remove();
|
|
16953
18023
|
}
|
|
16954
18024
|
};
|
|
16955
18025
|
//#endregion
|
|
@@ -17988,10 +19058,15 @@ var MediaStore = class {
|
|
|
17988
19058
|
storage;
|
|
17989
19059
|
store;
|
|
17990
19060
|
logger;
|
|
19061
|
+
writeGate;
|
|
17991
19062
|
constructor(deps) {
|
|
17992
19063
|
this.storage = deps.storage;
|
|
17993
19064
|
this.store = deps.store;
|
|
17994
19065
|
this.logger = deps.logger;
|
|
19066
|
+
this.writeGate = deps.writeGate;
|
|
19067
|
+
}
|
|
19068
|
+
gated(mutation) {
|
|
19069
|
+
return this.writeGate ? this.writeGate.run(mutation) : mutation();
|
|
17995
19070
|
}
|
|
17996
19071
|
static async declare(store) {
|
|
17997
19072
|
await store.declareCollection.mutate({
|
|
@@ -18003,6 +19078,10 @@ var MediaStore = class {
|
|
|
18003
19078
|
/** Persist a media blob and register its metadata row. Returns the
|
|
18004
19079
|
* collection key (MediaFile.key) that callers store as reference. */
|
|
18005
19080
|
async put(params) {
|
|
19081
|
+
if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
|
|
19082
|
+
return this.putUnchecked(params);
|
|
19083
|
+
}
|
|
19084
|
+
async putUnchecked(params) {
|
|
18006
19085
|
const key = buildKey(params);
|
|
18007
19086
|
const path = buildPath(params);
|
|
18008
19087
|
const record = {
|
|
@@ -18073,6 +19152,9 @@ var MediaStore = class {
|
|
|
18073
19152
|
* scheme (transition back-compat); once migrated it is a no-op.
|
|
18074
19153
|
*/
|
|
18075
19154
|
async putReplacing(params) {
|
|
19155
|
+
return this.gated(() => this.putReplacingUnchecked(params));
|
|
19156
|
+
}
|
|
19157
|
+
async putReplacingUnchecked(params) {
|
|
18076
19158
|
const existing = await this.store.query.query({
|
|
18077
19159
|
collection: MEDIA_COLLECTION,
|
|
18078
19160
|
filter: { where: {
|
|
@@ -18081,7 +19163,7 @@ var MediaStore = class {
|
|
|
18081
19163
|
kind: params.kind
|
|
18082
19164
|
} }
|
|
18083
19165
|
});
|
|
18084
|
-
const newKey = await this.
|
|
19166
|
+
const newKey = await this.putUnchecked(params);
|
|
18085
19167
|
for (const row of existing) {
|
|
18086
19168
|
if (row.id === newKey) continue;
|
|
18087
19169
|
const rowData = row.data;
|
|
@@ -18124,8 +19206,11 @@ var MediaStore = class {
|
|
|
18124
19206
|
* own kind.
|
|
18125
19207
|
*/
|
|
18126
19208
|
async promoteToLastFrame(input) {
|
|
19209
|
+
return this.gated(() => this.promoteToLastFrameUnchecked(input));
|
|
19210
|
+
}
|
|
19211
|
+
async promoteToLastFrameUnchecked(input) {
|
|
18127
19212
|
const data = Buffer.from(input.snapshot.base64, "base64");
|
|
18128
|
-
const newKey = await this.
|
|
19213
|
+
const newKey = await this.putReplacingUnchecked({
|
|
18129
19214
|
deviceId: input.deviceId,
|
|
18130
19215
|
ownerKind: "track",
|
|
18131
19216
|
ownerId: input.trackId,
|
|
@@ -18133,7 +19218,7 @@ var MediaStore = class {
|
|
|
18133
19218
|
timestamp: input.snapshot.timestamp,
|
|
18134
19219
|
data
|
|
18135
19220
|
});
|
|
18136
|
-
if (input.keepSource !== true) await this.
|
|
19221
|
+
if (input.keepSource !== true) await this.deleteByKeyUnchecked(input.snapshot.key);
|
|
18137
19222
|
return newKey;
|
|
18138
19223
|
}
|
|
18139
19224
|
/**
|
|
@@ -18171,6 +19256,9 @@ var MediaStore = class {
|
|
|
18171
19256
|
* Deletes the blob from storage (best-effort) and removes the index row.
|
|
18172
19257
|
*/
|
|
18173
19258
|
async deleteByKey(key) {
|
|
19259
|
+
await this.gated(() => this.deleteByKeyUnchecked(key));
|
|
19260
|
+
}
|
|
19261
|
+
async deleteByKeyUnchecked(key) {
|
|
18174
19262
|
const row = await this.store.get.query({
|
|
18175
19263
|
collection: MEDIA_COLLECTION,
|
|
18176
19264
|
key
|
|
@@ -18281,6 +19369,9 @@ var MediaStore = class {
|
|
|
18281
19369
|
* Best-effort per row; returns the number of index rows removed.
|
|
18282
19370
|
*/
|
|
18283
19371
|
async deleteForOwner(ownerKind, ownerIds) {
|
|
19372
|
+
return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
|
|
19373
|
+
}
|
|
19374
|
+
async deleteForOwnerUnchecked(ownerKind, ownerIds) {
|
|
18284
19375
|
let removed = 0;
|
|
18285
19376
|
for (const ownerId of ownerIds) {
|
|
18286
19377
|
const rows = await this.store.query.query({
|
|
@@ -18362,6 +19453,9 @@ var MediaStore = class {
|
|
|
18362
19453
|
* The old-entry deletion is best-effort: on failure a warning is logged
|
|
18363
19454
|
* and the method still returns the new key. */
|
|
18364
19455
|
async reown(input) {
|
|
19456
|
+
return this.gated(() => this.reownUnchecked(input));
|
|
19457
|
+
}
|
|
19458
|
+
async reownUnchecked(input) {
|
|
18365
19459
|
const { fromOwnerKind, fromOwnerId, toOwnerKind, toOwnerId, key } = input;
|
|
18366
19460
|
const row = (await this.store.query.query({
|
|
18367
19461
|
collection: MEDIA_COLLECTION,
|
|
@@ -18395,7 +19489,7 @@ var MediaStore = class {
|
|
|
18395
19489
|
relativePath: oldPath
|
|
18396
19490
|
})
|
|
18397
19491
|
};
|
|
18398
|
-
const newKey = await this.
|
|
19492
|
+
const newKey = await this.putUnchecked(newParams);
|
|
18399
19493
|
try {
|
|
18400
19494
|
await this.storage.delete({
|
|
18401
19495
|
location: mediaRowLocation(meta),
|
|
@@ -18449,8 +19543,10 @@ var MediaStore = class {
|
|
|
18449
19543
|
* batch.
|
|
18450
19544
|
*/
|
|
18451
19545
|
async deleteByTracks(trackIds) {
|
|
18452
|
-
await this.
|
|
18453
|
-
|
|
19546
|
+
await this.gated(async () => {
|
|
19547
|
+
await this.deleteForOwnerUnchecked("track", trackIds);
|
|
19548
|
+
await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
|
|
19549
|
+
});
|
|
18454
19550
|
await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
|
|
18455
19551
|
}
|
|
18456
19552
|
/**
|
|
@@ -18492,7 +19588,7 @@ function snapshot(j) {
|
|
|
18492
19588
|
fromLocationId: j.fromLocationId,
|
|
18493
19589
|
toLocationId: j.toLocationId,
|
|
18494
19590
|
deviceId: j.deviceId,
|
|
18495
|
-
entities: ["media"],
|
|
19591
|
+
entities: ["media", "retrainFrames"],
|
|
18496
19592
|
filesMoved: j.filesMoved,
|
|
18497
19593
|
bytesMoved: j.bytesMoved,
|
|
18498
19594
|
filesTotal: null,
|
|
@@ -18523,7 +19619,7 @@ var MediaRelocateEngine = class {
|
|
|
18523
19619
|
state: "running",
|
|
18524
19620
|
fromLocationId: "*",
|
|
18525
19621
|
toLocationId: input.toLocationId,
|
|
18526
|
-
deviceId:
|
|
19622
|
+
deviceId: null,
|
|
18527
19623
|
filesMoved: 0,
|
|
18528
19624
|
bytesMoved: 0,
|
|
18529
19625
|
startedAt: this.deps.now(),
|
|
@@ -18540,24 +19636,25 @@ var MediaRelocateEngine = class {
|
|
|
18540
19636
|
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
18541
19637
|
try {
|
|
18542
19638
|
await this.deps.resolveTargetRoot(job.toLocationId);
|
|
19639
|
+
let failedRows = 0;
|
|
18543
19640
|
let cursor = 0;
|
|
18544
|
-
let
|
|
19641
|
+
let offsetAtCursor = 0;
|
|
18545
19642
|
for (;;) {
|
|
18546
19643
|
if (job.cancelRequested) break;
|
|
18547
|
-
const
|
|
19644
|
+
const rows = await this.deps.store.query.query({
|
|
18548
19645
|
collection: MEDIA_COLLECTION,
|
|
18549
19646
|
filter: {
|
|
18550
|
-
...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
|
|
18551
19647
|
whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
18552
19648
|
orderBy: {
|
|
18553
19649
|
field: "timestamp",
|
|
18554
19650
|
direction: "asc"
|
|
18555
19651
|
},
|
|
18556
|
-
limit: PAGE_SIZE
|
|
19652
|
+
limit: PAGE_SIZE,
|
|
19653
|
+
offset: offsetAtCursor
|
|
18557
19654
|
}
|
|
18558
|
-
})
|
|
18559
|
-
if (
|
|
18560
|
-
for (const row of
|
|
19655
|
+
});
|
|
19656
|
+
if (rows.length === 0) break;
|
|
19657
|
+
for (const row of rows) {
|
|
18561
19658
|
if (job.cancelRequested) break;
|
|
18562
19659
|
const data = row.data;
|
|
18563
19660
|
const from = mediaRowLocation(data);
|
|
@@ -18565,6 +19662,17 @@ var MediaRelocateEngine = class {
|
|
|
18565
19662
|
const relativePath = String(data["path"] ?? "");
|
|
18566
19663
|
if (relativePath.length === 0) continue;
|
|
18567
19664
|
try {
|
|
19665
|
+
if (await this.samePhysicalLocation(from, job.toLocationId)) {
|
|
19666
|
+
await this.deps.store.set.mutate({
|
|
19667
|
+
collection: MEDIA_COLLECTION,
|
|
19668
|
+
key: row.id,
|
|
19669
|
+
value: {
|
|
19670
|
+
...data,
|
|
19671
|
+
locationId: job.toLocationId
|
|
19672
|
+
}
|
|
19673
|
+
});
|
|
19674
|
+
continue;
|
|
19675
|
+
}
|
|
18568
19676
|
const bytes = await this.deps.storage.read({
|
|
18569
19677
|
location: from,
|
|
18570
19678
|
relativePath
|
|
@@ -18574,6 +19682,10 @@ var MediaRelocateEngine = class {
|
|
|
18574
19682
|
relativePath,
|
|
18575
19683
|
data: bytes
|
|
18576
19684
|
});
|
|
19685
|
+
if ((await this.deps.storage.read({
|
|
19686
|
+
location: job.toLocationId,
|
|
19687
|
+
relativePath
|
|
19688
|
+
})).length !== bytes.length) throw new Error(`relocated media size mismatch for ${relativePath}`);
|
|
18577
19689
|
await this.deps.store.set.mutate({
|
|
18578
19690
|
collection: MEDIA_COLLECTION,
|
|
18579
19691
|
key: row.id,
|
|
@@ -18590,20 +19702,99 @@ var MediaRelocateEngine = class {
|
|
|
18590
19702
|
job.bytesMoved += bytes.length;
|
|
18591
19703
|
await sleep(bytes.length / bytesPerMs);
|
|
18592
19704
|
} catch (err) {
|
|
19705
|
+
failedRows++;
|
|
18593
19706
|
this.deps.logger.debug("media relocate row failed", { meta: {
|
|
18594
19707
|
key: row.id,
|
|
18595
19708
|
error: String(err)
|
|
18596
19709
|
} });
|
|
18597
19710
|
}
|
|
18598
19711
|
}
|
|
18599
|
-
const last =
|
|
19712
|
+
const last = rows[rows.length - 1];
|
|
18600
19713
|
const lastTs = Number(last.data["timestamp"] ?? cursor);
|
|
18601
|
-
if (lastTs === cursor)
|
|
19714
|
+
if (lastTs === cursor) offsetAtCursor += rows.length;
|
|
18602
19715
|
else {
|
|
18603
19716
|
cursor = lastTs;
|
|
18604
|
-
|
|
19717
|
+
offsetAtCursor = rows.filter((row) => Number(row.data["timestamp"]) === lastTs).length;
|
|
18605
19718
|
}
|
|
19719
|
+
if (rows.length < PAGE_SIZE) break;
|
|
18606
19720
|
}
|
|
19721
|
+
let retrainOffset = 0;
|
|
19722
|
+
for (;;) {
|
|
19723
|
+
if (job.cancelRequested) break;
|
|
19724
|
+
const rows = await this.deps.store.query.query({
|
|
19725
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
19726
|
+
filter: {
|
|
19727
|
+
orderBy: {
|
|
19728
|
+
field: "copiedAt",
|
|
19729
|
+
direction: "asc"
|
|
19730
|
+
},
|
|
19731
|
+
limit: PAGE_SIZE,
|
|
19732
|
+
offset: retrainOffset
|
|
19733
|
+
}
|
|
19734
|
+
});
|
|
19735
|
+
if (rows.length === 0) break;
|
|
19736
|
+
for (const row of rows) {
|
|
19737
|
+
if (job.cancelRequested) break;
|
|
19738
|
+
const data = row.data;
|
|
19739
|
+
const from = mediaRowLocation(data);
|
|
19740
|
+
if (from === job.toLocationId) continue;
|
|
19741
|
+
const relativePath = String(data["path"] ?? "");
|
|
19742
|
+
if (relativePath.length === 0) {
|
|
19743
|
+
failedRows++;
|
|
19744
|
+
continue;
|
|
19745
|
+
}
|
|
19746
|
+
try {
|
|
19747
|
+
if (await this.samePhysicalLocation(from, job.toLocationId)) {
|
|
19748
|
+
await this.deps.store.set.mutate({
|
|
19749
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
19750
|
+
key: row.id,
|
|
19751
|
+
value: {
|
|
19752
|
+
...data,
|
|
19753
|
+
locationId: job.toLocationId
|
|
19754
|
+
}
|
|
19755
|
+
});
|
|
19756
|
+
continue;
|
|
19757
|
+
}
|
|
19758
|
+
const bytes = await this.deps.storage.read({
|
|
19759
|
+
location: from,
|
|
19760
|
+
relativePath
|
|
19761
|
+
});
|
|
19762
|
+
await this.deps.storage.write({
|
|
19763
|
+
location: job.toLocationId,
|
|
19764
|
+
relativePath,
|
|
19765
|
+
data: bytes
|
|
19766
|
+
});
|
|
19767
|
+
if ((await this.deps.storage.read({
|
|
19768
|
+
location: job.toLocationId,
|
|
19769
|
+
relativePath
|
|
19770
|
+
})).length !== bytes.length) throw new Error(`relocated retrain frame size mismatch for ${relativePath}`);
|
|
19771
|
+
await this.deps.store.set.mutate({
|
|
19772
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
19773
|
+
key: row.id,
|
|
19774
|
+
value: {
|
|
19775
|
+
...data,
|
|
19776
|
+
locationId: job.toLocationId
|
|
19777
|
+
}
|
|
19778
|
+
});
|
|
19779
|
+
await this.deps.storage.delete({
|
|
19780
|
+
location: from,
|
|
19781
|
+
relativePath
|
|
19782
|
+
});
|
|
19783
|
+
job.filesMoved++;
|
|
19784
|
+
job.bytesMoved += bytes.length;
|
|
19785
|
+
await sleep(bytes.length / bytesPerMs);
|
|
19786
|
+
} catch (err) {
|
|
19787
|
+
failedRows++;
|
|
19788
|
+
this.deps.logger.debug("retrain frame relocate row failed", { meta: {
|
|
19789
|
+
key: row.id,
|
|
19790
|
+
error: String(err)
|
|
19791
|
+
} });
|
|
19792
|
+
}
|
|
19793
|
+
}
|
|
19794
|
+
retrainOffset += rows.length;
|
|
19795
|
+
if (rows.length < PAGE_SIZE) break;
|
|
19796
|
+
}
|
|
19797
|
+
if (failedRows > 0) throw new Error(`event-media relocation left ${failedRows} row(s) unmoved`);
|
|
18607
19798
|
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
18608
19799
|
} catch (err) {
|
|
18609
19800
|
job.state = "failed";
|
|
@@ -18617,6 +19808,18 @@ var MediaRelocateEngine = class {
|
|
|
18617
19808
|
this.deps.onFinished?.(snapshot(job));
|
|
18618
19809
|
}
|
|
18619
19810
|
}
|
|
19811
|
+
async samePhysicalLocation(from, to) {
|
|
19812
|
+
if (from === to) return true;
|
|
19813
|
+
try {
|
|
19814
|
+
const [fromRoot, toRoot] = await Promise.all([this.deps.resolveTargetRoot(from), this.deps.resolveTargetRoot(to)]);
|
|
19815
|
+
const [fromStat, toStat] = await Promise.all([promises.stat(fromRoot), promises.stat(toRoot)]);
|
|
19816
|
+
if (fromStat.dev === toStat.dev && fromStat.ino === toStat.ino) return true;
|
|
19817
|
+
const [fromReal, toReal] = await Promise.all([promises.realpath(path.resolve(fromRoot)), promises.realpath(path.resolve(toRoot))]);
|
|
19818
|
+
return fromReal === toReal;
|
|
19819
|
+
} catch {
|
|
19820
|
+
return false;
|
|
19821
|
+
}
|
|
19822
|
+
}
|
|
18620
19823
|
};
|
|
18621
19824
|
//#endregion
|
|
18622
19825
|
//#region src/pipeline-analytics/media-settings.ts
|
|
@@ -31169,6 +32372,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
31169
32372
|
eventsOpsLog = null;
|
|
31170
32373
|
/** Event-media relocation engine (entity-routing Phase 4). */
|
|
31171
32374
|
mediaRelocate = null;
|
|
32375
|
+
mediaLocationStorage = null;
|
|
32376
|
+
mediaWriteGate = new MediaWriteGate();
|
|
32377
|
+
storageMigrationLeaseId = null;
|
|
31172
32378
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
31173
32379
|
sensorEventStore = null;
|
|
31174
32380
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
@@ -31856,7 +33062,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
31856
33062
|
listCallerTargetIds: async (userId) => {
|
|
31857
33063
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
31858
33064
|
},
|
|
31859
|
-
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
|
|
33065
|
+
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
|
|
33066
|
+
timelapse: {
|
|
33067
|
+
store: center.timelapseStore,
|
|
33068
|
+
getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
|
|
33069
|
+
runNow: (input) => center.runTimelapseNow(input)
|
|
33070
|
+
}
|
|
31860
33071
|
});
|
|
31861
33072
|
}
|
|
31862
33073
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
@@ -31969,7 +33180,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
31969
33180
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
31970
33181
|
}
|
|
31971
33182
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
31972
|
-
|
|
33183
|
+
const routed = createLocationAwareMediaStorage({
|
|
31973
33184
|
base: storage,
|
|
31974
33185
|
defaultLocation: "eventMedia",
|
|
31975
33186
|
resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
|
|
@@ -31977,6 +33188,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
31977
33188
|
relativePath: ""
|
|
31978
33189
|
})
|
|
31979
33190
|
});
|
|
33191
|
+
this.mediaLocationStorage = routed;
|
|
33192
|
+
return routed;
|
|
31980
33193
|
}
|
|
31981
33194
|
/** Constructs every SQLite-backed store plus the stationary/package-drop/
|
|
31982
33195
|
* sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
|
|
@@ -32021,7 +33234,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
32021
33234
|
const mediaStore = new MediaStore({
|
|
32022
33235
|
storage,
|
|
32023
33236
|
store: api.settingsStore,
|
|
32024
|
-
logger: logger.child("MediaStore")
|
|
33237
|
+
logger: logger.child("MediaStore"),
|
|
33238
|
+
writeGate: this.mediaWriteGate
|
|
32025
33239
|
});
|
|
32026
33240
|
this.mediaStore = mediaStore;
|
|
32027
33241
|
const retrainFrames = new RetrainFrameStore({
|
|
@@ -32029,6 +33243,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
32029
33243
|
storage,
|
|
32030
33244
|
logger: logger.child("RetrainFrames"),
|
|
32031
33245
|
now: () => Date.now(),
|
|
33246
|
+
writeGate: this.mediaWriteGate,
|
|
32032
33247
|
readSource: async (mediaKey) => {
|
|
32033
33248
|
const file = await mediaStore.getByKey(mediaKey);
|
|
32034
33249
|
return file === null ? null : Buffer.from(file.base64, "base64");
|
|
@@ -32465,12 +33680,91 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
32465
33680
|
limit,
|
|
32466
33681
|
projection: "slim"
|
|
32467
33682
|
})).tracks;
|
|
32468
|
-
}
|
|
33683
|
+
},
|
|
33684
|
+
...this.isPostProcessingNode ? { timelapse: this.buildTimelapsePorts(api, stores) } : {}
|
|
32469
33685
|
});
|
|
32470
33686
|
this.wireAlarmPanel(this.notificationCenter);
|
|
32471
33687
|
await this.serveNcActionPlane(this.notificationCenter);
|
|
32472
33688
|
await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
|
|
32473
33689
|
}
|
|
33690
|
+
/**
|
|
33691
|
+
* The scheduled-timelapse producer's ports.
|
|
33692
|
+
*
|
|
33693
|
+
* Every one of them is something that already exists: `recording` for the
|
|
33694
|
+
* footage index, the LOCAL track store for the history (no self-cap call, and
|
|
33695
|
+
* `projection: 'slim'` because the planner reads two timestamps per track and
|
|
33696
|
+
* nothing else), `recordingExport` for the render.
|
|
33697
|
+
*
|
|
33698
|
+
* TWO links, two audiences, and they are not interchangeable:
|
|
33699
|
+
*
|
|
33700
|
+
* - `publishArtifact` produces the one a notifier BACKEND fetches. It reads
|
|
33701
|
+
* the finished mp4 once through `recordingExport.readExportBytes` and
|
|
33702
|
+
* republishes it on the NC artifact plane — the same signed, expiring,
|
|
33703
|
+
* public surface a detection rule's still and clip already ride. Bounded
|
|
33704
|
+
* at `RECORDING_EXPORT_MAX_READ_BYTES`; above it there is no attachment
|
|
33705
|
+
* and a log line saying so.
|
|
33706
|
+
* - `resolveDownloadUrl` produces the one the RECIPIENT taps. The export's
|
|
33707
|
+
* own download route is served `access: 'authenticated'`, so it is a
|
|
33708
|
+
* perfectly good `clickUrl` and a useless attachment URL — a backend
|
|
33709
|
+
* fetching it holds no session and gets a 401.
|
|
33710
|
+
*/
|
|
33711
|
+
buildTimelapsePorts(api, stores) {
|
|
33712
|
+
return {
|
|
33713
|
+
getAvailability: async ({ deviceId, fromMs, toMs }) => {
|
|
33714
|
+
return (await api.recording.getAvailability.query({
|
|
33715
|
+
deviceId,
|
|
33716
|
+
fromMs,
|
|
33717
|
+
toMs
|
|
33718
|
+
})).ranges;
|
|
33719
|
+
},
|
|
33720
|
+
listTracks: async ({ deviceId, since, until }) => {
|
|
33721
|
+
return (await stores.trackStore.queryHistorical({
|
|
33722
|
+
deviceId,
|
|
33723
|
+
since,
|
|
33724
|
+
until,
|
|
33725
|
+
projection: "slim"
|
|
33726
|
+
})).map((t) => ({
|
|
33727
|
+
firstSeen: t.firstSeen,
|
|
33728
|
+
lastSeen: t.lastSeen
|
|
33729
|
+
}));
|
|
33730
|
+
},
|
|
33731
|
+
createExport: (input) => api.recordingExport.createExport.mutate({
|
|
33732
|
+
deviceId: input.deviceId,
|
|
33733
|
+
profile: input.profile,
|
|
33734
|
+
fromMs: input.fromMs,
|
|
33735
|
+
toMs: input.toMs,
|
|
33736
|
+
options: {
|
|
33737
|
+
includeAudio: input.options.includeAudio,
|
|
33738
|
+
maxLifeMs: input.options.maxLifeMs,
|
|
33739
|
+
deleteAfterDownload: input.options.deleteAfterDownload,
|
|
33740
|
+
title: input.options.title,
|
|
33741
|
+
timelapse: {
|
|
33742
|
+
everyMs: input.options.timelapse.everyMs,
|
|
33743
|
+
outputFps: input.options.timelapse.outputFps,
|
|
33744
|
+
...input.options.timelapse.dense !== void 0 ? { dense: {
|
|
33745
|
+
everyMs: input.options.timelapse.dense.everyMs,
|
|
33746
|
+
ranges: input.options.timelapse.dense.ranges.map((r) => ({
|
|
33747
|
+
fromSec: r.fromSec,
|
|
33748
|
+
toSec: r.toSec
|
|
33749
|
+
}))
|
|
33750
|
+
} } : {}
|
|
33751
|
+
}
|
|
33752
|
+
}
|
|
33753
|
+
}),
|
|
33754
|
+
getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
|
|
33755
|
+
publishArtifact: buildTimelapseArtifactPublisher({
|
|
33756
|
+
logger: this.ctx.logger.child("Timelapse"),
|
|
33757
|
+
readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
|
|
33758
|
+
publish: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null
|
|
33759
|
+
}),
|
|
33760
|
+
resolveDownloadUrl: async ({ exportId }) => {
|
|
33761
|
+
const plane = this.ncArtifactPlane;
|
|
33762
|
+
if (plane === null) return null;
|
|
33763
|
+
const download = await api.recordingExport.getDownloadUrl.query({ exportId });
|
|
33764
|
+
return plane.resolveExternalUrl(download.url);
|
|
33765
|
+
}
|
|
33766
|
+
};
|
|
33767
|
+
}
|
|
32474
33768
|
/** Event-media data-plane: serve thumbnail JPEGs at
|
|
32475
33769
|
* `/addon/<addonId>/event-media/<eventId>` through the hub's port.
|
|
32476
33770
|
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
@@ -36270,14 +37564,38 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
36270
37564
|
return this.queryFacade.deleteDeviceEvents(input);
|
|
36271
37565
|
}
|
|
36272
37566
|
/** The events ops-log rows (newest-first), optionally scoped to one camera. */
|
|
36273
|
-
async
|
|
37567
|
+
async pauseForStorageMigration(input) {
|
|
37568
|
+
if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media writes are already paused by another storage migration");
|
|
37569
|
+
this.storageMigrationLeaseId = input.leaseId;
|
|
37570
|
+
try {
|
|
37571
|
+
await this.mediaWriteGate.pause();
|
|
37572
|
+
return { paused: true };
|
|
37573
|
+
} catch (err) {
|
|
37574
|
+
this.storageMigrationLeaseId = null;
|
|
37575
|
+
throw err;
|
|
37576
|
+
}
|
|
37577
|
+
}
|
|
37578
|
+
async resumeForStorageMigration(input) {
|
|
37579
|
+
if (this.storageMigrationLeaseId === null) return { resumed: true };
|
|
37580
|
+
if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
|
|
37581
|
+
this.mediaWriteGate.resume();
|
|
37582
|
+
this.storageMigrationLeaseId = null;
|
|
37583
|
+
return { resumed: true };
|
|
37584
|
+
}
|
|
37585
|
+
async refreshStorageLocationsForMigration(input) {
|
|
37586
|
+
if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
|
|
37587
|
+
this.mediaLocationStorage?.invalidateRoots();
|
|
37588
|
+
return { refreshed: true };
|
|
37589
|
+
}
|
|
37590
|
+
async startStorageMigrationMove(input) {
|
|
36274
37591
|
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
37592
|
+
if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
|
|
36275
37593
|
return { jobId: this.mediaRelocate.start(input) };
|
|
36276
37594
|
}
|
|
36277
|
-
async
|
|
36278
|
-
return this.mediaRelocate?.list() ??
|
|
37595
|
+
async getStorageMigrationMoveStatus(input) {
|
|
37596
|
+
return this.mediaRelocate?.list().find((job) => job.jobId === input.jobId) ?? null;
|
|
36279
37597
|
}
|
|
36280
|
-
async
|
|
37598
|
+
async cancelStorageMigrationMove(input) {
|
|
36281
37599
|
return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
|
|
36282
37600
|
}
|
|
36283
37601
|
async listOpsLog(input) {
|