@camstack/addon-post-analysis 0.1.19 → 0.1.20

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.
Files changed (67) hide show
  1. package/dist/embedding-encoder/index.js +1 -1
  2. package/dist/embedding-encoder/index.mjs +1 -1
  3. package/dist/enrichment-engine/index.js +1 -1
  4. package/dist/enrichment-engine/index.mjs +1 -1
  5. package/dist/{index-BFbwYH1P.js → index-B0RhVv1c.js} +3514 -750
  6. package/dist/index-B0RhVv1c.js.map +1 -0
  7. package/dist/{index-BrTlzsrE.mjs → index-ot5PeFg_.mjs} +3517 -753
  8. package/dist/index-ot5PeFg_.mjs.map +1 -0
  9. package/dist/pipeline-analytics/@mf-types.zip +0 -0
  10. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs} +1 -1
  11. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-BD3oMNGB.mjs +29 -0
  12. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BgOHCakr.mjs +18 -0
  13. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-BZTB2scQ.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-D1qPKjvR.mjs} +2 -1
  14. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CJO5YKGV.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-B5X50Xa4.mjs} +1 -1
  15. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B0h0AGOH.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B10b5k5J.mjs} +1 -1
  16. package/dist/pipeline-analytics/_stub.js +2 -3
  17. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-kZBmgzMg.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DWB3apaJ.mjs} +6 -6
  18. package/dist/pipeline-analytics/{client-BlxIUpgf.mjs → client-C6xdgLZU.mjs} +2 -2
  19. package/dist/pipeline-analytics/{hostInit-qBB1Thhi.mjs → hostInit-3cyL9eyG.mjs} +12 -12
  20. package/dist/pipeline-analytics/{index-Dw6Q30NI.mjs → index-BCTHeI2m.mjs} +253 -267
  21. package/dist/pipeline-analytics/{index-DlhiA9R0.mjs → index-BuWLz0GG.mjs} +1 -1
  22. package/dist/pipeline-analytics/{index-DtdgkNgf.mjs → index-CIwq-tQL.mjs} +1 -1
  23. package/dist/pipeline-analytics/{index-BoL0rgZt.mjs → index-CWBMDbou.mjs} +1 -1
  24. package/dist/pipeline-analytics/index-CZhagnlH.mjs +67784 -0
  25. package/dist/pipeline-analytics/{index-CR1aiZDH.mjs → index-D883Q5B8.mjs} +1 -1
  26. package/dist/pipeline-analytics/{index-Dy2V7VOm.mjs → index-DtOI1aTU.mjs} +10112 -5987
  27. package/dist/pipeline-analytics/index.js +605 -42
  28. package/dist/pipeline-analytics/index.js.map +1 -1
  29. package/dist/pipeline-analytics/index.mjs +604 -42
  30. package/dist/pipeline-analytics/index.mjs.map +1 -1
  31. package/dist/pipeline-analytics/{jsx-runtime-Dlbl3gpr.mjs → jsx-runtime-DdLhuHmJ.mjs} +1 -1
  32. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  33. package/dist/pipeline-analytics/{schemas-ClCuS4qa.mjs → schemas-B7L0qZtq.mjs} +411 -406
  34. package/package.json +12 -27
  35. package/dist/ffmpeg-config-DRONlBsj.mjs +0 -56
  36. package/dist/ffmpeg-config-DRONlBsj.mjs.map +0 -1
  37. package/dist/ffmpeg-config-uANz3sV5.js +0 -73
  38. package/dist/ffmpeg-config-uANz3sV5.js.map +0 -1
  39. package/dist/index-BFbwYH1P.js.map +0 -1
  40. package/dist/index-BrTlzsrE.mjs.map +0 -1
  41. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.mjs +0 -19
  42. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-7HAAnpQu.mjs +0 -18
  43. package/dist/pipeline-analytics/index-i47purqY.mjs +0 -37880
  44. package/dist/playlist-generator-EhPaB7Hn.js +0 -48
  45. package/dist/playlist-generator-EhPaB7Hn.js.map +0 -1
  46. package/dist/playlist-generator-VTkgn53O.mjs +0 -48
  47. package/dist/playlist-generator-VTkgn53O.mjs.map +0 -1
  48. package/dist/recording/index.js +0 -257
  49. package/dist/recording/index.js.map +0 -1
  50. package/dist/recording/index.mjs +0 -235
  51. package/dist/recording/index.mjs.map +0 -1
  52. package/dist/recording-coordinator-BoGr5moz.js +0 -1052
  53. package/dist/recording-coordinator-BoGr5moz.js.map +0 -1
  54. package/dist/recording-coordinator-CsYH9LqF.mjs +0 -1012
  55. package/dist/recording-coordinator-CsYH9LqF.mjs.map +0 -1
  56. package/dist/recording-db-gOgaoQh0.js +0 -348
  57. package/dist/recording-db-gOgaoQh0.js.map +0 -1
  58. package/dist/recording-db-lIkSMTLq.mjs +0 -348
  59. package/dist/recording-db-lIkSMTLq.mjs.map +0 -1
  60. package/dist/recording-service-facade-B9lG6OFn.mjs +0 -123
  61. package/dist/recording-service-facade-B9lG6OFn.mjs.map +0 -1
  62. package/dist/recording-service-facade-Do1PKlAL.js +0 -123
  63. package/dist/recording-service-facade-Do1PKlAL.js.map +0 -1
  64. package/dist/storage-estimator-CRpoQc9j.js +0 -72
  65. package/dist/storage-estimator-CRpoQc9j.js.map +0 -1
  66. package/dist/storage-estimator-DzD8gWJH.mjs +0 -72
  67. package/dist/storage-estimator-DzD8gWJH.mjs.map +0 -1
@@ -1,1012 +0,0 @@
1
- import { E as EventCategory } from "./index-BrTlzsrE.mjs";
2
- import { buildFfmpegInputArgs, buildFfmpegOutputArgs, resolveFfmpegConfig } from "./ffmpeg-config-DRONlBsj.mjs";
3
- import { randomBytes } from "node:crypto";
4
- import { spawn } from "node:child_process";
5
- import * as fs from "node:fs/promises";
6
- import * as path from "node:path";
7
- import sharp from "sharp";
8
- import { PlaylistGenerator } from "./playlist-generator-VTkgn53O.mjs";
9
- import { StorageEstimator } from "./storage-estimator-DzD8gWJH.mjs";
10
- class SegmentRingBuffer {
11
- constructor(maxDurationSec) {
12
- this.maxDurationSec = maxDurationSec;
13
- }
14
- segments = [];
15
- totalDurationSec = 0;
16
- push(segment) {
17
- this.segments.push(segment);
18
- this.totalDurationSec += segment.duration;
19
- while (this.totalDurationSec > this.maxDurationSec && this.segments.length > 1) {
20
- const evicted = this.segments.shift();
21
- this.totalDurationSec -= evicted.duration;
22
- }
23
- }
24
- flush() {
25
- const result = [...this.segments];
26
- this.segments = [];
27
- this.totalDurationSec = 0;
28
- return result;
29
- }
30
- get memoryEstimateBytes() {
31
- return this.segments.reduce((sum, s) => sum + s.data.length, 0);
32
- }
33
- }
34
- class SegmentWriter {
35
- constructor(config, logger, eventBus, db, _networkTracker) {
36
- this.config = config;
37
- this.logger = logger;
38
- this.eventBus = eventBus;
39
- this.db = db;
40
- this._mode = config.mode;
41
- this.ringBuffer = new SegmentRingBuffer(config.preBufferSec);
42
- }
43
- _state = "idle";
44
- _mode;
45
- ffmpeg = null;
46
- activeSegment = null;
47
- restartCount = 0;
48
- restartWindowStart = 0;
49
- healthTimer = null;
50
- lastDataTime = 0;
51
- ringBuffer;
52
- restartTimeout = null;
53
- pendingFinalization = null;
54
- paused = false;
55
- detectedCodec = "h264";
56
- detectedHasAudio = false;
57
- static MAX_RESTARTS = 10;
58
- static RESTART_WINDOW_MS = 5 * 60 * 1e3;
59
- static HEALTH_CHECK_INTERVAL_MS = 5e3;
60
- static DATA_TIMEOUT_MS = 15e3;
61
- static MIN_SEGMENT_DURATION_SEC = 0.5;
62
- static CRITICAL_DISK_GB = 1;
63
- get state() {
64
- return this._state;
65
- }
66
- get mode() {
67
- return this._mode;
68
- }
69
- get isPaused() {
70
- return this.paused;
71
- }
72
- // --- Public API ---
73
- async start(rtspUrl) {
74
- if (this._state !== "idle") return;
75
- const segmentDir = path.join(
76
- this.config.storagePath,
77
- this.config.subDirectory,
78
- String(this.config.deviceId)
79
- );
80
- await fs.mkdir(segmentDir, { recursive: true });
81
- this._state = "recording";
82
- this.lastDataTime = Date.now();
83
- this.restartCount = 0;
84
- this.restartWindowStart = Date.now();
85
- const segmentPattern = path.join(segmentDir, "%d.mp4");
86
- const args = SegmentWriter.buildSegmentationArgs(
87
- this.config.ffmpeg,
88
- rtspUrl,
89
- segmentPattern,
90
- this.config.segmentDurationSec
91
- );
92
- this.spawnFfmpeg(args, rtspUrl);
93
- this.startHealthCheck(rtspUrl);
94
- }
95
- async stop() {
96
- if (this._state === "idle") return;
97
- this._state = "stopping";
98
- this.stopHealthCheck();
99
- this.clearRestartTimeout();
100
- this.killFfmpeg();
101
- this.finalizeActiveSegment();
102
- if (this.pendingFinalization) {
103
- await this.pendingFinalization;
104
- }
105
- this._state = "idle";
106
- }
107
- resume(rtspUrl) {
108
- if (!this.paused) return;
109
- this.paused = false;
110
- this.logger.info("Resuming recording after disk space freed", {
111
- tags: { deviceId: this.config.deviceId }
112
- });
113
- this._state = "idle";
114
- void this.start(rtspUrl);
115
- }
116
- async flushAndContinue() {
117
- if (this._mode !== "buffer") return;
118
- const buffered = this.ringBuffer.flush();
119
- this.logger.info("Flushing buffered segments to disk", {
120
- tags: { deviceId: this.config.deviceId },
121
- meta: { count: buffered.length }
122
- });
123
- for (const seg of buffered) {
124
- await this.writeBufferedSegmentToDisk(seg);
125
- }
126
- this._mode = "continuous";
127
- }
128
- switchToBuffer() {
129
- this._mode = "buffer";
130
- this.ringBuffer = new SegmentRingBuffer(this.config.preBufferSec);
131
- }
132
- // --- Static helpers ---
133
- static generateSegmentId(deviceId, streamId, startTime) {
134
- const suffix = randomBytes(2).toString("hex");
135
- return `${deviceId}_${streamId}_${startTime}_${suffix}`;
136
- }
137
- static buildSegmentationArgs(config, inputUrl, outputPattern, segmentDuration) {
138
- const inputArgs = buildFfmpegInputArgs(config, inputUrl);
139
- const outputArgs = buildFfmpegOutputArgs(config);
140
- const segmentArgs = [
141
- "-f",
142
- "segment",
143
- "-segment_time",
144
- String(segmentDuration),
145
- "-segment_format",
146
- "mp4",
147
- "-movflags",
148
- "+frag_keyframe+empty_moov+default_base_moof",
149
- "-reset_timestamps",
150
- "1",
151
- "-strftime",
152
- "0"
153
- ];
154
- return [...inputArgs, ...outputArgs, ...segmentArgs, outputPattern];
155
- }
156
- static async checkDiskSpace(storagePath, statfsFn) {
157
- const doStatfs = statfsFn ?? (async (p) => {
158
- const { statfs: nodeStatfs } = await import("node:fs/promises");
159
- return nodeStatfs(p);
160
- });
161
- try {
162
- const stats = await doStatfs(storagePath);
163
- const availableBytes = stats.bfree * stats.bsize;
164
- const availableGb = availableBytes / (1024 * 1024 * 1024);
165
- return { ok: availableGb >= SegmentWriter.CRITICAL_DISK_GB, availableGb };
166
- } catch {
167
- return { ok: true, availableGb: -1 };
168
- }
169
- }
170
- // --- Private: ffmpeg process management ---
171
- spawnFfmpeg(args, rtspUrl) {
172
- this.ffmpeg = spawn(this.config.ffmpeg.path, args, {
173
- stdio: ["ignore", "pipe", "pipe"]
174
- });
175
- this.ffmpeg.stdout?.on("data", () => {
176
- this.lastDataTime = Date.now();
177
- });
178
- this.ffmpeg.stderr?.on("data", (data) => {
179
- this.lastDataTime = Date.now();
180
- const msg = data.toString().trim();
181
- if (msg) {
182
- this.logger.debug("ffmpeg stderr", { meta: { msg } });
183
- this.parseSegmentOutput(msg);
184
- }
185
- });
186
- this.ffmpeg.on("error", (err) => {
187
- this.logger.warn("ffmpeg process error", { meta: { error: err.message } });
188
- this.handleCrash(rtspUrl);
189
- });
190
- this.ffmpeg.on("exit", (code) => {
191
- if (code !== 0 && code !== null && this._state === "recording") {
192
- this.logger.warn("ffmpeg exited with non-zero code", { meta: { code } });
193
- this.handleCrash(rtspUrl);
194
- }
195
- });
196
- }
197
- handleCrash(rtspUrl) {
198
- this.ffmpeg = null;
199
- const prevFinalization = this.pendingFinalization;
200
- this.pendingFinalization = (prevFinalization ?? Promise.resolve()).then(() => {
201
- return this.finalizeActiveSegment();
202
- });
203
- if (this._state !== "recording") return;
204
- if (this.paused) return;
205
- const now = Date.now();
206
- if (now - this.restartWindowStart > SegmentWriter.RESTART_WINDOW_MS) {
207
- this.restartCount = 0;
208
- this.restartWindowStart = now;
209
- }
210
- this.restartCount++;
211
- this.eventBus.emit({
212
- id: `rec-err-${now}`,
213
- timestamp: /* @__PURE__ */ new Date(),
214
- source: { type: "addon", id: "recording-engine" },
215
- category: EventCategory.RecordingError,
216
- data: {
217
- deviceId: this.config.deviceId,
218
- streamId: this.config.streamId,
219
- restartAttempt: this.restartCount
220
- }
221
- });
222
- if (this.restartCount > SegmentWriter.MAX_RESTARTS) {
223
- this.logger.error("Max restarts exceeded", {
224
- tags: { deviceId: this.config.deviceId, streamId: this.config.streamId }
225
- });
226
- this._state = "idle";
227
- this.eventBus.emit({
228
- id: `rec-degraded-${now}`,
229
- timestamp: /* @__PURE__ */ new Date(),
230
- source: { type: "addon", id: "recording-engine" },
231
- category: EventCategory.RecordingHealthDegraded,
232
- data: {
233
- deviceId: this.config.deviceId,
234
- streamId: this.config.streamId
235
- }
236
- });
237
- return;
238
- }
239
- const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.restartCount - 1));
240
- this.logger.info("Restarting ffmpeg", { meta: { backoffMs, attempt: this.restartCount } });
241
- this.restartTimeout = setTimeout(() => {
242
- this.restartTimeout = null;
243
- if (this._state === "recording") {
244
- const segmentDir = path.join(
245
- this.config.storagePath,
246
- this.config.subDirectory,
247
- String(this.config.deviceId)
248
- );
249
- const segmentPattern = path.join(segmentDir, "%d.mp4");
250
- const args = SegmentWriter.buildSegmentationArgs(
251
- this.config.ffmpeg,
252
- rtspUrl,
253
- segmentPattern,
254
- this.config.segmentDurationSec
255
- );
256
- this.spawnFfmpeg(args, rtspUrl);
257
- }
258
- }, backoffMs);
259
- }
260
- // --- Private: health monitoring ---
261
- startHealthCheck(rtspUrl) {
262
- this.healthTimer = setInterval(() => {
263
- if (this._state !== "recording") return;
264
- const elapsed = Date.now() - this.lastDataTime;
265
- if (elapsed > SegmentWriter.DATA_TIMEOUT_MS) {
266
- this.logger.warn("No data received, restarting ffmpeg", { meta: { elapsedMs: elapsed } });
267
- this.killFfmpeg();
268
- this.handleCrash(rtspUrl);
269
- }
270
- }, SegmentWriter.HEALTH_CHECK_INTERVAL_MS);
271
- }
272
- stopHealthCheck() {
273
- if (this.healthTimer) {
274
- clearInterval(this.healthTimer);
275
- this.healthTimer = null;
276
- }
277
- }
278
- clearRestartTimeout() {
279
- if (this.restartTimeout) {
280
- clearTimeout(this.restartTimeout);
281
- this.restartTimeout = null;
282
- }
283
- }
284
- killFfmpeg() {
285
- if (this.ffmpeg) {
286
- this.ffmpeg.kill("SIGTERM");
287
- this.ffmpeg = null;
288
- }
289
- }
290
- // --- Private: segment parsing and finalization ---
291
- parseSegmentOutput(msg) {
292
- const videoMatch = msg.match(/Stream\s+#\d+:\d+.*Video:\s+(h264|hevc|h265)/i);
293
- if (videoMatch) {
294
- const codec = videoMatch[1].toLowerCase();
295
- this.detectedCodec = codec === "hevc" || codec === "h265" ? "h265" : "h264";
296
- }
297
- const audioMatch = msg.match(/Stream\s+#\d+:\d+.*Audio:/i);
298
- if (audioMatch) {
299
- this.detectedHasAudio = true;
300
- }
301
- const openMatch = msg.match(/Opening '(.+\.mp4)' for writing/);
302
- if (openMatch) {
303
- const prevFinalization = this.pendingFinalization;
304
- this.pendingFinalization = (prevFinalization ?? Promise.resolve()).then(() => {
305
- return this.finalizeActiveSegment();
306
- });
307
- const absolutePath = openMatch[1];
308
- const segPath = absolutePath.startsWith(this.config.storagePath) ? absolutePath.slice(this.config.storagePath.length).replace(/^\//, "") : absolutePath;
309
- this.activeSegment = {
310
- id: SegmentWriter.generateSegmentId(
311
- this.config.deviceId,
312
- this.config.streamId,
313
- Date.now()
314
- ),
315
- path: segPath,
316
- startTime: Date.now()
317
- };
318
- }
319
- }
320
- async finalizeActiveSegment() {
321
- if (!this.activeSegment) return;
322
- const seg = this.activeSegment;
323
- this.activeSegment = null;
324
- const endTime = Date.now();
325
- const duration = (endTime - seg.startTime) / 1e3;
326
- if (duration < SegmentWriter.MIN_SEGMENT_DURATION_SEC) return;
327
- if (this._mode === "buffer") {
328
- await this.bufferSegmentFromDisk(seg, endTime, duration);
329
- return;
330
- }
331
- await this.finalizeSegmentToDisk(seg, endTime, duration);
332
- }
333
- async bufferSegmentFromDisk(seg, _endTime, duration) {
334
- try {
335
- const data = await fs.readFile(seg.path);
336
- this.ringBuffer.push({ data, startTime: seg.startTime, duration });
337
- await fs.unlink(seg.path).catch(() => {
338
- });
339
- } catch (err) {
340
- this.logger.warn("Failed to buffer segment", { meta: { error: String(err) } });
341
- }
342
- }
343
- async finalizeSegmentToDisk(seg, endTime, duration) {
344
- try {
345
- const diskCheck = await SegmentWriter.checkDiskSpace(this.config.storagePath);
346
- if (!diskCheck.ok) {
347
- this.eventBus.emit({
348
- id: `storage-critical-${Date.now()}`,
349
- timestamp: /* @__PURE__ */ new Date(),
350
- source: { type: "addon", id: "recording-engine" },
351
- category: EventCategory.RecordingStorageCritical,
352
- data: {
353
- storageId: this.config.storageName,
354
- availableGB: diskCheck.availableGb
355
- }
356
- });
357
- this.logger.error("Disk space critically low, pausing recording");
358
- this.paused = true;
359
- this.killFfmpeg();
360
- this._state = "idle";
361
- return;
362
- }
363
- let sizeBytes = 0;
364
- try {
365
- const fileStat = await fs.stat(seg.path);
366
- sizeBytes = fileStat.size;
367
- } catch {
368
- }
369
- const codec = this.detectedCodec;
370
- const hasAudio = this.detectedHasAudio;
371
- const segment = {
372
- id: seg.id,
373
- deviceId: this.config.deviceId,
374
- streamId: this.config.streamId,
375
- startTime: seg.startTime,
376
- endTime,
377
- duration,
378
- path: seg.path,
379
- storageName: this.config.storageName,
380
- subDirectory: this.config.subDirectory,
381
- sizeBytes,
382
- codec,
383
- hasAudio
384
- };
385
- try {
386
- this.db.insertSegment(segment);
387
- this.eventBus.emit({
388
- id: `seg-${seg.id}`,
389
- timestamp: /* @__PURE__ */ new Date(),
390
- source: { type: "addon", id: "recording-engine" },
391
- category: EventCategory.RecordingSegmentWritten,
392
- data: {
393
- deviceId: this.config.deviceId,
394
- streamId: this.config.streamId,
395
- segmentId: seg.id,
396
- duration,
397
- sizeBytes
398
- }
399
- });
400
- } catch (err) {
401
- this.logger.error("Failed to insert segment", { meta: { error: String(err) } });
402
- }
403
- } catch (err) {
404
- this.logger.error("Disk space check failed", { meta: { error: String(err) } });
405
- }
406
- }
407
- async writeBufferedSegmentToDisk(buffered) {
408
- const segId = SegmentWriter.generateSegmentId(
409
- this.config.deviceId,
410
- this.config.streamId,
411
- buffered.startTime
412
- );
413
- const relativePath = `${this.config.subDirectory}/${this.config.deviceId}/${segId}.mp4`;
414
- try {
415
- await this.config.fileStorage?.writeFile(relativePath, buffered.data);
416
- const sizeBytes = buffered.data.length;
417
- const segment = {
418
- id: segId,
419
- deviceId: this.config.deviceId,
420
- streamId: this.config.streamId,
421
- startTime: buffered.startTime,
422
- endTime: buffered.startTime + buffered.duration * 1e3,
423
- duration: buffered.duration,
424
- path: relativePath,
425
- storageName: this.config.storageName,
426
- subDirectory: this.config.subDirectory,
427
- sizeBytes,
428
- codec: this.detectedCodec,
429
- hasAudio: this.detectedHasAudio
430
- };
431
- this.db.insertSegment(segment);
432
- this.eventBus.emit({
433
- id: `seg-${segId}`,
434
- timestamp: /* @__PURE__ */ new Date(),
435
- source: { type: "addon", id: "recording-engine" },
436
- category: EventCategory.RecordingSegmentWritten,
437
- data: {
438
- deviceId: this.config.deviceId,
439
- streamId: this.config.streamId,
440
- segmentId: segId,
441
- duration: buffered.duration,
442
- sizeBytes
443
- }
444
- });
445
- } catch (err) {
446
- this.logger.error("Failed to write buffered segment to disk", { meta: { error: String(err) } });
447
- }
448
- }
449
- }
450
- class ThumbnailExtractor {
451
- constructor(config, logger, db) {
452
- this.config = config;
453
- this.logger = logger;
454
- this.db = db;
455
- }
456
- id = "thumbnail-extractor";
457
- name = "Thumbnail Extractor";
458
- needsAudio = false;
459
- videoRequirements = {
460
- keyframeOnly: true,
461
- maxFps: 1,
462
- format: "jpeg"
463
- };
464
- unsubscribe = null;
465
- active = false;
466
- attachToPipeline(pipeline, _deviceId) {
467
- this.active = true;
468
- this.unsubscribe = pipeline.onVideoFrame(
469
- (frame) => {
470
- this.handleFrame(frame).catch((err) => this.logger.debug("Thumbnail error", { meta: { error: String(err) } }));
471
- },
472
- this.videoRequirements
473
- );
474
- this.logger.info("ThumbnailExtractor attached", { tags: { deviceId: this.config.deviceId } });
475
- }
476
- detachFromPipeline(_deviceId) {
477
- this.active = false;
478
- if (this.unsubscribe) {
479
- this.unsubscribe();
480
- this.unsubscribe = null;
481
- }
482
- this.logger.info("ThumbnailExtractor detached", { tags: { deviceId: this.config.deviceId } });
483
- }
484
- setActive(active) {
485
- this.active = active;
486
- }
487
- async handleFrame(frame) {
488
- if (!this.active) return;
489
- const timestamp = frame.timestamp || Date.now();
490
- const relativePath = ThumbnailExtractor.thumbnailPath(
491
- this.config.subDirectory,
492
- this.config.deviceId,
493
- timestamp
494
- );
495
- const resized = await sharp(frame.data).resize({ width: this.config.maxWidthPx, withoutEnlargement: true }).jpeg({ quality: this.config.jpegQuality }).toBuffer();
496
- await this.config.fileStorage?.writeFile(relativePath, resized);
497
- this.db.insertThumbnail({
498
- deviceId: this.config.deviceId,
499
- timestamp,
500
- path: relativePath,
501
- storageName: this.config.storageName,
502
- subDirectory: this.config.subDirectory,
503
- sizeBytes: resized.length,
504
- category: "scrub"
505
- });
506
- }
507
- static thumbnailPath(subDirectory, deviceId, timestamp) {
508
- return `${subDirectory}/${deviceId}/${timestamp}.jpg`;
509
- }
510
- }
511
- const NORMAL_INTERVAL_MS = 5 * 60 * 1e3;
512
- const HIGH_USAGE_INTERVAL_MS = 30 * 1e3;
513
- const STORAGE_WARNING_THRESHOLD = 0.8;
514
- const STORAGE_CRITICAL_THRESHOLD = 0.95;
515
- const STORAGE_HIGH_USAGE_THRESHOLD = 0.9;
516
- class RetentionManager {
517
- constructor(db, logger, eventBus, storageProvider) {
518
- this.db = db;
519
- this.logger = logger;
520
- this.eventBus = eventBus;
521
- this.storageProvider = storageProvider;
522
- }
523
- timer = null;
524
- start() {
525
- this.scheduleNextCycle(NORMAL_INTERVAL_MS);
526
- }
527
- stop() {
528
- if (this.timer) {
529
- clearTimeout(this.timer);
530
- this.timer = null;
531
- }
532
- }
533
- async runCycle() {
534
- this.db.resetStaleCleanups();
535
- const policies = this.db.getEnabledPolicies();
536
- let totalFreedBytes = 0;
537
- let totalDeletedSegments = 0;
538
- let highUsage = false;
539
- for (const policy of policies) {
540
- for (const sp of policy.streams) {
541
- const category = `recording:${sp.streamId}`;
542
- const config = this.db.resolveStorageConfig(policy.deviceId, category);
543
- if (!config) continue;
544
- if (config.retentionDays !== null) {
545
- const cutoff = Date.now() - config.retentionDays * 864e5;
546
- const deleted = this.db.deleteSegmentsBefore(policy.deviceId, sp.streamId, cutoff);
547
- totalDeletedSegments += deleted.length;
548
- for (const seg of deleted) {
549
- totalFreedBytes += seg.sizeBytes;
550
- await this.deleteFile(seg.path);
551
- }
552
- this.db.deleteThumbnailsBefore(policy.deviceId, cutoff);
553
- }
554
- if (config.retentionGb !== null) {
555
- const maxBytes = config.retentionGb * 1024 * 1024 * 1024;
556
- let usage = this.db.getStorageUsage(policy.deviceId, sp.streamId);
557
- const usageRatio = usage.totalBytes / maxBytes;
558
- if (usageRatio > STORAGE_CRITICAL_THRESHOLD) {
559
- this.emitStorageEvent("recording.storage.critical", policy.deviceId, sp.streamId, usageRatio);
560
- } else if (usageRatio > STORAGE_WARNING_THRESHOLD) {
561
- this.emitStorageEvent("recording.storage.warning", policy.deviceId, sp.streamId, usageRatio);
562
- }
563
- if (usageRatio > STORAGE_HIGH_USAGE_THRESHOLD) {
564
- highUsage = true;
565
- }
566
- while (usage.totalBytes > maxBytes && usage.segmentCount > 0) {
567
- const oldest = this.db.getOldestSegments(policy.deviceId, sp.streamId, 10);
568
- if (oldest.length === 0) break;
569
- for (const seg of oldest) {
570
- this.db.deleteSegmentsBefore(policy.deviceId, sp.streamId, seg.endTime + 1);
571
- totalFreedBytes += seg.sizeBytes;
572
- totalDeletedSegments++;
573
- await this.deleteFile(seg.path);
574
- }
575
- usage = this.db.getStorageUsage(policy.deviceId, sp.streamId);
576
- }
577
- }
578
- }
579
- }
580
- const pending = this.db.getPendingCleanups();
581
- for (const entry of pending) {
582
- this.db.markCleanupInProgress(entry.deviceId);
583
- try {
584
- const deleted = this.db.deleteSegmentsForDevice(entry.deviceId);
585
- for (const seg of deleted) {
586
- totalFreedBytes += seg.sizeBytes;
587
- totalDeletedSegments++;
588
- await this.deleteFile(seg.path);
589
- }
590
- this.db.deleteThumbnailsForDevice(entry.deviceId);
591
- this.db.markCleanupCompleted(entry.deviceId);
592
- } catch (err) {
593
- this.logger.error("Cleanup failed", { tags: { deviceId: entry.deviceId }, meta: { error: String(err) } });
594
- }
595
- }
596
- if (totalDeletedSegments > 0) {
597
- this.eventBus.emit({
598
- id: `retention-${Date.now()}`,
599
- timestamp: /* @__PURE__ */ new Date(),
600
- source: { type: "addon", id: "recording-engine" },
601
- category: EventCategory.RecordingRetentionCompleted,
602
- data: {
603
- freedMB: Math.round(totalFreedBytes / 1024 / 1024),
604
- deletedSegments: totalDeletedSegments
605
- }
606
- });
607
- }
608
- return highUsage;
609
- }
610
- scheduleNextCycle(intervalMs) {
611
- this.timer = setTimeout(async () => {
612
- try {
613
- const storageHighUsage = await this.runCycle();
614
- const nextInterval = storageHighUsage ? HIGH_USAGE_INTERVAL_MS : NORMAL_INTERVAL_MS;
615
- this.scheduleNextCycle(nextInterval);
616
- } catch (err) {
617
- this.logger.error("Retention cycle error", { meta: { error: String(err) } });
618
- this.scheduleNextCycle(NORMAL_INTERVAL_MS);
619
- }
620
- }, intervalMs);
621
- }
622
- emitStorageEvent(category, deviceId, streamId, usageRatio) {
623
- this.eventBus.emit({
624
- id: `${category}-${deviceId}-${Date.now()}`,
625
- timestamp: /* @__PURE__ */ new Date(),
626
- source: { type: "addon", id: "recording-engine" },
627
- category,
628
- data: {
629
- deviceId,
630
- streamId,
631
- usagePercent: Math.round(usageRatio * 100)
632
- }
633
- });
634
- }
635
- async deleteFile(filePath) {
636
- try {
637
- await this.storageProvider.delete({ location: "recordings", relativePath: filePath });
638
- } catch {
639
- }
640
- }
641
- }
642
- const DEFAULT_SEGMENT_DURATION_SEC = 4;
643
- const POLICY_EVAL_INTERVAL_MS = 1e3;
644
- const MOTION_FALLBACK_TIMEOUT_MS = 6e4;
645
- class RecordingCoordinator {
646
- db;
647
- logger;
648
- eventBus;
649
- streamingEngine;
650
- pipelineManager;
651
- networkTracker;
652
- storageProvider;
653
- globalFfmpegConfig;
654
- detectedFfmpegConfig;
655
- segmentDurationSec;
656
- recordings = /* @__PURE__ */ new Map();
657
- policyTimer = null;
658
- retentionManager;
659
- playlistGenerator;
660
- storageEstimator;
661
- constructor(config) {
662
- this.db = config.db;
663
- this.logger = config.logger;
664
- this.eventBus = config.eventBus;
665
- this.streamingEngine = config.streamingEngine;
666
- this.pipelineManager = config.pipelineManager;
667
- this.networkTracker = config.networkTracker;
668
- this.storageProvider = config.storageProvider;
669
- this.globalFfmpegConfig = config.globalFfmpegConfig;
670
- this.detectedFfmpegConfig = config.detectedFfmpegConfig;
671
- this.segmentDurationSec = config.segmentDurationSec ?? DEFAULT_SEGMENT_DURATION_SEC;
672
- this.retentionManager = new RetentionManager(
673
- this.db,
674
- this.logger.child("retention"),
675
- this.eventBus,
676
- this.storageProvider
677
- );
678
- this.playlistGenerator = new PlaylistGenerator(this.db);
679
- this.storageEstimator = new StorageEstimator(this.db, this.networkTracker);
680
- }
681
- async start() {
682
- this.logger.info("RecordingCoordinator starting");
683
- this.retentionManager.start();
684
- const enabledPolicies = this.db.getEnabledPolicies();
685
- for (const policy of enabledPolicies) {
686
- try {
687
- await this.enableRecording(policy.deviceId, {
688
- policy: {
689
- mode: policy.mode,
690
- streams: policy.streams,
691
- enabled: policy.enabled,
692
- preBufferSec: policy.preBufferSec,
693
- postBufferSec: policy.postBufferSec,
694
- scheduleRules: policy.scheduleRules
695
- }
696
- });
697
- } catch (err) {
698
- this.logger.error("Failed to start recording", { tags: { deviceId: policy.deviceId }, meta: { error: String(err) } });
699
- }
700
- }
701
- this.policyTimer = setInterval(() => {
702
- this.evaluatePolicies();
703
- }, POLICY_EVAL_INTERVAL_MS);
704
- this.logger.info("RecordingCoordinator started");
705
- }
706
- stop() {
707
- this.logger.info("RecordingCoordinator stopping");
708
- if (this.policyTimer) {
709
- clearInterval(this.policyTimer);
710
- this.policyTimer = null;
711
- }
712
- this.retentionManager.stop();
713
- for (const [deviceId] of this.recordings) {
714
- this.stopRecordingInternal(deviceId);
715
- }
716
- this.recordings.clear();
717
- this.logger.info("RecordingCoordinator stopped");
718
- }
719
- async enableRecording(deviceId, config) {
720
- if (this.recordings.has(deviceId)) {
721
- this.stopRecordingInternal(deviceId);
722
- this.recordings.delete(deviceId);
723
- }
724
- const policy = {
725
- deviceId,
726
- mode: config.policy.mode,
727
- streams: config.policy.streams,
728
- enabled: config.policy.enabled,
729
- preBufferSec: config.policy.preBufferSec,
730
- postBufferSec: config.policy.postBufferSec,
731
- scheduleRules: config.policy.scheduleRules
732
- };
733
- this.db.upsertPolicy({
734
- deviceId,
735
- enabled: policy.enabled,
736
- mode: policy.mode,
737
- streams: policy.streams,
738
- preBufferSec: policy.preBufferSec,
739
- postBufferSec: policy.postBufferSec,
740
- scheduleRules: policy.scheduleRules
741
- });
742
- this.db.cancelCleanup(deviceId);
743
- const ffmpegConfig = resolveFfmpegConfig(
744
- config.ffmpegOverrides,
745
- this.globalFfmpegConfig,
746
- this.detectedFfmpegConfig
747
- );
748
- const writerMode = policy.mode === "motion" ? "buffer" : "continuous";
749
- const writers = [];
750
- for (const sp of policy.streams) {
751
- const storageConfig = this.db.resolveStorageConfig(deviceId, `recording:${sp.streamId}`);
752
- const storageName = storageConfig?.storageName ?? "recordings";
753
- const subDirectory = storageConfig?.subDirectory ?? `recordings/${sp.streamId}`;
754
- const resolvedStoragePath = await this.storageProvider.resolve({ location: storageName, relativePath: "" });
755
- const writerConfig = {
756
- deviceId,
757
- streamId: sp.streamId,
758
- segmentDurationSec: this.segmentDurationSec,
759
- storagePath: resolvedStoragePath,
760
- storageName,
761
- subDirectory,
762
- ffmpeg: ffmpegConfig,
763
- mode: writerMode,
764
- preBufferSec: policy.preBufferSec
765
- };
766
- const writer = new SegmentWriter(
767
- writerConfig,
768
- this.logger.child(`writer:${deviceId}:${sp.streamId}`),
769
- this.eventBus,
770
- this.db,
771
- this.networkTracker
772
- );
773
- const rtspUrl = this.streamingEngine.getStreamUrl(`${policy.deviceId}_${sp.streamId}`, "rtsp");
774
- if (rtspUrl) {
775
- await writer.start(rtspUrl);
776
- }
777
- writers.push(writer);
778
- }
779
- const thumbStorageConfig = this.db.resolveStorageConfig(deviceId, "thumbnail:scrub");
780
- const thumbStorageName = thumbStorageConfig?.storageName ?? "recordings";
781
- const thumbConfig = {
782
- deviceId,
783
- storagePath: await this.storageProvider.resolve({ location: thumbStorageName, relativePath: "" }),
784
- storageName: thumbStorageName,
785
- subDirectory: thumbStorageConfig?.subDirectory ?? "thumbnails/scrub",
786
- maxWidthPx: 160,
787
- jpegQuality: 65
788
- };
789
- const thumbnailExtractor = new ThumbnailExtractor(
790
- thumbConfig,
791
- this.logger.child(`thumb:${deviceId}`),
792
- this.db
793
- );
794
- const pipeline = this.pipelineManager.getPipeline(deviceId);
795
- if (pipeline) {
796
- thumbnailExtractor.attachToPipeline(pipeline, deviceId);
797
- }
798
- if (policy.mode === "motion") {
799
- thumbnailExtractor.setActive(false);
800
- }
801
- const motionUnsubscribe = this.subscribeToMotionEvents(deviceId, policy);
802
- const state = {
803
- deviceId,
804
- policy,
805
- writers,
806
- thumbnailExtractor,
807
- motionUnsubscribe,
808
- motionActive: false,
809
- motionTimeout: null,
810
- motionFallbackTimeout: null,
811
- motionReceived: false
812
- };
813
- this.recordings.set(deviceId, state);
814
- if (policy.mode === "motion") {
815
- state.motionFallbackTimeout = setTimeout(() => {
816
- const currentState = this.recordings.get(deviceId);
817
- if (!currentState || currentState.motionReceived) return;
818
- this.logger.warn("No motion events received — falling back to continuous recording", {
819
- tags: { deviceId },
820
- meta: { timeoutSec: MOTION_FALLBACK_TIMEOUT_MS / 1e3 }
821
- });
822
- this.eventBus.emit({
823
- id: `recording-policy-fallback-${deviceId}-${Date.now()}`,
824
- timestamp: /* @__PURE__ */ new Date(),
825
- source: { type: "addon", id: "recording-engine" },
826
- category: EventCategory.RecordingPolicyFallback,
827
- data: {
828
- deviceId,
829
- originalMode: "motion",
830
- fallbackMode: "continuous",
831
- reason: "no_motion_events"
832
- }
833
- });
834
- for (const writer of currentState.writers) {
835
- writer.flushAndContinue().catch((err) => {
836
- this.logger.error("Failed to flush buffer during fallback", { tags: { deviceId }, meta: { error: String(err) } });
837
- });
838
- }
839
- currentState.thumbnailExtractor.setActive(true);
840
- }, MOTION_FALLBACK_TIMEOUT_MS);
841
- }
842
- this.eventBus.emit({
843
- id: `recording-started-${deviceId}-${Date.now()}`,
844
- timestamp: /* @__PURE__ */ new Date(),
845
- source: { type: "addon", id: "recording-engine" },
846
- category: EventCategory.RecordingStarted,
847
- data: {
848
- deviceId,
849
- mode: policy.mode,
850
- streams: policy.streams.map((s) => s.streamId)
851
- }
852
- });
853
- this.logger.info("Recording enabled", { tags: { deviceId }, meta: { mode: policy.mode } });
854
- }
855
- async disableRecording(deviceId) {
856
- const state = this.recordings.get(deviceId);
857
- if (!state) {
858
- this.logger.warn("No active recording", { tags: { deviceId } });
859
- return;
860
- }
861
- let totalSegmentCount = 0;
862
- let totalSizeBytes = 0;
863
- for (const sp of state.policy.streams) {
864
- const usage = this.db.getStorageUsage(deviceId, sp.streamId);
865
- totalSegmentCount += usage.segmentCount;
866
- totalSizeBytes += usage.totalBytes;
867
- }
868
- const totalMB = Math.round(totalSizeBytes / 1024 / 1024);
869
- this.stopRecordingInternal(deviceId);
870
- this.recordings.delete(deviceId);
871
- this.db.addToCleanupQueue(deviceId, Date.now());
872
- this.eventBus.emit({
873
- id: `recording-stopped-${deviceId}-${Date.now()}`,
874
- timestamp: /* @__PURE__ */ new Date(),
875
- source: { type: "addon", id: "recording-engine" },
876
- category: EventCategory.RecordingStopped,
877
- data: {
878
- deviceId,
879
- segmentCount: totalSegmentCount,
880
- totalMB
881
- }
882
- });
883
- this.logger.info("Recording disabled", { tags: { deviceId }, meta: { segmentCount: totalSegmentCount, totalMB } });
884
- }
885
- isRecording(deviceId) {
886
- return this.recordings.has(deviceId);
887
- }
888
- /** Number of devices currently being recorded. */
889
- getActiveCount() {
890
- return this.recordings.size;
891
- }
892
- evaluatePolicies() {
893
- const now = /* @__PURE__ */ new Date();
894
- for (const [_deviceId, state] of this.recordings) {
895
- const { policy } = state;
896
- if (policy.mode === "scheduled" || policy.mode === "composite") {
897
- if (!policy.scheduleRules || policy.scheduleRules.length === 0) continue;
898
- const matchingRule = policy.scheduleRules.find(
899
- (rule) => RecordingCoordinator.evaluateScheduleRule(rule, now)
900
- );
901
- if (matchingRule) {
902
- const targetMode = matchingRule.mode === "motion" ? "buffer" : "continuous";
903
- for (const writer of state.writers) {
904
- if (writer.mode !== targetMode) {
905
- if (targetMode === "buffer") {
906
- writer.switchToBuffer();
907
- }
908
- }
909
- }
910
- } else {
911
- for (const writer of state.writers) {
912
- if (writer.mode !== "buffer") {
913
- writer.switchToBuffer();
914
- }
915
- }
916
- }
917
- }
918
- }
919
- }
920
- static evaluateScheduleRule(rule, date) {
921
- const dayOfWeek = date.getDay();
922
- const timeMinutes = date.getHours() * 60 + date.getMinutes();
923
- const [startH, startM] = rule.startTime.split(":").map(Number);
924
- const [endH, endM] = rule.endTime.split(":").map(Number);
925
- const startMinutes = startH * 60 + startM;
926
- const endMinutes = endH * 60 + endM;
927
- if (endMinutes > startMinutes) {
928
- return rule.days.includes(dayOfWeek) && timeMinutes >= startMinutes && timeMinutes < endMinutes;
929
- }
930
- if (rule.days.includes(dayOfWeek) && timeMinutes >= startMinutes) {
931
- return true;
932
- }
933
- const previousDay = (dayOfWeek + 6) % 7;
934
- if (rule.days.includes(previousDay) && timeMinutes < endMinutes) {
935
- return true;
936
- }
937
- return false;
938
- }
939
- subscribeToMotionEvents(deviceId, policy) {
940
- if (policy.mode !== "motion" && policy.mode !== "composite") {
941
- return null;
942
- }
943
- return this.eventBus.subscribe(
944
- { category: `motion.${deviceId}` },
945
- (event) => {
946
- this.handleMotionEvent(deviceId, event);
947
- }
948
- );
949
- }
950
- handleMotionEvent(deviceId, event) {
951
- const state = this.recordings.get(deviceId);
952
- if (!state) return;
953
- if (!state.motionReceived) {
954
- state.motionReceived = true;
955
- if (state.motionFallbackTimeout) {
956
- clearTimeout(state.motionFallbackTimeout);
957
- state.motionFallbackTimeout = null;
958
- }
959
- }
960
- const motionDetected = event.data.active === true || event.data.type === "start";
961
- if (motionDetected) {
962
- state.motionActive = true;
963
- if (state.motionTimeout) {
964
- clearTimeout(state.motionTimeout);
965
- state.motionTimeout = null;
966
- }
967
- for (const writer of state.writers) {
968
- writer.flushAndContinue().catch((err) => {
969
- this.logger.error("Failed to flush buffer", { tags: { deviceId }, meta: { error: String(err) } });
970
- });
971
- }
972
- state.thumbnailExtractor.setActive(true);
973
- } else {
974
- if (state.motionTimeout) {
975
- clearTimeout(state.motionTimeout);
976
- }
977
- state.motionTimeout = setTimeout(() => {
978
- state.motionActive = false;
979
- state.motionTimeout = null;
980
- for (const writer of state.writers) {
981
- writer.switchToBuffer();
982
- }
983
- if (state.policy.mode === "motion") {
984
- state.thumbnailExtractor.setActive(false);
985
- }
986
- }, state.policy.postBufferSec * 1e3);
987
- }
988
- }
989
- stopRecordingInternal(deviceId) {
990
- const state = this.recordings.get(deviceId);
991
- if (!state) return;
992
- for (const writer of state.writers) {
993
- writer.stop();
994
- }
995
- state.thumbnailExtractor.detachFromPipeline(deviceId);
996
- if (state.motionUnsubscribe) {
997
- state.motionUnsubscribe();
998
- }
999
- if (state.motionTimeout) {
1000
- clearTimeout(state.motionTimeout);
1001
- state.motionTimeout = null;
1002
- }
1003
- if (state.motionFallbackTimeout) {
1004
- clearTimeout(state.motionFallbackTimeout);
1005
- state.motionFallbackTimeout = null;
1006
- }
1007
- }
1008
- }
1009
- export {
1010
- RecordingCoordinator
1011
- };
1012
- //# sourceMappingURL=recording-coordinator-CsYH9LqF.mjs.map