@camstack/system 1.1.41 → 1.1.43
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/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +337 -27
- package/dist/builtins/snapshot/index.mjs +337 -27
- package/dist/builtins/snapshot/snapshot-coalescing.d.ts +130 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +40 -4
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BCseJMTD.js → dist-BP89I7gi.js} +33 -2
- package/dist/{dist-ClOjKsC4.mjs → dist-C68OyuEd.mjs} +33 -2
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{manifest-python-deps-BVUZVpma.js → manifest-python-deps-CJn9aExb.js} +1 -1
- package/dist/{manifest-python-deps-DyIcZtF5.mjs → manifest-python-deps-MJEshIKy.mjs} +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,200 @@
|
|
|
1
|
-
import { $ as errMsg, K as snapshotCapability, L as nodePin, Y as streamQualityLabel, et as BaseAddon, it as DeviceFeature, ot as DeviceType } from "../../dist-
|
|
1
|
+
import { $ as errMsg, K as snapshotCapability, L as nodePin, Y as streamQualityLabel, et as BaseAddon, it as DeviceFeature, ot as DeviceType } from "../../dist-C68OyuEd.mjs";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
+
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
4
|
+
/**
|
|
5
|
+
* Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
|
|
6
|
+
* primitives for the snapshot wrapper.
|
|
7
|
+
*
|
|
8
|
+
* These are extracted from `SnapshotAddon` so the decision logic is unit-testable
|
|
9
|
+
* without instantiating the addon (no camera, no ffmpeg, no tRPC). The addon wires
|
|
10
|
+
* them to its cache + ladder; this module owns only the mechanics:
|
|
11
|
+
*
|
|
12
|
+
* - {@link decideSnapshotServe} — the pure serve/refresh decision (fresh cache vs
|
|
13
|
+
* stale-while-revalidate vs cold blocking capture) keyed off the cached frame
|
|
14
|
+
* age, the per-device max-age, and whether the caller forced a refresh.
|
|
15
|
+
* - {@link Semaphore} — a counting semaphore bounding concurrent grabs.
|
|
16
|
+
* - {@link SingleFlight} — de-dupes concurrent captures for the same key and
|
|
17
|
+
* retains a SETTLED success for a short hold window so a mount burst collapses
|
|
18
|
+
* into one capture.
|
|
19
|
+
* - {@link raceForResult} — races a promise against a wait bound without
|
|
20
|
+
* cancelling the promise (the loser keeps running in the background).
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Retain a SETTLED single-flight capture this long so a grid-mount burst of
|
|
24
|
+
* `getSnapshot` calls (and a row of refresh buttons) collapses into ONE capture
|
|
25
|
+
* instead of one dial per tile. Only success outcomes are held — a null/failed
|
|
26
|
+
* capture expires immediately so a transiently-unreachable camera is retried on
|
|
27
|
+
* the next call rather than pinned blank for the window.
|
|
28
|
+
*/
|
|
29
|
+
var COALESCE_MS = 4e3;
|
|
30
|
+
/**
|
|
31
|
+
* Stale-while-revalidate: when a cached frame exists but is past its max-age,
|
|
32
|
+
* start the refresh and wait at most this long for it to land before returning
|
|
33
|
+
* the STALE frame (the refresh keeps running and populates the cache for the
|
|
34
|
+
* next poll). Keeps a grid mount painting instantly from cache.
|
|
35
|
+
*/
|
|
36
|
+
var SWR_STALE_WAIT_MS = 2500;
|
|
37
|
+
/**
|
|
38
|
+
* Cold path (no cached frame) or a forced refresh: wait up to this long for a
|
|
39
|
+
* fresh capture. On a cold miss there is nothing to fall back to, so we block
|
|
40
|
+
* (bounded) on the capture; on a forced refresh with a stale frame available we
|
|
41
|
+
* still prefer a fresh one but cap the wait, falling back to stale on timeout.
|
|
42
|
+
*/
|
|
43
|
+
var SWR_COLD_WAIT_MS = 1e4;
|
|
44
|
+
/**
|
|
45
|
+
* Pure serve/refresh decision.
|
|
46
|
+
*
|
|
47
|
+
* - fresh cache + !force -> serve-cache
|
|
48
|
+
* - stale cache + !force -> SWR: wait SWR_STALE_WAIT_MS, then stale
|
|
49
|
+
* - no cache + !force -> cold: wait SWR_COLD_WAIT_MS, no fallback
|
|
50
|
+
* - force (cache present or not) -> prefer fresh: wait SWR_COLD_WAIT_MS,
|
|
51
|
+
* fall back to stale only if a frame exists
|
|
52
|
+
*/
|
|
53
|
+
function decideSnapshotServe(input) {
|
|
54
|
+
const { now, cachedAt, effectiveMaxAgeMs, force } = input;
|
|
55
|
+
const hasFrame = cachedAt !== null;
|
|
56
|
+
const fresh = hasFrame && now - cachedAt < effectiveMaxAgeMs;
|
|
57
|
+
if (!force && fresh) return { kind: "serve-cache" };
|
|
58
|
+
if (force) return {
|
|
59
|
+
kind: "await-refresh",
|
|
60
|
+
waitMs: SWR_COLD_WAIT_MS,
|
|
61
|
+
staleFallback: hasFrame
|
|
62
|
+
};
|
|
63
|
+
if (hasFrame) return {
|
|
64
|
+
kind: "await-refresh",
|
|
65
|
+
waitMs: SWR_STALE_WAIT_MS,
|
|
66
|
+
staleFallback: true
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
kind: "await-refresh",
|
|
70
|
+
waitMs: SWR_COLD_WAIT_MS,
|
|
71
|
+
staleFallback: false
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A counting semaphore. `run` acquires a permit, awaits `fn`, and releases the
|
|
76
|
+
* permit (even if `fn` throws), so at most `max` `fn`s run concurrently. Waiters
|
|
77
|
+
* are served FIFO.
|
|
78
|
+
*/
|
|
79
|
+
var Semaphore = class {
|
|
80
|
+
available;
|
|
81
|
+
waiters = [];
|
|
82
|
+
constructor(max) {
|
|
83
|
+
if (!Number.isInteger(max) || max < 1) throw new Error(`Semaphore max must be a positive integer, got ${String(max)}`);
|
|
84
|
+
this.available = max;
|
|
85
|
+
}
|
|
86
|
+
async run(fn) {
|
|
87
|
+
const release = await this.acquire();
|
|
88
|
+
try {
|
|
89
|
+
return await fn();
|
|
90
|
+
} finally {
|
|
91
|
+
release();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async acquire() {
|
|
95
|
+
if (this.available > 0) {
|
|
96
|
+
this.available -= 1;
|
|
97
|
+
return this.makeReleaser();
|
|
98
|
+
}
|
|
99
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
100
|
+
return this.makeReleaser();
|
|
101
|
+
}
|
|
102
|
+
makeReleaser() {
|
|
103
|
+
let released = false;
|
|
104
|
+
return () => {
|
|
105
|
+
if (released) return;
|
|
106
|
+
released = true;
|
|
107
|
+
const next = this.waiters.shift();
|
|
108
|
+
if (next) {
|
|
109
|
+
next();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.available += 1;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* One in-flight capture per key. A settled SUCCESS is retained for `holdMs` so a
|
|
118
|
+
* burst collapses into one capture; a settled failure/empty expires immediately
|
|
119
|
+
* so the next caller retries. Concurrent callers ALWAYS join the running flight
|
|
120
|
+
* regardless of eventual outcome.
|
|
121
|
+
*/
|
|
122
|
+
var Flight = class {
|
|
123
|
+
promise;
|
|
124
|
+
settledAt = null;
|
|
125
|
+
hold = false;
|
|
126
|
+
constructor(factory, now, holdWorthy) {
|
|
127
|
+
this.promise = (async () => {
|
|
128
|
+
try {
|
|
129
|
+
const value = await factory();
|
|
130
|
+
this.hold = holdWorthy(value);
|
|
131
|
+
return value;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
this.hold = false;
|
|
134
|
+
throw err;
|
|
135
|
+
} finally {
|
|
136
|
+
this.settledAt = now();
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
var SingleFlight = class {
|
|
142
|
+
holdMs;
|
|
143
|
+
holdWorthy;
|
|
144
|
+
now;
|
|
145
|
+
flights = /* @__PURE__ */ new Map();
|
|
146
|
+
/**
|
|
147
|
+
* @param holdMs how long a settled hold-worthy result is reused
|
|
148
|
+
* @param holdWorthy predicate deciding whether a settled value is worth holding
|
|
149
|
+
* (e.g. a successful capture with a real frame)
|
|
150
|
+
* @param now clock injection for tests
|
|
151
|
+
*/
|
|
152
|
+
constructor(holdMs, holdWorthy, now = () => Date.now()) {
|
|
153
|
+
this.holdMs = holdMs;
|
|
154
|
+
this.holdWorthy = holdWorthy;
|
|
155
|
+
this.now = now;
|
|
156
|
+
}
|
|
157
|
+
run(key, factory) {
|
|
158
|
+
const existing = this.flights.get(key);
|
|
159
|
+
if (existing && this.isReusable(existing)) return existing.promise;
|
|
160
|
+
const flight = new Flight(factory, this.now, this.holdWorthy);
|
|
161
|
+
this.flights.set(key, flight);
|
|
162
|
+
return flight.promise;
|
|
163
|
+
}
|
|
164
|
+
/** Drop every held flight whose key starts with `prefix` (e.g. `"<deviceId>:"`). */
|
|
165
|
+
invalidatePrefix(prefix) {
|
|
166
|
+
for (const key of [...this.flights.keys()]) if (key.startsWith(prefix)) this.flights.delete(key);
|
|
167
|
+
}
|
|
168
|
+
clear() {
|
|
169
|
+
this.flights.clear();
|
|
170
|
+
}
|
|
171
|
+
isReusable(flight) {
|
|
172
|
+
if (flight.settledAt === null) return true;
|
|
173
|
+
if (!flight.hold) return false;
|
|
174
|
+
return this.now() - flight.settledAt < this.holdMs;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Race `promise` against a `timeoutMs` bound WITHOUT cancelling it — on timeout
|
|
179
|
+
* the promise keeps running (its result lands in the cache for the next poll).
|
|
180
|
+
* Rejections propagate: if `promise` rejects before the timeout, this rejects.
|
|
181
|
+
* The caller passes a promise that never rejects (a settled outcome union) when
|
|
182
|
+
* background continuation must stay unhandled-safe.
|
|
183
|
+
*/
|
|
184
|
+
function raceForResult(promise, timeoutMs) {
|
|
185
|
+
let timer;
|
|
186
|
+
const timeout = new Promise((resolve) => {
|
|
187
|
+
timer = setTimeout(() => resolve({ settled: false }), timeoutMs);
|
|
188
|
+
});
|
|
189
|
+
const settled = promise.then((value) => ({
|
|
190
|
+
settled: true,
|
|
191
|
+
value
|
|
192
|
+
}));
|
|
193
|
+
return Promise.race([settled, timeout]).finally(() => {
|
|
194
|
+
if (timer) clearTimeout(timer);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
3
198
|
//#region src/builtins/snapshot/snapshot.addon.ts
|
|
4
199
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
5
200
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
@@ -26,6 +221,16 @@ var BATTERY_DEFAULT_MAX_AGE_S = 3600;
|
|
|
26
221
|
var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
27
222
|
cache = /* @__PURE__ */ new Map();
|
|
28
223
|
/**
|
|
224
|
+
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
225
|
+
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
226
|
+
* buttons) collapses into one capture instead of one dial per tile.
|
|
227
|
+
*/
|
|
228
|
+
captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
|
|
229
|
+
/** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
|
|
230
|
+
grabPool = new Semaphore(3);
|
|
231
|
+
/** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
|
|
232
|
+
nativePool = new Semaphore(6);
|
|
233
|
+
/**
|
|
29
234
|
* Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
|
|
30
235
|
* has no per-request attach hook to resolve this fresh, so it's cached with
|
|
31
236
|
* a short TTL and invalidated on `onConfigChanged` (covers the common case
|
|
@@ -48,12 +253,14 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
48
253
|
getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
|
|
49
254
|
getDeviceLiveContribution: async () => null,
|
|
50
255
|
applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
|
|
51
|
-
getStatus: async (input) => this.getStatus(input.deviceId)
|
|
256
|
+
getStatus: async (input) => this.getStatus(input.deviceId),
|
|
257
|
+
getSnapshotOverview: (input) => this.getSnapshotOverview(input)
|
|
52
258
|
}
|
|
53
259
|
}];
|
|
54
260
|
}
|
|
55
261
|
async onShutdown() {
|
|
56
262
|
this.cache.clear();
|
|
263
|
+
this.captureFlight.clear();
|
|
57
264
|
this.ownerCache = null;
|
|
58
265
|
}
|
|
59
266
|
/**
|
|
@@ -86,7 +293,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
86
293
|
}] });
|
|
87
294
|
}
|
|
88
295
|
async getSnapshot(input) {
|
|
89
|
-
const { deviceId
|
|
296
|
+
const { deviceId } = input;
|
|
297
|
+
const force = input.force === true;
|
|
90
298
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
91
299
|
const deviceName = meta?.name;
|
|
92
300
|
const isBatteryDevice = meta?.isBattery ?? false;
|
|
@@ -105,8 +313,14 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
105
313
|
});
|
|
106
314
|
const defaultMaxAgeS = isBatteryDevice ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S;
|
|
107
315
|
const effectiveMaxAgeMs = (typeof prefs.snapshotMaxAgeS === "number" && prefs.snapshotMaxAgeS >= 0 ? prefs.snapshotMaxAgeS : defaultMaxAgeS) * 1e3;
|
|
108
|
-
|
|
109
|
-
|
|
316
|
+
const decision = decideSnapshotServe({
|
|
317
|
+
now,
|
|
318
|
+
cachedAt: hit?.ts ?? null,
|
|
319
|
+
effectiveMaxAgeMs,
|
|
320
|
+
force
|
|
321
|
+
});
|
|
322
|
+
if (decision.kind === "serve-cache") {
|
|
323
|
+
if (prefs.snapshotDebug && hit) log.debug("snapshot: cache hit", {
|
|
110
324
|
tags: { deviceId },
|
|
111
325
|
meta: {
|
|
112
326
|
ageMs: now - hit.ts,
|
|
@@ -114,21 +328,97 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
114
328
|
isBattery: isBatteryDevice
|
|
115
329
|
}
|
|
116
330
|
});
|
|
331
|
+
return hit ? hit.data : null;
|
|
332
|
+
}
|
|
333
|
+
const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
|
|
334
|
+
const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
|
|
335
|
+
deviceId,
|
|
336
|
+
input,
|
|
337
|
+
effectiveStreamId,
|
|
338
|
+
isBatteryDevice,
|
|
339
|
+
log
|
|
340
|
+
}));
|
|
341
|
+
flight.catch(() => void 0);
|
|
342
|
+
const raced = await raceForResult(flight, decision.waitMs);
|
|
343
|
+
if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
|
|
344
|
+
if (decision.staleFallback && hit) {
|
|
345
|
+
if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
|
|
346
|
+
tags: { deviceId },
|
|
347
|
+
meta: {
|
|
348
|
+
ageMs: now - hit.ts,
|
|
349
|
+
waitMs: decision.waitMs
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return hit.data;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Map a settled capture outcome onto the response, preserving the legacy
|
|
358
|
+
* semantics: a fresh frame wins; a soft miss (native null + broker null)
|
|
359
|
+
* falls back to stale cache (or null); a HARD native error with no frame
|
|
360
|
+
* and no cache propagates.
|
|
361
|
+
*/
|
|
362
|
+
resolveOutcome(outcome, deviceId, hit, log) {
|
|
363
|
+
if (outcome.ok) {
|
|
364
|
+
if (outcome.image) return outcome.image;
|
|
365
|
+
if (hit) {
|
|
366
|
+
const ageMs = Date.now() - hit.ts;
|
|
367
|
+
if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
|
|
368
|
+
tags: { deviceId },
|
|
369
|
+
meta: { ageMs }
|
|
370
|
+
});
|
|
371
|
+
return hit.data;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
if (hit) {
|
|
376
|
+
const ageMs = Date.now() - hit.ts;
|
|
377
|
+
if (ageMs > this.config.staleTtlMs) log.warn("snapshot: native failed — serving stale cache", {
|
|
378
|
+
tags: { deviceId },
|
|
379
|
+
meta: { ageMs }
|
|
380
|
+
});
|
|
117
381
|
return hit.data;
|
|
118
382
|
}
|
|
383
|
+
throw outcome.error;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Run the capture ladder ONCE for a device: native provider first, then the
|
|
387
|
+
* stream-broker ffmpeg fallback. Never rejects — resolves a {@link
|
|
388
|
+
* CaptureOutcome}. On a produced frame it populates the cache (so a
|
|
389
|
+
* background SWR refresh lands for the next poll) and returns it. A hard
|
|
390
|
+
* native error with no broker frame resolves `{ ok:false }` so the caller
|
|
391
|
+
* can surface it; a soft miss resolves `{ ok:true, image:null }`.
|
|
392
|
+
*
|
|
393
|
+
* TODO(decoded-frame fast path): for a device whose detection pipeline is
|
|
394
|
+
* already decoding (the always-on subscription, see docs/design/decode-path.md),
|
|
395
|
+
* the freshest decoded frame is already in the shared-memory ring — a single
|
|
396
|
+
* JPEG encode of the latest FrameHandle would cost zero dials and beat the
|
|
397
|
+
* ffmpeg grab for the wrapper fleet. Slotting it as native -> latest-decoded
|
|
398
|
+
* -> broker needs cross-addon plumbing not cheaply reachable from the hub
|
|
399
|
+
* builtin today: frames are session-scoped (decoder `pullHandles`/`getFrame`
|
|
400
|
+
* need the device's active session id) and raw-pixel (need a JPEG encoder the
|
|
401
|
+
* builtin lacks). Deferred as its own slice.
|
|
402
|
+
*/
|
|
403
|
+
async captureFresh(args) {
|
|
404
|
+
const { deviceId, input, effectiveStreamId, isBatteryDevice, log } = args;
|
|
405
|
+
const now = Date.now();
|
|
119
406
|
let nativeError = null;
|
|
120
407
|
let nativeAbsent = false;
|
|
121
408
|
try {
|
|
122
409
|
const native = this.ctx.getNativeProvider(snapshotCapability, deviceId);
|
|
123
410
|
if (native) {
|
|
124
|
-
const result = await native.getSnapshot(input);
|
|
411
|
+
const result = await this.nativePool.run(() => native.getSnapshot(input));
|
|
125
412
|
if (result) {
|
|
126
413
|
this.cache.set(deviceId, {
|
|
127
414
|
data: result,
|
|
128
415
|
ts: now,
|
|
129
416
|
streamId: effectiveStreamId ?? null
|
|
130
417
|
});
|
|
131
|
-
return
|
|
418
|
+
return {
|
|
419
|
+
ok: true,
|
|
420
|
+
image: result
|
|
421
|
+
};
|
|
132
422
|
}
|
|
133
423
|
log.debug("native snapshot returned null — falling through to broker", { tags: { deviceId } });
|
|
134
424
|
} else {
|
|
@@ -159,7 +449,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
159
449
|
ts: now,
|
|
160
450
|
streamId: effectiveStreamId ?? null
|
|
161
451
|
});
|
|
162
|
-
return
|
|
452
|
+
return {
|
|
453
|
+
ok: true,
|
|
454
|
+
image: fallback
|
|
455
|
+
};
|
|
163
456
|
}
|
|
164
457
|
} catch (err) {
|
|
165
458
|
log.warn("stream-broker snapshot fallback failed", {
|
|
@@ -168,25 +461,16 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
168
461
|
});
|
|
169
462
|
}
|
|
170
463
|
else log.debug("snapshot: skipping broker fallback — battery device with absent native and no streaming broker", { tags: { deviceId } });
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
if (nativeError) throw nativeError;
|
|
180
|
-
if (nativeAbsent) return null;
|
|
181
|
-
return null;
|
|
464
|
+
if (nativeError) return {
|
|
465
|
+
ok: false,
|
|
466
|
+
error: nativeError
|
|
467
|
+
};
|
|
468
|
+
return {
|
|
469
|
+
ok: true,
|
|
470
|
+
image: null
|
|
471
|
+
};
|
|
182
472
|
}
|
|
183
473
|
/**
|
|
184
|
-
* Tell apart "native provider isn't registered for this device" from
|
|
185
|
-
* "native provider ran and threw a real error". The former is the steady
|
|
186
|
-
* state for cameras without a vendor snapshot endpoint and should not
|
|
187
|
-
* propagate as a 500; the latter should.
|
|
188
|
-
*/
|
|
189
|
-
/**
|
|
190
474
|
* Pull one JPEG from the device's stream-broker RTSP restream using
|
|
191
475
|
* a short-lived ffmpeg invocation.
|
|
192
476
|
*
|
|
@@ -291,12 +575,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
291
575
|
async runGrabWithResumeRetry(url, deviceId) {
|
|
292
576
|
let buf;
|
|
293
577
|
try {
|
|
294
|
-
buf = await runFfmpegFrameGrab(url, 15e3);
|
|
578
|
+
buf = await this.grabPool.run(() => runFfmpegFrameGrab(url, 15e3));
|
|
295
579
|
} catch (err) {
|
|
296
580
|
if (isBrokerColdError(errMsg(err))) {
|
|
297
581
|
this.ctx.logger.debug("grabFrame: broker-resume race — retrying in 1500ms", { tags: { deviceId } });
|
|
298
582
|
await new Promise((r) => setTimeout(r, 1500));
|
|
299
|
-
buf = await runFfmpegFrameGrab(url, 15e3);
|
|
583
|
+
buf = await this.grabPool.run(() => runFfmpegFrameGrab(url, 15e3));
|
|
300
584
|
} else throw err;
|
|
301
585
|
}
|
|
302
586
|
if (buf.length === 0) return null;
|
|
@@ -307,6 +591,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
307
591
|
}
|
|
308
592
|
async invalidateCache(input) {
|
|
309
593
|
this.cache.delete(input.deviceId);
|
|
594
|
+
this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
|
|
310
595
|
}
|
|
311
596
|
/**
|
|
312
597
|
* Non-throwing probe of the device's battery cap. Returns true only
|
|
@@ -346,6 +631,30 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
346
631
|
}
|
|
347
632
|
}
|
|
348
633
|
/**
|
|
634
|
+
* Cache-only batch overview — O(n) over the in-memory cache, NEVER triggers
|
|
635
|
+
* a capture. Lets a grid skip requesting images for devices that never
|
|
636
|
+
* produced a frame, and hands each an ETag for conditional image fetches.
|
|
637
|
+
*/
|
|
638
|
+
getSnapshotOverview(input) {
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
const rows = input.deviceIds.map((deviceId) => {
|
|
641
|
+
const hit = this.cache.get(deviceId);
|
|
642
|
+
if (!hit) return {
|
|
643
|
+
deviceId,
|
|
644
|
+
lastCapturedAt: null,
|
|
645
|
+
cacheAgeMs: null,
|
|
646
|
+
etag: null
|
|
647
|
+
};
|
|
648
|
+
return {
|
|
649
|
+
deviceId,
|
|
650
|
+
lastCapturedAt: hit.ts,
|
|
651
|
+
cacheAgeMs: now - hit.ts,
|
|
652
|
+
etag: `"${deviceId}-${hit.ts}"`
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
return Promise.resolve(rows);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
349
658
|
* Diagnostic status for the `status` auto-injected cap method. Reports
|
|
350
659
|
* the cache bookkeeping for this device — when the last snapshot was
|
|
351
660
|
* captured, how stale the cached image is, its size, and which stream
|
|
@@ -502,6 +811,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
502
811
|
}
|
|
503
812
|
await this.ctx.settings.writeDeviceStore(deviceId, next);
|
|
504
813
|
this.cache.delete(deviceId);
|
|
814
|
+
this.captureFlight.invalidatePrefix(`${deviceId}:`);
|
|
505
815
|
return { success: true };
|
|
506
816
|
}
|
|
507
817
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
|
|
3
|
+
* primitives for the snapshot wrapper.
|
|
4
|
+
*
|
|
5
|
+
* These are extracted from `SnapshotAddon` so the decision logic is unit-testable
|
|
6
|
+
* without instantiating the addon (no camera, no ffmpeg, no tRPC). The addon wires
|
|
7
|
+
* them to its cache + ladder; this module owns only the mechanics:
|
|
8
|
+
*
|
|
9
|
+
* - {@link decideSnapshotServe} — the pure serve/refresh decision (fresh cache vs
|
|
10
|
+
* stale-while-revalidate vs cold blocking capture) keyed off the cached frame
|
|
11
|
+
* age, the per-device max-age, and whether the caller forced a refresh.
|
|
12
|
+
* - {@link Semaphore} — a counting semaphore bounding concurrent grabs.
|
|
13
|
+
* - {@link SingleFlight} — de-dupes concurrent captures for the same key and
|
|
14
|
+
* retains a SETTLED success for a short hold window so a mount burst collapses
|
|
15
|
+
* into one capture.
|
|
16
|
+
* - {@link raceForResult} — races a promise against a wait bound without
|
|
17
|
+
* cancelling the promise (the loser keeps running in the background).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Retain a SETTLED single-flight capture this long so a grid-mount burst of
|
|
21
|
+
* `getSnapshot` calls (and a row of refresh buttons) collapses into ONE capture
|
|
22
|
+
* instead of one dial per tile. Only success outcomes are held — a null/failed
|
|
23
|
+
* capture expires immediately so a transiently-unreachable camera is retried on
|
|
24
|
+
* the next call rather than pinned blank for the window.
|
|
25
|
+
*/
|
|
26
|
+
export declare const COALESCE_MS = 4000;
|
|
27
|
+
/**
|
|
28
|
+
* Stale-while-revalidate: when a cached frame exists but is past its max-age,
|
|
29
|
+
* start the refresh and wait at most this long for it to land before returning
|
|
30
|
+
* the STALE frame (the refresh keeps running and populates the cache for the
|
|
31
|
+
* next poll). Keeps a grid mount painting instantly from cache.
|
|
32
|
+
*/
|
|
33
|
+
export declare const SWR_STALE_WAIT_MS = 2500;
|
|
34
|
+
/**
|
|
35
|
+
* Cold path (no cached frame) or a forced refresh: wait up to this long for a
|
|
36
|
+
* fresh capture. On a cold miss there is nothing to fall back to, so we block
|
|
37
|
+
* (bounded) on the capture; on a forced refresh with a stale frame available we
|
|
38
|
+
* still prefer a fresh one but cap the wait, falling back to stale on timeout.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SWR_COLD_WAIT_MS = 10000;
|
|
41
|
+
/**
|
|
42
|
+
* Max simultaneous ffmpeg keyframe grabs — the WRAPPER path, the common case for
|
|
43
|
+
* cameras with no vendor snapshot URL. A 20-cam grid mount spawns at most this
|
|
44
|
+
* many ffmpeg processes; the rest serve cached frames (SWR) or queue.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MAX_CONCURRENT_GRABS = 3;
|
|
47
|
+
/**
|
|
48
|
+
* Max simultaneous cheap native snapshot fetches (vendor HTTP/ONVIF). Higher than
|
|
49
|
+
* the ffmpeg bound because a native fetch is a lightweight HTTP round-trip, not a
|
|
50
|
+
* decode pipeline.
|
|
51
|
+
*/
|
|
52
|
+
export declare const MAX_CONCURRENT_NATIVE = 6;
|
|
53
|
+
/**
|
|
54
|
+
* Serve either the fresh cache directly, or start/join a refresh and wait a
|
|
55
|
+
* bounded time. `await-refresh` always runs (or joins) the single flight;
|
|
56
|
+
* `waitMs` bounds how long the caller waits before, when `staleFallback` is set
|
|
57
|
+
* and a cached frame exists, returning the stale frame while the refresh
|
|
58
|
+
* continues in the background.
|
|
59
|
+
*/
|
|
60
|
+
export type SnapshotServeDecision = {
|
|
61
|
+
readonly kind: 'serve-cache';
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: 'await-refresh';
|
|
64
|
+
readonly waitMs: number;
|
|
65
|
+
readonly staleFallback: boolean;
|
|
66
|
+
};
|
|
67
|
+
export interface SnapshotServeInputs {
|
|
68
|
+
readonly now: number;
|
|
69
|
+
/** Epoch ms of the cached frame, or null when nothing is cached. */
|
|
70
|
+
readonly cachedAt: number | null;
|
|
71
|
+
/** Effective per-device max cache age in ms (battery-aware default or override). */
|
|
72
|
+
readonly effectiveMaxAgeMs: number;
|
|
73
|
+
/** Caller forced a refresh (UI refresh button) — bypass the freshness gate. */
|
|
74
|
+
readonly force: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Pure serve/refresh decision.
|
|
78
|
+
*
|
|
79
|
+
* - fresh cache + !force -> serve-cache
|
|
80
|
+
* - stale cache + !force -> SWR: wait SWR_STALE_WAIT_MS, then stale
|
|
81
|
+
* - no cache + !force -> cold: wait SWR_COLD_WAIT_MS, no fallback
|
|
82
|
+
* - force (cache present or not) -> prefer fresh: wait SWR_COLD_WAIT_MS,
|
|
83
|
+
* fall back to stale only if a frame exists
|
|
84
|
+
*/
|
|
85
|
+
export declare function decideSnapshotServe(input: SnapshotServeInputs): SnapshotServeDecision;
|
|
86
|
+
/**
|
|
87
|
+
* A counting semaphore. `run` acquires a permit, awaits `fn`, and releases the
|
|
88
|
+
* permit (even if `fn` throws), so at most `max` `fn`s run concurrently. Waiters
|
|
89
|
+
* are served FIFO.
|
|
90
|
+
*/
|
|
91
|
+
export declare class Semaphore {
|
|
92
|
+
private available;
|
|
93
|
+
private readonly waiters;
|
|
94
|
+
constructor(max: number);
|
|
95
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
96
|
+
private acquire;
|
|
97
|
+
private makeReleaser;
|
|
98
|
+
}
|
|
99
|
+
export declare class SingleFlight<T> {
|
|
100
|
+
private readonly holdMs;
|
|
101
|
+
private readonly holdWorthy;
|
|
102
|
+
private readonly now;
|
|
103
|
+
private readonly flights;
|
|
104
|
+
/**
|
|
105
|
+
* @param holdMs how long a settled hold-worthy result is reused
|
|
106
|
+
* @param holdWorthy predicate deciding whether a settled value is worth holding
|
|
107
|
+
* (e.g. a successful capture with a real frame)
|
|
108
|
+
* @param now clock injection for tests
|
|
109
|
+
*/
|
|
110
|
+
constructor(holdMs: number, holdWorthy: (value: T) => boolean, now?: () => number);
|
|
111
|
+
run(key: string, factory: () => Promise<T>): Promise<T>;
|
|
112
|
+
/** Drop every held flight whose key starts with `prefix` (e.g. `"<deviceId>:"`). */
|
|
113
|
+
invalidatePrefix(prefix: string): void;
|
|
114
|
+
clear(): void;
|
|
115
|
+
private isReusable;
|
|
116
|
+
}
|
|
117
|
+
export type RaceOutcome<T> = {
|
|
118
|
+
readonly settled: true;
|
|
119
|
+
readonly value: T;
|
|
120
|
+
} | {
|
|
121
|
+
readonly settled: false;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Race `promise` against a `timeoutMs` bound WITHOUT cancelling it — on timeout
|
|
125
|
+
* the promise keeps running (its result lands in the cache for the next poll).
|
|
126
|
+
* Rejections propagate: if `promise` rejects before the timeout, this rejects.
|
|
127
|
+
* The caller passes a promise that never rejects (a settled outcome union) when
|
|
128
|
+
* background continuation must stay unhandled-safe.
|
|
129
|
+
*/
|
|
130
|
+
export declare function raceForResult<T>(promise: Promise<T>, timeoutMs: number): Promise<RaceOutcome<T>>;
|
|
@@ -30,6 +30,16 @@ interface SnapshotAddonConfig {
|
|
|
30
30
|
*/
|
|
31
31
|
export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
|
|
32
32
|
private readonly cache;
|
|
33
|
+
/**
|
|
34
|
+
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
35
|
+
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
36
|
+
* buttons) collapses into one capture instead of one dial per tile.
|
|
37
|
+
*/
|
|
38
|
+
private readonly captureFlight;
|
|
39
|
+
/** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
|
|
40
|
+
private readonly grabPool;
|
|
41
|
+
/** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
|
|
42
|
+
private readonly nativePool;
|
|
33
43
|
/**
|
|
34
44
|
* Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
|
|
35
45
|
* has no per-request attach hook to resolve this fresh, so it's cached with
|
|
@@ -55,11 +65,31 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
|
|
|
55
65
|
protected globalSettingsSchema(): import('@camstack/types').ConfigUISchema;
|
|
56
66
|
private getSnapshot;
|
|
57
67
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
68
|
+
* Map a settled capture outcome onto the response, preserving the legacy
|
|
69
|
+
* semantics: a fresh frame wins; a soft miss (native null + broker null)
|
|
70
|
+
* falls back to stale cache (or null); a HARD native error with no frame
|
|
71
|
+
* and no cache propagates.
|
|
62
72
|
*/
|
|
73
|
+
private resolveOutcome;
|
|
74
|
+
/**
|
|
75
|
+
* Run the capture ladder ONCE for a device: native provider first, then the
|
|
76
|
+
* stream-broker ffmpeg fallback. Never rejects — resolves a {@link
|
|
77
|
+
* CaptureOutcome}. On a produced frame it populates the cache (so a
|
|
78
|
+
* background SWR refresh lands for the next poll) and returns it. A hard
|
|
79
|
+
* native error with no broker frame resolves `{ ok:false }` so the caller
|
|
80
|
+
* can surface it; a soft miss resolves `{ ok:true, image:null }`.
|
|
81
|
+
*
|
|
82
|
+
* TODO(decoded-frame fast path): for a device whose detection pipeline is
|
|
83
|
+
* already decoding (the always-on subscription, see docs/design/decode-path.md),
|
|
84
|
+
* the freshest decoded frame is already in the shared-memory ring — a single
|
|
85
|
+
* JPEG encode of the latest FrameHandle would cost zero dials and beat the
|
|
86
|
+
* ffmpeg grab for the wrapper fleet. Slotting it as native -> latest-decoded
|
|
87
|
+
* -> broker needs cross-addon plumbing not cheaply reachable from the hub
|
|
88
|
+
* builtin today: frames are session-scoped (decoder `pullHandles`/`getFrame`
|
|
89
|
+
* need the device's active session id) and raw-pixel (need a JPEG encoder the
|
|
90
|
+
* builtin lacks). Deferred as its own slice.
|
|
91
|
+
*/
|
|
92
|
+
private captureFresh;
|
|
63
93
|
/**
|
|
64
94
|
* Pull one JPEG from the device's stream-broker RTSP restream using
|
|
65
95
|
* a short-lived ffmpeg invocation.
|
|
@@ -138,6 +168,12 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
|
|
|
138
168
|
* host) is byte-identical to the previous unpinned call.
|
|
139
169
|
*/
|
|
140
170
|
private hasStreamingBrokerForDevice;
|
|
171
|
+
/**
|
|
172
|
+
* Cache-only batch overview — O(n) over the in-memory cache, NEVER triggers
|
|
173
|
+
* a capture. Lets a grid skip requesting images for devices that never
|
|
174
|
+
* produced a frame, and hands each an ETag for conditional image fetches.
|
|
175
|
+
*/
|
|
176
|
+
private getSnapshotOverview;
|
|
141
177
|
/**
|
|
142
178
|
* Diagnostic status for the `status` auto-injected cap method. Reports
|
|
143
179
|
* the cache bookkeeping for this device — when the last snapshot was
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BP89I7gi.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_path = require("node:path");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { E as filesystemBrowseCapability, J as storageProviderCapability, et as BaseAddon } from "../../dist-
|
|
1
|
+
import { E as filesystemBrowseCapability, J as storageProviderCapability, et as BaseAddon } from "../../dist-C68OyuEd.mjs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path$1 from "node:path";
|
|
4
4
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-BP89I7gi.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
let better_sqlite3 = require("better-sqlite3");
|
|
9
9
|
better_sqlite3 = require_chunk.__toESM(better_sqlite3);
|