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