@camstack/addon-pipeline 0.1.0

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.cjs ADDED
@@ -0,0 +1,3403 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // src/recording/ffmpeg-config.ts
34
+ var ffmpeg_config_exports = {};
35
+ __export(ffmpeg_config_exports, {
36
+ buildFfmpegInputArgs: () => buildFfmpegInputArgs,
37
+ buildFfmpegOutputArgs: () => buildFfmpegOutputArgs,
38
+ detectPlatformDefaults: () => detectPlatformDefaults,
39
+ resolveFfmpegConfig: () => resolveFfmpegConfig
40
+ });
41
+ function detectPlatformDefaults(ffmpegPath = "ffmpeg") {
42
+ let hwaccels = [];
43
+ try {
44
+ const output = (0, import_node_child_process3.execFileSync)(ffmpegPath, ["-hwaccels", "-hide_banner"], { encoding: "utf8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] });
45
+ hwaccels = output.split("\n").map((l) => l.trim()).filter((l) => l && l !== "Hardware acceleration methods:");
46
+ } catch {
47
+ }
48
+ const platform2 = os.platform();
49
+ if (platform2 === "darwin" && hwaccels.includes("videotoolbox")) return { hwaccel: "videotoolbox", threads: 0 };
50
+ if (platform2 === "linux") {
51
+ for (const hw of ["vaapi", "qsv", "cuda", "v4l2m2m"]) {
52
+ if (hwaccels.includes(hw)) return { hwaccel: hw, threads: 0 };
53
+ }
54
+ }
55
+ return { hwaccel: "none", threads: Math.max(1, Math.floor(os.cpus().length / 2)) };
56
+ }
57
+ function resolveFfmpegConfig(deviceConfig, globalConfig, detected) {
58
+ return {
59
+ path: deviceConfig?.path ?? globalConfig.path ?? detected.path ?? "ffmpeg",
60
+ hwaccel: deviceConfig?.hwaccel ?? globalConfig.hwaccel ?? detected.hwaccel ?? "none",
61
+ inputArgs: deviceConfig?.inputArgs ?? globalConfig.inputArgs,
62
+ outputArgs: deviceConfig?.outputArgs ?? globalConfig.outputArgs,
63
+ videoCodec: deviceConfig?.videoCodec ?? globalConfig.videoCodec ?? "copy",
64
+ audioCodec: deviceConfig?.audioCodec ?? globalConfig.audioCodec ?? "copy",
65
+ threads: deviceConfig?.threads ?? globalConfig.threads ?? detected.threads
66
+ };
67
+ }
68
+ function buildFfmpegInputArgs(config, inputUrl) {
69
+ const args = ["-hide_banner", "-loglevel", "warning"];
70
+ if (config.hwaccel && config.hwaccel !== "none") args.push("-hwaccel", config.hwaccel);
71
+ if (config.threads !== void 0) args.push("-threads", String(config.threads));
72
+ if (config.inputArgs?.length) args.push(...config.inputArgs);
73
+ args.push("-rtsp_transport", "tcp", "-i", inputUrl);
74
+ return args;
75
+ }
76
+ function buildFfmpegOutputArgs(config) {
77
+ const args = ["-c:v", config.videoCodec ?? "copy", "-c:a", config.audioCodec ?? "copy"];
78
+ if (config.outputArgs?.length) args.push(...config.outputArgs);
79
+ return args;
80
+ }
81
+ var import_node_child_process3, os;
82
+ var init_ffmpeg_config = __esm({
83
+ "src/recording/ffmpeg-config.ts"() {
84
+ "use strict";
85
+ import_node_child_process3 = require("child_process");
86
+ os = __toESM(require("os"), 1);
87
+ }
88
+ });
89
+
90
+ // src/recording/recording-db.ts
91
+ var recording_db_exports = {};
92
+ __export(recording_db_exports, {
93
+ RecordingDb: () => RecordingDb
94
+ });
95
+ function rowToSegment(row) {
96
+ return {
97
+ id: row.id,
98
+ deviceId: row.device_id,
99
+ streamId: row.stream_id,
100
+ startTime: row.start_time,
101
+ endTime: row.end_time,
102
+ duration: row.duration,
103
+ path: row.path,
104
+ storageName: row.storage_name,
105
+ subDirectory: row.sub_directory,
106
+ sizeBytes: row.size_bytes,
107
+ codec: row.codec,
108
+ hasAudio: row.has_audio === 1
109
+ };
110
+ }
111
+ function rowToThumbnail(row) {
112
+ return {
113
+ deviceId: row.device_id,
114
+ timestamp: row.timestamp,
115
+ path: row.path,
116
+ storageName: row.storage_name,
117
+ subDirectory: row.sub_directory,
118
+ sizeBytes: row.size_bytes,
119
+ category: row.category
120
+ };
121
+ }
122
+ function rowToPolicy(row) {
123
+ return {
124
+ deviceId: row.device_id,
125
+ enabled: row.enabled === 1,
126
+ mode: row.mode,
127
+ streams: JSON.parse(row.streams_json),
128
+ preBufferSec: row.pre_buffer_sec,
129
+ postBufferSec: row.post_buffer_sec,
130
+ scheduleRules: row.schedule_json ? JSON.parse(row.schedule_json) : void 0
131
+ };
132
+ }
133
+ function rowToStorageConfig(row) {
134
+ return {
135
+ deviceId: row.device_id,
136
+ dataCategory: row.data_category,
137
+ storageName: row.storage_name,
138
+ subDirectory: row.sub_directory,
139
+ retentionDays: row.retention_days,
140
+ retentionGb: row.retention_gb
141
+ };
142
+ }
143
+ function rowToCleanup(row) {
144
+ return {
145
+ deviceId: row.device_id,
146
+ disabledAt: row.disabled_at,
147
+ cleanupAfter: row.cleanup_after,
148
+ status: row.status,
149
+ startedAt: row.started_at
150
+ };
151
+ }
152
+ var RecordingDb;
153
+ var init_recording_db = __esm({
154
+ "src/recording/recording-db.ts"() {
155
+ "use strict";
156
+ RecordingDb = class {
157
+ constructor(db) {
158
+ this.db = db;
159
+ }
160
+ initialize() {
161
+ this.db.exec(`
162
+ CREATE TABLE IF NOT EXISTS recording_segments (
163
+ id TEXT PRIMARY KEY,
164
+ device_id TEXT NOT NULL,
165
+ stream_id TEXT NOT NULL,
166
+ start_time INTEGER NOT NULL,
167
+ end_time INTEGER NOT NULL,
168
+ duration REAL NOT NULL,
169
+ path TEXT NOT NULL,
170
+ storage_name TEXT NOT NULL,
171
+ sub_directory TEXT NOT NULL,
172
+ size_bytes INTEGER NOT NULL,
173
+ codec TEXT NOT NULL,
174
+ has_audio INTEGER NOT NULL DEFAULT 0
175
+ );
176
+ CREATE INDEX IF NOT EXISTS idx_segments_device_time ON recording_segments(device_id, stream_id, start_time);
177
+ CREATE INDEX IF NOT EXISTS idx_segments_start_time ON recording_segments(start_time);
178
+
179
+ CREATE TABLE IF NOT EXISTS recording_thumbnails (
180
+ device_id TEXT NOT NULL,
181
+ timestamp INTEGER NOT NULL,
182
+ path TEXT NOT NULL,
183
+ storage_name TEXT NOT NULL,
184
+ sub_directory TEXT NOT NULL,
185
+ size_bytes INTEGER NOT NULL,
186
+ category TEXT NOT NULL,
187
+ PRIMARY KEY (device_id, timestamp, category)
188
+ );
189
+
190
+ CREATE TABLE IF NOT EXISTS recording_policies (
191
+ device_id TEXT PRIMARY KEY,
192
+ enabled INTEGER NOT NULL DEFAULT 0,
193
+ mode TEXT NOT NULL,
194
+ streams_json TEXT NOT NULL,
195
+ schedule_json TEXT,
196
+ pre_buffer_sec INTEGER NOT NULL DEFAULT 5,
197
+ post_buffer_sec INTEGER NOT NULL DEFAULT 10,
198
+ created_at INTEGER NOT NULL,
199
+ updated_at INTEGER NOT NULL
200
+ );
201
+
202
+ CREATE TABLE IF NOT EXISTS recording_storage_config (
203
+ device_id TEXT NOT NULL,
204
+ data_category TEXT NOT NULL,
205
+ storage_name TEXT NOT NULL,
206
+ sub_directory TEXT NOT NULL,
207
+ retention_days INTEGER,
208
+ retention_gb REAL,
209
+ PRIMARY KEY (device_id, data_category)
210
+ );
211
+
212
+ CREATE TABLE IF NOT EXISTS recording_cleanup_queue (
213
+ device_id TEXT PRIMARY KEY,
214
+ disabled_at INTEGER NOT NULL,
215
+ cleanup_after INTEGER NOT NULL,
216
+ status TEXT NOT NULL,
217
+ started_at INTEGER
218
+ );
219
+ `);
220
+ }
221
+ // --- Segments ---
222
+ insertSegment(seg) {
223
+ this.db.prepare(`
224
+ INSERT INTO recording_segments (id, device_id, stream_id, start_time, end_time, duration, path, storage_name, sub_directory, size_bytes, codec, has_audio)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
226
+ `).run(seg.id, seg.deviceId, seg.streamId, seg.startTime, seg.endTime, seg.duration, seg.path, seg.storageName, seg.subDirectory, seg.sizeBytes, seg.codec, seg.hasAudio ? 1 : 0);
227
+ }
228
+ querySegments(deviceId, streamId, startTime, endTime) {
229
+ const rows = this.db.prepare(`
230
+ SELECT * FROM recording_segments
231
+ WHERE device_id = ? AND stream_id = ? AND start_time < ? AND end_time > ?
232
+ ORDER BY start_time ASC
233
+ `).all(deviceId, streamId, endTime, startTime);
234
+ return rows.map(rowToSegment);
235
+ }
236
+ deleteSegmentsBefore(deviceId, streamId, beforeTime) {
237
+ const toDelete = this.db.prepare(`
238
+ SELECT * FROM recording_segments WHERE device_id = ? AND stream_id = ? AND start_time < ?
239
+ `).all(deviceId, streamId, beforeTime);
240
+ this.db.prepare(`DELETE FROM recording_segments WHERE device_id = ? AND stream_id = ? AND start_time < ?`).run(deviceId, streamId, beforeTime);
241
+ return toDelete.map(rowToSegment);
242
+ }
243
+ deleteSegmentsForDevice(deviceId) {
244
+ const toDelete = this.db.prepare(`SELECT * FROM recording_segments WHERE device_id = ?`).all(deviceId);
245
+ this.db.prepare(`DELETE FROM recording_segments WHERE device_id = ?`).run(deviceId);
246
+ return toDelete.map(rowToSegment);
247
+ }
248
+ getStorageUsage(deviceId, streamId) {
249
+ const row = this.db.prepare(`
250
+ SELECT COALESCE(SUM(size_bytes), 0) as total_bytes, COUNT(*) as segment_count
251
+ FROM recording_segments WHERE device_id = ? AND stream_id = ?
252
+ `).get(deviceId, streamId);
253
+ return { totalBytes: row.total_bytes, segmentCount: row.segment_count };
254
+ }
255
+ getOldestSegments(deviceId, streamId, limit) {
256
+ const rows = this.db.prepare(`
257
+ SELECT * FROM recording_segments WHERE device_id = ? AND stream_id = ?
258
+ ORDER BY start_time ASC LIMIT ?
259
+ `).all(deviceId, streamId, limit);
260
+ return rows.map(rowToSegment);
261
+ }
262
+ getAvailability(deviceId, startTime, endTime) {
263
+ const rows = this.db.prepare(`
264
+ SELECT start_time, end_time, stream_id FROM recording_segments
265
+ WHERE device_id = ? AND end_time >= ? AND start_time <= ?
266
+ ORDER BY start_time ASC
267
+ `).all(deviceId, startTime, endTime);
268
+ if (rows.length === 0) return [];
269
+ const ranges = [];
270
+ let current = { startTime: rows[0].start_time, endTime: rows[0].end_time, streams: /* @__PURE__ */ new Set([rows[0].stream_id]) };
271
+ for (let i = 1; i < rows.length; i++) {
272
+ const row = rows[i];
273
+ if (row.start_time <= current.endTime) {
274
+ current.endTime = Math.max(current.endTime, row.end_time);
275
+ current.streams.add(row.stream_id);
276
+ } else {
277
+ ranges.push(current);
278
+ current = { startTime: row.start_time, endTime: row.end_time, streams: /* @__PURE__ */ new Set([row.stream_id]) };
279
+ }
280
+ }
281
+ ranges.push(current);
282
+ return ranges.map((r) => ({ startTime: r.startTime, endTime: r.endTime, streams: [...r.streams] }));
283
+ }
284
+ getMotionStats(deviceId, startTime, endTime) {
285
+ const row = this.db.prepare(`
286
+ SELECT COUNT(*) as total_events, COALESCE(AVG(duration), 0) as avg_duration, COALESCE(SUM(duration), 0) as total_duration
287
+ FROM recording_segments
288
+ WHERE device_id = ? AND start_time >= ? AND end_time <= ?
289
+ `).get(deviceId, startTime, endTime);
290
+ const timeRangeMs = endTime - startTime;
291
+ const timeRangeDays = Math.max(timeRangeMs / (24 * 60 * 60 * 1e3), 1);
292
+ const timeRangeSec = Math.max(timeRangeMs / 1e3, 1);
293
+ return {
294
+ totalEvents: row.total_events,
295
+ avgDurationSec: Math.round(row.avg_duration * 100) / 100,
296
+ avgEventsPerDay: Math.round(row.total_events / timeRangeDays * 100) / 100,
297
+ dutyCyclePercent: Math.round(row.total_duration / timeRangeSec * 1e4) / 100
298
+ };
299
+ }
300
+ // --- Thumbnails ---
301
+ insertThumbnail(thumb) {
302
+ this.db.prepare(`
303
+ INSERT OR REPLACE INTO recording_thumbnails (device_id, timestamp, path, storage_name, sub_directory, size_bytes, category)
304
+ VALUES (?, ?, ?, ?, ?, ?, ?)
305
+ `).run(thumb.deviceId, thumb.timestamp, thumb.path, thumb.storageName, thumb.subDirectory, thumb.sizeBytes, thumb.category);
306
+ }
307
+ findNearestThumbnail(deviceId, timestamp, category) {
308
+ const row = this.db.prepare(`
309
+ SELECT * FROM recording_thumbnails
310
+ WHERE device_id = ? AND category = ?
311
+ ORDER BY ABS(timestamp - ?) ASC LIMIT 1
312
+ `).get(deviceId, category, timestamp);
313
+ return row ? rowToThumbnail(row) : null;
314
+ }
315
+ deleteThumbnailsBefore(deviceId, beforeTime) {
316
+ const result = this.db.prepare(`DELETE FROM recording_thumbnails WHERE device_id = ? AND timestamp < ?`).run(deviceId, beforeTime);
317
+ return result.changes;
318
+ }
319
+ deleteThumbnailsForDevice(deviceId) {
320
+ const result = this.db.prepare(`DELETE FROM recording_thumbnails WHERE device_id = ?`).run(deviceId);
321
+ return result.changes;
322
+ }
323
+ // --- Policies ---
324
+ upsertPolicy(policy) {
325
+ const now = Date.now();
326
+ this.db.prepare(`
327
+ INSERT INTO recording_policies (device_id, enabled, mode, streams_json, schedule_json, pre_buffer_sec, post_buffer_sec, created_at, updated_at)
328
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
329
+ ON CONFLICT(device_id) DO UPDATE SET enabled=?, mode=?, streams_json=?, schedule_json=?, pre_buffer_sec=?, post_buffer_sec=?, updated_at=?
330
+ `).run(
331
+ policy.deviceId,
332
+ policy.enabled ? 1 : 0,
333
+ policy.mode,
334
+ JSON.stringify(policy.streams),
335
+ policy.scheduleRules ? JSON.stringify(policy.scheduleRules) : null,
336
+ policy.preBufferSec,
337
+ policy.postBufferSec,
338
+ now,
339
+ now,
340
+ policy.enabled ? 1 : 0,
341
+ policy.mode,
342
+ JSON.stringify(policy.streams),
343
+ policy.scheduleRules ? JSON.stringify(policy.scheduleRules) : null,
344
+ policy.preBufferSec,
345
+ policy.postBufferSec,
346
+ now
347
+ );
348
+ }
349
+ getPolicy(deviceId) {
350
+ const row = this.db.prepare(`SELECT * FROM recording_policies WHERE device_id = ?`).get(deviceId);
351
+ return row ? rowToPolicy(row) : null;
352
+ }
353
+ getEnabledPolicies() {
354
+ const rows = this.db.prepare(`SELECT * FROM recording_policies WHERE enabled = 1`).all();
355
+ return rows.map(rowToPolicy);
356
+ }
357
+ deletePolicy(deviceId) {
358
+ this.db.prepare(`DELETE FROM recording_policies WHERE device_id = ?`).run(deviceId);
359
+ }
360
+ // --- Storage Config ---
361
+ upsertStorageConfig(config) {
362
+ this.db.prepare(`
363
+ INSERT INTO recording_storage_config (device_id, data_category, storage_name, sub_directory, retention_days, retention_gb)
364
+ VALUES (?, ?, ?, ?, ?, ?)
365
+ ON CONFLICT(device_id, data_category) DO UPDATE SET storage_name=?, sub_directory=?, retention_days=?, retention_gb=?
366
+ `).run(
367
+ config.deviceId,
368
+ config.dataCategory,
369
+ config.storageName,
370
+ config.subDirectory,
371
+ config.retentionDays,
372
+ config.retentionGb,
373
+ config.storageName,
374
+ config.subDirectory,
375
+ config.retentionDays,
376
+ config.retentionGb
377
+ );
378
+ }
379
+ resolveStorageConfig(deviceId, category) {
380
+ const specific = this.db.prepare(`SELECT * FROM recording_storage_config WHERE device_id = ? AND data_category = ?`).get(deviceId, category);
381
+ if (specific) return rowToStorageConfig(specific);
382
+ const global = this.db.prepare(`SELECT * FROM recording_storage_config WHERE device_id = '*' AND data_category = ?`).get(category);
383
+ return global ? rowToStorageConfig(global) : null;
384
+ }
385
+ // --- Cleanup Queue ---
386
+ addToCleanupQueue(deviceId, disabledAt) {
387
+ const cleanupAfter = disabledAt + 24 * 60 * 60 * 1e3;
388
+ this.db.prepare(`
389
+ INSERT OR REPLACE INTO recording_cleanup_queue (device_id, disabled_at, cleanup_after, status, started_at)
390
+ VALUES (?, ?, ?, 'pending', NULL)
391
+ `).run(deviceId, disabledAt, cleanupAfter);
392
+ }
393
+ cancelCleanup(deviceId) {
394
+ this.db.prepare(`UPDATE recording_cleanup_queue SET status = 'cancelled' WHERE device_id = ? AND status = 'pending'`).run(deviceId);
395
+ }
396
+ getCleanupEntry(deviceId) {
397
+ const row = this.db.prepare(`SELECT * FROM recording_cleanup_queue WHERE device_id = ?`).get(deviceId);
398
+ return row ? rowToCleanup(row) : null;
399
+ }
400
+ getPendingCleanups() {
401
+ const now = Date.now();
402
+ const rows = this.db.prepare(`SELECT * FROM recording_cleanup_queue WHERE status = 'pending' AND cleanup_after <= ?`).all(now);
403
+ return rows.map(rowToCleanup);
404
+ }
405
+ resetStaleCleanups(maxAgeMs = 36e5) {
406
+ const cutoff = Date.now() - maxAgeMs;
407
+ this.db.prepare(`UPDATE recording_cleanup_queue SET status = 'pending', started_at = NULL WHERE status = 'in_progress' AND started_at < ?`).run(cutoff);
408
+ }
409
+ markCleanupInProgress(deviceId) {
410
+ this.db.prepare(`UPDATE recording_cleanup_queue SET status = 'in_progress', started_at = ? WHERE device_id = ?`).run(Date.now(), deviceId);
411
+ }
412
+ markCleanupCompleted(deviceId) {
413
+ this.db.prepare(`UPDATE recording_cleanup_queue SET status = 'completed' WHERE device_id = ?`).run(deviceId);
414
+ }
415
+ };
416
+ }
417
+ });
418
+
419
+ // src/recording/segment-writer.ts
420
+ var import_node_crypto4, import_node_child_process4, fs, path, SegmentRingBuffer, SegmentWriter;
421
+ var init_segment_writer = __esm({
422
+ "src/recording/segment-writer.ts"() {
423
+ "use strict";
424
+ import_node_crypto4 = require("crypto");
425
+ import_node_child_process4 = require("child_process");
426
+ fs = __toESM(require("fs/promises"), 1);
427
+ path = __toESM(require("path"), 1);
428
+ init_ffmpeg_config();
429
+ SegmentRingBuffer = class {
430
+ constructor(maxDurationSec) {
431
+ this.maxDurationSec = maxDurationSec;
432
+ }
433
+ segments = [];
434
+ totalDurationSec = 0;
435
+ push(segment) {
436
+ this.segments.push(segment);
437
+ this.totalDurationSec += segment.duration;
438
+ while (this.totalDurationSec > this.maxDurationSec && this.segments.length > 1) {
439
+ const evicted = this.segments.shift();
440
+ this.totalDurationSec -= evicted.duration;
441
+ }
442
+ }
443
+ flush() {
444
+ const result = [...this.segments];
445
+ this.segments = [];
446
+ this.totalDurationSec = 0;
447
+ return result;
448
+ }
449
+ get memoryEstimateBytes() {
450
+ return this.segments.reduce((sum, s) => sum + s.data.length, 0);
451
+ }
452
+ };
453
+ SegmentWriter = class _SegmentWriter {
454
+ constructor(config, logger, eventBus, db, _networkTracker) {
455
+ this.config = config;
456
+ this.logger = logger;
457
+ this.eventBus = eventBus;
458
+ this.db = db;
459
+ this._networkTracker = _networkTracker;
460
+ this._mode = config.mode;
461
+ this.ringBuffer = new SegmentRingBuffer(config.preBufferSec);
462
+ }
463
+ _state = "idle";
464
+ _mode;
465
+ ffmpeg = null;
466
+ activeSegment = null;
467
+ restartCount = 0;
468
+ restartWindowStart = 0;
469
+ healthTimer = null;
470
+ lastDataTime = 0;
471
+ ringBuffer;
472
+ restartTimeout = null;
473
+ pendingFinalization = null;
474
+ paused = false;
475
+ detectedCodec = "h264";
476
+ detectedHasAudio = false;
477
+ static MAX_RESTARTS = 10;
478
+ static RESTART_WINDOW_MS = 5 * 60 * 1e3;
479
+ static HEALTH_CHECK_INTERVAL_MS = 5e3;
480
+ static DATA_TIMEOUT_MS = 15e3;
481
+ static MIN_SEGMENT_DURATION_SEC = 0.5;
482
+ static CRITICAL_DISK_GB = 1;
483
+ get state() {
484
+ return this._state;
485
+ }
486
+ get mode() {
487
+ return this._mode;
488
+ }
489
+ get isPaused() {
490
+ return this.paused;
491
+ }
492
+ // --- Public API ---
493
+ async start(rtspUrl) {
494
+ if (this._state !== "idle") return;
495
+ const segmentDir = path.join(
496
+ this.config.storagePath,
497
+ this.config.subDirectory,
498
+ this.config.deviceId
499
+ );
500
+ await fs.mkdir(segmentDir, { recursive: true });
501
+ this._state = "recording";
502
+ this.lastDataTime = Date.now();
503
+ this.restartCount = 0;
504
+ this.restartWindowStart = Date.now();
505
+ const segmentPattern = path.join(segmentDir, "%d.mp4");
506
+ const args = _SegmentWriter.buildSegmentationArgs(
507
+ this.config.ffmpeg,
508
+ rtspUrl,
509
+ segmentPattern,
510
+ this.config.segmentDurationSec
511
+ );
512
+ this.spawnFfmpeg(args, rtspUrl);
513
+ this.startHealthCheck(rtspUrl);
514
+ }
515
+ async stop() {
516
+ if (this._state === "idle") return;
517
+ this._state = "stopping";
518
+ this.stopHealthCheck();
519
+ this.clearRestartTimeout();
520
+ this.killFfmpeg();
521
+ this.finalizeActiveSegment();
522
+ if (this.pendingFinalization) {
523
+ await this.pendingFinalization;
524
+ }
525
+ this._state = "idle";
526
+ }
527
+ resume(rtspUrl) {
528
+ if (!this.paused) return;
529
+ this.paused = false;
530
+ this.logger.info("Resuming recording after disk space freed", {
531
+ deviceId: this.config.deviceId
532
+ });
533
+ this._state = "idle";
534
+ void this.start(rtspUrl);
535
+ }
536
+ async flushAndContinue() {
537
+ if (this._mode !== "buffer") return;
538
+ const buffered = this.ringBuffer.flush();
539
+ this.logger.info(`Flushing ${buffered.length} buffered segments to disk`, {
540
+ deviceId: this.config.deviceId
541
+ });
542
+ for (const seg of buffered) {
543
+ await this.writeBufferedSegmentToDisk(seg);
544
+ }
545
+ this._mode = "continuous";
546
+ }
547
+ switchToBuffer() {
548
+ this._mode = "buffer";
549
+ this.ringBuffer = new SegmentRingBuffer(this.config.preBufferSec);
550
+ }
551
+ // --- Static helpers ---
552
+ static generateSegmentId(deviceId, streamId, startTime) {
553
+ const suffix = (0, import_node_crypto4.randomBytes)(2).toString("hex");
554
+ return `${deviceId}_${streamId}_${startTime}_${suffix}`;
555
+ }
556
+ static buildSegmentationArgs(config, inputUrl, outputPattern, segmentDuration) {
557
+ const inputArgs = buildFfmpegInputArgs(config, inputUrl);
558
+ const outputArgs = buildFfmpegOutputArgs(config);
559
+ const segmentArgs = [
560
+ "-f",
561
+ "segment",
562
+ "-segment_time",
563
+ String(segmentDuration),
564
+ "-segment_format",
565
+ "mp4",
566
+ "-movflags",
567
+ "+frag_keyframe+empty_moov+default_base_moof",
568
+ "-reset_timestamps",
569
+ "1",
570
+ "-strftime",
571
+ "0"
572
+ ];
573
+ return [...inputArgs, ...outputArgs, ...segmentArgs, outputPattern];
574
+ }
575
+ static async checkDiskSpace(storagePath, statfsFn) {
576
+ const doStatfs = statfsFn ?? (async (p) => {
577
+ const { statfs: nodeStatfs } = await import("fs/promises");
578
+ return nodeStatfs(p);
579
+ });
580
+ try {
581
+ const stats = await doStatfs(storagePath);
582
+ const availableBytes = stats.bfree * stats.bsize;
583
+ const availableGb = availableBytes / (1024 * 1024 * 1024);
584
+ return { ok: availableGb >= _SegmentWriter.CRITICAL_DISK_GB, availableGb };
585
+ } catch {
586
+ return { ok: true, availableGb: -1 };
587
+ }
588
+ }
589
+ // --- Private: ffmpeg process management ---
590
+ spawnFfmpeg(args, rtspUrl) {
591
+ this.ffmpeg = (0, import_node_child_process4.spawn)(this.config.ffmpeg.path, args, {
592
+ stdio: ["ignore", "pipe", "pipe"]
593
+ });
594
+ this.ffmpeg.stdout?.on("data", () => {
595
+ this.lastDataTime = Date.now();
596
+ });
597
+ this.ffmpeg.stderr?.on("data", (data) => {
598
+ this.lastDataTime = Date.now();
599
+ const msg = data.toString().trim();
600
+ if (msg) {
601
+ this.logger.debug(`ffmpeg: ${msg}`);
602
+ this.parseSegmentOutput(msg);
603
+ }
604
+ });
605
+ this.ffmpeg.on("error", (err) => {
606
+ this.logger.warn(`ffmpeg process error: ${err.message}`);
607
+ this.handleCrash(rtspUrl);
608
+ });
609
+ this.ffmpeg.on("exit", (code) => {
610
+ if (code !== 0 && code !== null && this._state === "recording") {
611
+ this.logger.warn(`ffmpeg exited with code ${code}`);
612
+ this.handleCrash(rtspUrl);
613
+ }
614
+ });
615
+ }
616
+ handleCrash(rtspUrl) {
617
+ this.ffmpeg = null;
618
+ const prevFinalization = this.pendingFinalization;
619
+ this.pendingFinalization = (prevFinalization ?? Promise.resolve()).then(() => {
620
+ return this.finalizeActiveSegment();
621
+ });
622
+ if (this._state !== "recording") return;
623
+ if (this.paused) return;
624
+ const now = Date.now();
625
+ if (now - this.restartWindowStart > _SegmentWriter.RESTART_WINDOW_MS) {
626
+ this.restartCount = 0;
627
+ this.restartWindowStart = now;
628
+ }
629
+ this.restartCount++;
630
+ this.eventBus.emit({
631
+ id: `rec-err-${now}`,
632
+ timestamp: /* @__PURE__ */ new Date(),
633
+ source: { type: "addon", id: "recording-engine" },
634
+ category: "recording.error",
635
+ data: {
636
+ deviceId: this.config.deviceId,
637
+ streamId: this.config.streamId,
638
+ restartAttempt: this.restartCount
639
+ }
640
+ });
641
+ if (this.restartCount > _SegmentWriter.MAX_RESTARTS) {
642
+ this.logger.error(
643
+ `Max restarts exceeded for ${this.config.deviceId}/${this.config.streamId}`
644
+ );
645
+ this._state = "idle";
646
+ this.eventBus.emit({
647
+ id: `rec-degraded-${now}`,
648
+ timestamp: /* @__PURE__ */ new Date(),
649
+ source: { type: "addon", id: "recording-engine" },
650
+ category: "recording.health.degraded",
651
+ data: {
652
+ deviceId: this.config.deviceId,
653
+ streamId: this.config.streamId
654
+ }
655
+ });
656
+ return;
657
+ }
658
+ const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.restartCount - 1));
659
+ this.logger.info(`Restarting ffmpeg in ${backoffMs}ms (attempt ${this.restartCount})`);
660
+ this.restartTimeout = setTimeout(() => {
661
+ this.restartTimeout = null;
662
+ if (this._state === "recording") {
663
+ const segmentDir = path.join(
664
+ this.config.storagePath,
665
+ this.config.subDirectory,
666
+ this.config.deviceId
667
+ );
668
+ const segmentPattern = path.join(segmentDir, "%d.mp4");
669
+ const args = _SegmentWriter.buildSegmentationArgs(
670
+ this.config.ffmpeg,
671
+ rtspUrl,
672
+ segmentPattern,
673
+ this.config.segmentDurationSec
674
+ );
675
+ this.spawnFfmpeg(args, rtspUrl);
676
+ }
677
+ }, backoffMs);
678
+ }
679
+ // --- Private: health monitoring ---
680
+ startHealthCheck(rtspUrl) {
681
+ this.healthTimer = setInterval(() => {
682
+ if (this._state !== "recording") return;
683
+ const elapsed = Date.now() - this.lastDataTime;
684
+ if (elapsed > _SegmentWriter.DATA_TIMEOUT_MS) {
685
+ this.logger.warn(`No data for ${elapsed}ms, restarting ffmpeg`);
686
+ this.killFfmpeg();
687
+ this.handleCrash(rtspUrl);
688
+ }
689
+ }, _SegmentWriter.HEALTH_CHECK_INTERVAL_MS);
690
+ }
691
+ stopHealthCheck() {
692
+ if (this.healthTimer) {
693
+ clearInterval(this.healthTimer);
694
+ this.healthTimer = null;
695
+ }
696
+ }
697
+ clearRestartTimeout() {
698
+ if (this.restartTimeout) {
699
+ clearTimeout(this.restartTimeout);
700
+ this.restartTimeout = null;
701
+ }
702
+ }
703
+ killFfmpeg() {
704
+ if (this.ffmpeg) {
705
+ this.ffmpeg.kill("SIGTERM");
706
+ this.ffmpeg = null;
707
+ }
708
+ }
709
+ // --- Private: segment parsing and finalization ---
710
+ parseSegmentOutput(msg) {
711
+ const videoMatch = msg.match(/Stream\s+#\d+:\d+.*Video:\s+(h264|hevc|h265)/i);
712
+ if (videoMatch) {
713
+ const codec = videoMatch[1].toLowerCase();
714
+ this.detectedCodec = codec === "hevc" || codec === "h265" ? "h265" : "h264";
715
+ }
716
+ const audioMatch = msg.match(/Stream\s+#\d+:\d+.*Audio:/i);
717
+ if (audioMatch) {
718
+ this.detectedHasAudio = true;
719
+ }
720
+ const openMatch = msg.match(/Opening '(.+\.mp4)' for writing/);
721
+ if (openMatch) {
722
+ const prevFinalization = this.pendingFinalization;
723
+ this.pendingFinalization = (prevFinalization ?? Promise.resolve()).then(() => {
724
+ return this.finalizeActiveSegment();
725
+ });
726
+ const absolutePath = openMatch[1];
727
+ const segPath = absolutePath.startsWith(this.config.storagePath) ? absolutePath.slice(this.config.storagePath.length).replace(/^\//, "") : absolutePath;
728
+ this.activeSegment = {
729
+ id: _SegmentWriter.generateSegmentId(
730
+ this.config.deviceId,
731
+ this.config.streamId,
732
+ Date.now()
733
+ ),
734
+ path: segPath,
735
+ startTime: Date.now()
736
+ };
737
+ }
738
+ }
739
+ async finalizeActiveSegment() {
740
+ if (!this.activeSegment) return;
741
+ const seg = this.activeSegment;
742
+ this.activeSegment = null;
743
+ const endTime = Date.now();
744
+ const duration = (endTime - seg.startTime) / 1e3;
745
+ if (duration < _SegmentWriter.MIN_SEGMENT_DURATION_SEC) return;
746
+ if (this._mode === "buffer") {
747
+ await this.bufferSegmentFromDisk(seg, endTime, duration);
748
+ return;
749
+ }
750
+ await this.finalizeSegmentToDisk(seg, endTime, duration);
751
+ }
752
+ async bufferSegmentFromDisk(seg, _endTime, duration) {
753
+ try {
754
+ const data = await fs.readFile(seg.path);
755
+ this.ringBuffer.push({ data, startTime: seg.startTime, duration });
756
+ await fs.unlink(seg.path).catch(() => {
757
+ });
758
+ } catch (err) {
759
+ this.logger.warn(`Failed to buffer segment: ${err}`);
760
+ }
761
+ }
762
+ async finalizeSegmentToDisk(seg, endTime, duration) {
763
+ try {
764
+ const diskCheck = await _SegmentWriter.checkDiskSpace(this.config.storagePath);
765
+ if (!diskCheck.ok) {
766
+ this.eventBus.emit({
767
+ id: `storage-critical-${Date.now()}`,
768
+ timestamp: /* @__PURE__ */ new Date(),
769
+ source: { type: "addon", id: "recording-engine" },
770
+ category: "recording.storage.critical",
771
+ data: {
772
+ storageId: this.config.storageName,
773
+ availableGB: diskCheck.availableGb
774
+ }
775
+ });
776
+ this.logger.error("Disk space critically low, pausing recording");
777
+ this.paused = true;
778
+ this.killFfmpeg();
779
+ this._state = "idle";
780
+ return;
781
+ }
782
+ let sizeBytes = 0;
783
+ try {
784
+ const fileStat = await fs.stat(seg.path);
785
+ sizeBytes = fileStat.size;
786
+ } catch {
787
+ }
788
+ const codec = this.detectedCodec;
789
+ const hasAudio = this.detectedHasAudio;
790
+ const segment = {
791
+ id: seg.id,
792
+ deviceId: this.config.deviceId,
793
+ streamId: this.config.streamId,
794
+ startTime: seg.startTime,
795
+ endTime,
796
+ duration,
797
+ path: seg.path,
798
+ storageName: this.config.storageName,
799
+ subDirectory: this.config.subDirectory,
800
+ sizeBytes,
801
+ codec,
802
+ hasAudio
803
+ };
804
+ try {
805
+ this.db.insertSegment(segment);
806
+ this.eventBus.emit({
807
+ id: `seg-${seg.id}`,
808
+ timestamp: /* @__PURE__ */ new Date(),
809
+ source: { type: "addon", id: "recording-engine" },
810
+ category: "recording.segment.written",
811
+ data: {
812
+ deviceId: this.config.deviceId,
813
+ streamId: this.config.streamId,
814
+ segmentId: seg.id,
815
+ duration,
816
+ sizeBytes
817
+ }
818
+ });
819
+ } catch (err) {
820
+ this.logger.error(`Failed to insert segment: ${err}`);
821
+ }
822
+ } catch (err) {
823
+ this.logger.error(`Disk space check failed: ${err}`);
824
+ }
825
+ }
826
+ async writeBufferedSegmentToDisk(buffered) {
827
+ const segId = _SegmentWriter.generateSegmentId(
828
+ this.config.deviceId,
829
+ this.config.streamId,
830
+ buffered.startTime
831
+ );
832
+ const relativePath = `${this.config.subDirectory}/${this.config.deviceId}/${segId}.mp4`;
833
+ try {
834
+ await this.config.fileStorage?.writeFile(relativePath, buffered.data);
835
+ const sizeBytes = buffered.data.length;
836
+ const segment = {
837
+ id: segId,
838
+ deviceId: this.config.deviceId,
839
+ streamId: this.config.streamId,
840
+ startTime: buffered.startTime,
841
+ endTime: buffered.startTime + buffered.duration * 1e3,
842
+ duration: buffered.duration,
843
+ path: relativePath,
844
+ storageName: this.config.storageName,
845
+ subDirectory: this.config.subDirectory,
846
+ sizeBytes,
847
+ codec: this.detectedCodec,
848
+ hasAudio: this.detectedHasAudio
849
+ };
850
+ this.db.insertSegment(segment);
851
+ this.eventBus.emit({
852
+ id: `seg-${segId}`,
853
+ timestamp: /* @__PURE__ */ new Date(),
854
+ source: { type: "addon", id: "recording-engine" },
855
+ category: "recording.segment.written",
856
+ data: {
857
+ deviceId: this.config.deviceId,
858
+ streamId: this.config.streamId,
859
+ segmentId: segId,
860
+ duration: buffered.duration,
861
+ sizeBytes
862
+ }
863
+ });
864
+ } catch (err) {
865
+ this.logger.error(`Failed to write buffered segment to disk: ${err}`);
866
+ }
867
+ }
868
+ };
869
+ }
870
+ });
871
+
872
+ // src/recording/thumbnail-extractor.ts
873
+ var import_sharp, ThumbnailExtractor;
874
+ var init_thumbnail_extractor = __esm({
875
+ "src/recording/thumbnail-extractor.ts"() {
876
+ "use strict";
877
+ import_sharp = __toESM(require("sharp"), 1);
878
+ ThumbnailExtractor = class _ThumbnailExtractor {
879
+ constructor(config, logger, db) {
880
+ this.config = config;
881
+ this.logger = logger;
882
+ this.db = db;
883
+ }
884
+ id = "thumbnail-extractor";
885
+ name = "Thumbnail Extractor";
886
+ needsAudio = false;
887
+ videoRequirements = {
888
+ keyframeOnly: true,
889
+ maxFps: 1,
890
+ format: "jpeg"
891
+ };
892
+ unsubscribe = null;
893
+ active = false;
894
+ attachToPipeline(pipeline, _deviceId) {
895
+ this.active = true;
896
+ this.unsubscribe = pipeline.onVideoFrame(
897
+ (frame) => {
898
+ this.handleFrame(frame).catch((err) => this.logger.debug(`Thumbnail error: ${err}`));
899
+ },
900
+ this.videoRequirements
901
+ );
902
+ this.logger.info(`ThumbnailExtractor attached for ${this.config.deviceId}`);
903
+ }
904
+ detachFromPipeline(_deviceId) {
905
+ this.active = false;
906
+ if (this.unsubscribe) {
907
+ this.unsubscribe();
908
+ this.unsubscribe = null;
909
+ }
910
+ this.logger.info(`ThumbnailExtractor detached for ${this.config.deviceId}`);
911
+ }
912
+ setActive(active) {
913
+ this.active = active;
914
+ }
915
+ async handleFrame(frame) {
916
+ if (!this.active) return;
917
+ const timestamp = frame.timestamp || Date.now();
918
+ const relativePath = _ThumbnailExtractor.thumbnailPath(
919
+ this.config.subDirectory,
920
+ this.config.deviceId,
921
+ timestamp
922
+ );
923
+ const resized = await (0, import_sharp.default)(frame.data).resize({ width: this.config.maxWidthPx, withoutEnlargement: true }).jpeg({ quality: this.config.jpegQuality }).toBuffer();
924
+ await this.config.fileStorage?.writeFile(relativePath, resized);
925
+ this.db.insertThumbnail({
926
+ deviceId: this.config.deviceId,
927
+ timestamp,
928
+ path: relativePath,
929
+ storageName: this.config.storageName,
930
+ subDirectory: this.config.subDirectory,
931
+ sizeBytes: resized.length,
932
+ category: "scrub"
933
+ });
934
+ }
935
+ static thumbnailPath(subDirectory, deviceId, timestamp) {
936
+ return `${subDirectory}/${deviceId}/${timestamp}.jpg`;
937
+ }
938
+ };
939
+ }
940
+ });
941
+
942
+ // src/recording/retention-manager.ts
943
+ var NORMAL_INTERVAL_MS, HIGH_USAGE_INTERVAL_MS, STORAGE_WARNING_THRESHOLD, STORAGE_CRITICAL_THRESHOLD, STORAGE_HIGH_USAGE_THRESHOLD, RetentionManager;
944
+ var init_retention_manager = __esm({
945
+ "src/recording/retention-manager.ts"() {
946
+ "use strict";
947
+ NORMAL_INTERVAL_MS = 5 * 60 * 1e3;
948
+ HIGH_USAGE_INTERVAL_MS = 30 * 1e3;
949
+ STORAGE_WARNING_THRESHOLD = 0.8;
950
+ STORAGE_CRITICAL_THRESHOLD = 0.95;
951
+ STORAGE_HIGH_USAGE_THRESHOLD = 0.9;
952
+ RetentionManager = class {
953
+ constructor(db, logger, eventBus, fileStorage) {
954
+ this.db = db;
955
+ this.logger = logger;
956
+ this.eventBus = eventBus;
957
+ this.fileStorage = fileStorage;
958
+ }
959
+ timer = null;
960
+ start() {
961
+ this.scheduleNextCycle(NORMAL_INTERVAL_MS);
962
+ }
963
+ stop() {
964
+ if (this.timer) {
965
+ clearTimeout(this.timer);
966
+ this.timer = null;
967
+ }
968
+ }
969
+ async runCycle() {
970
+ this.db.resetStaleCleanups();
971
+ const policies = this.db.getEnabledPolicies();
972
+ let totalFreedBytes = 0;
973
+ let totalDeletedSegments = 0;
974
+ let highUsage = false;
975
+ for (const policy of policies) {
976
+ for (const sp of policy.streams) {
977
+ const category = `recording:${sp.streamId}`;
978
+ const config = this.db.resolveStorageConfig(policy.deviceId, category);
979
+ if (!config) continue;
980
+ if (config.retentionDays !== null) {
981
+ const cutoff = Date.now() - config.retentionDays * 864e5;
982
+ const deleted = this.db.deleteSegmentsBefore(policy.deviceId, sp.streamId, cutoff);
983
+ totalDeletedSegments += deleted.length;
984
+ for (const seg of deleted) {
985
+ totalFreedBytes += seg.sizeBytes;
986
+ await this.deleteFile(seg.path);
987
+ }
988
+ this.db.deleteThumbnailsBefore(policy.deviceId, cutoff);
989
+ }
990
+ if (config.retentionGb !== null) {
991
+ const maxBytes = config.retentionGb * 1024 * 1024 * 1024;
992
+ let usage = this.db.getStorageUsage(policy.deviceId, sp.streamId);
993
+ const usageRatio = usage.totalBytes / maxBytes;
994
+ if (usageRatio > STORAGE_CRITICAL_THRESHOLD) {
995
+ this.emitStorageEvent("recording.storage.critical", policy.deviceId, sp.streamId, usageRatio);
996
+ } else if (usageRatio > STORAGE_WARNING_THRESHOLD) {
997
+ this.emitStorageEvent("recording.storage.warning", policy.deviceId, sp.streamId, usageRatio);
998
+ }
999
+ if (usageRatio > STORAGE_HIGH_USAGE_THRESHOLD) {
1000
+ highUsage = true;
1001
+ }
1002
+ while (usage.totalBytes > maxBytes && usage.segmentCount > 0) {
1003
+ const oldest = this.db.getOldestSegments(policy.deviceId, sp.streamId, 10);
1004
+ if (oldest.length === 0) break;
1005
+ for (const seg of oldest) {
1006
+ this.db.deleteSegmentsBefore(policy.deviceId, sp.streamId, seg.endTime + 1);
1007
+ totalFreedBytes += seg.sizeBytes;
1008
+ totalDeletedSegments++;
1009
+ await this.deleteFile(seg.path);
1010
+ }
1011
+ usage = this.db.getStorageUsage(policy.deviceId, sp.streamId);
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ const pending = this.db.getPendingCleanups();
1017
+ for (const entry of pending) {
1018
+ this.db.markCleanupInProgress(entry.deviceId);
1019
+ try {
1020
+ const deleted = this.db.deleteSegmentsForDevice(entry.deviceId);
1021
+ for (const seg of deleted) {
1022
+ totalFreedBytes += seg.sizeBytes;
1023
+ totalDeletedSegments++;
1024
+ await this.deleteFile(seg.path);
1025
+ }
1026
+ this.db.deleteThumbnailsForDevice(entry.deviceId);
1027
+ this.db.markCleanupCompleted(entry.deviceId);
1028
+ } catch (err) {
1029
+ this.logger.error(`Cleanup failed for ${entry.deviceId}: ${err}`);
1030
+ }
1031
+ }
1032
+ if (totalDeletedSegments > 0) {
1033
+ this.eventBus.emit({
1034
+ id: `retention-${Date.now()}`,
1035
+ timestamp: /* @__PURE__ */ new Date(),
1036
+ source: { type: "addon", id: "recording-engine" },
1037
+ category: "recording.retention.completed",
1038
+ data: {
1039
+ freedMB: Math.round(totalFreedBytes / 1024 / 1024),
1040
+ deletedSegments: totalDeletedSegments
1041
+ }
1042
+ });
1043
+ }
1044
+ return highUsage;
1045
+ }
1046
+ scheduleNextCycle(intervalMs) {
1047
+ this.timer = setTimeout(async () => {
1048
+ try {
1049
+ const storageHighUsage = await this.runCycle();
1050
+ const nextInterval = storageHighUsage ? HIGH_USAGE_INTERVAL_MS : NORMAL_INTERVAL_MS;
1051
+ this.scheduleNextCycle(nextInterval);
1052
+ } catch (err) {
1053
+ this.logger.error(`Retention cycle error: ${err}`);
1054
+ this.scheduleNextCycle(NORMAL_INTERVAL_MS);
1055
+ }
1056
+ }, intervalMs);
1057
+ }
1058
+ emitStorageEvent(category, deviceId, streamId, usageRatio) {
1059
+ this.eventBus.emit({
1060
+ id: `${category}-${deviceId}-${Date.now()}`,
1061
+ timestamp: /* @__PURE__ */ new Date(),
1062
+ source: { type: "addon", id: "recording-engine" },
1063
+ category,
1064
+ data: {
1065
+ deviceId,
1066
+ streamId,
1067
+ usagePercent: Math.round(usageRatio * 100)
1068
+ }
1069
+ });
1070
+ }
1071
+ async deleteFile(filePath) {
1072
+ try {
1073
+ await this.fileStorage.deleteFile(filePath);
1074
+ } catch {
1075
+ }
1076
+ }
1077
+ };
1078
+ }
1079
+ });
1080
+
1081
+ // src/recording/playlist-generator.ts
1082
+ var FALLBACK_STREAMS, PlaylistGenerator;
1083
+ var init_playlist_generator = __esm({
1084
+ "src/recording/playlist-generator.ts"() {
1085
+ "use strict";
1086
+ FALLBACK_STREAMS = ["sub", "mid", "main"];
1087
+ PlaylistGenerator = class {
1088
+ constructor(db) {
1089
+ this.db = db;
1090
+ }
1091
+ generate(deviceId, streamId, startTime, endTime, options) {
1092
+ const segments = this.resolveSegments(deviceId, streamId, startTime, endTime);
1093
+ return this.buildPlaylist(segments, options);
1094
+ }
1095
+ resolveSegments(deviceId, streamId, startTime, endTime) {
1096
+ const segments = this.db.querySegments(deviceId, streamId, startTime, endTime);
1097
+ if (segments.length > 0) {
1098
+ return segments;
1099
+ }
1100
+ const fallbacks = FALLBACK_STREAMS.filter((s) => s !== streamId);
1101
+ for (const fb of fallbacks) {
1102
+ const fallbackSegments = this.db.querySegments(deviceId, fb, startTime, endTime);
1103
+ if (fallbackSegments.length > 0) {
1104
+ return fallbackSegments;
1105
+ }
1106
+ }
1107
+ return [];
1108
+ }
1109
+ buildPlaylist(segments, options) {
1110
+ const targetDuration = segments.length > 0 ? Math.ceil(Math.max(...segments.map((s) => s.duration))) : 4;
1111
+ const lines = [
1112
+ "#EXTM3U",
1113
+ "#EXT-X-VERSION:7",
1114
+ `#EXT-X-TARGETDURATION:${targetDuration}`,
1115
+ "#EXT-X-MEDIA-SEQUENCE:0"
1116
+ ];
1117
+ if (!options?.live) {
1118
+ lines.push("#EXT-X-PLAYLIST-TYPE:VOD");
1119
+ }
1120
+ for (const seg of segments) {
1121
+ lines.push(`#EXTINF:${seg.duration.toFixed(3)},`);
1122
+ lines.push(seg.path);
1123
+ }
1124
+ if (!options?.live) {
1125
+ lines.push("#EXT-X-ENDLIST");
1126
+ }
1127
+ return lines.join("\n");
1128
+ }
1129
+ };
1130
+ }
1131
+ });
1132
+
1133
+ // src/recording/storage-estimator.ts
1134
+ var StorageEstimator;
1135
+ var init_storage_estimator = __esm({
1136
+ "src/recording/storage-estimator.ts"() {
1137
+ "use strict";
1138
+ StorageEstimator = class {
1139
+ constructor(db, networkTracker) {
1140
+ this.db = db;
1141
+ this.networkTracker = networkTracker;
1142
+ }
1143
+ estimateForDevice(deviceId, motionInput) {
1144
+ const policy = this.db.getPolicy(deviceId);
1145
+ if (!policy) return { perStream: {}, thumbnails: { estimatedGb: 0 }, totalEstimatedGb: 0 };
1146
+ const stats = this.networkTracker.getDeviceStats(deviceId);
1147
+ const perStream = {};
1148
+ let totalGb = 0;
1149
+ const motionEstimate = motionInput ? {
1150
+ avgEventsPerDay: motionInput.avgEventsPerDay,
1151
+ avgDurationSec: motionInput.avgDurationSec,
1152
+ dutyCyclePercent: motionInput.avgEventsPerDay * motionInput.avgDurationSec / 86400 * 100
1153
+ } : void 0;
1154
+ for (const sp of policy.streams) {
1155
+ const category = `recording:${sp.streamId}`;
1156
+ const config = this.db.resolveStorageConfig(deviceId, category);
1157
+ const retentionDays = config?.retentionDays ?? null;
1158
+ const retentionGb = config?.retentionGb ?? null;
1159
+ const streamStats = stats?.streams[sp.streamId];
1160
+ const observedBitrate = streamStats?.observedBitrateKbps ?? 0;
1161
+ const bitrateKbps = observedBitrate > 0 ? observedBitrate : streamStats?.nominalBitrateKbps ?? 4e3;
1162
+ let estimatedGb = 0;
1163
+ if (retentionDays !== null) {
1164
+ const retentionSeconds = retentionDays * 86400;
1165
+ estimatedGb = bitrateKbps * retentionSeconds / 8 / 1024 / 1024 / 1024;
1166
+ }
1167
+ if (policy.mode === "motion" && motionEstimate) {
1168
+ estimatedGb = estimatedGb * motionEstimate.dutyCyclePercent / 100;
1169
+ }
1170
+ let estimatedDaysAtCapacity = null;
1171
+ if (retentionGb !== null && estimatedGb > retentionGb) {
1172
+ estimatedDaysAtCapacity = retentionDays !== null ? retentionDays * (retentionGb / estimatedGb) : null;
1173
+ estimatedGb = retentionGb;
1174
+ }
1175
+ perStream[sp.streamId] = { bitrateKbps, retentionDays, retentionGb, estimatedGb, estimatedDaysAtCapacity };
1176
+ totalGb += estimatedGb;
1177
+ }
1178
+ const thumbRetentionDays = policy.streams[0] ? this.db.resolveStorageConfig(deviceId, "thumbnail:scrub")?.retentionDays ?? 7 : 7;
1179
+ const thumbGb = 2 * 24 * thumbRetentionDays / 1024;
1180
+ totalGb += thumbGb;
1181
+ return { perStream, thumbnails: { estimatedGb: thumbGb }, totalEstimatedGb: totalGb, motionEstimate };
1182
+ }
1183
+ };
1184
+ }
1185
+ });
1186
+
1187
+ // src/recording/recording-coordinator.ts
1188
+ var recording_coordinator_exports = {};
1189
+ __export(recording_coordinator_exports, {
1190
+ RecordingCoordinator: () => RecordingCoordinator
1191
+ });
1192
+ var POLICY_EVAL_INTERVAL_MS, MOTION_FALLBACK_TIMEOUT_MS, RecordingCoordinator;
1193
+ var init_recording_coordinator = __esm({
1194
+ "src/recording/recording-coordinator.ts"() {
1195
+ "use strict";
1196
+ init_ffmpeg_config();
1197
+ init_segment_writer();
1198
+ init_thumbnail_extractor();
1199
+ init_retention_manager();
1200
+ init_playlist_generator();
1201
+ init_storage_estimator();
1202
+ POLICY_EVAL_INTERVAL_MS = 1e3;
1203
+ MOTION_FALLBACK_TIMEOUT_MS = 6e4;
1204
+ RecordingCoordinator = class _RecordingCoordinator {
1205
+ db;
1206
+ logger;
1207
+ eventBus;
1208
+ streamingEngine;
1209
+ pipelineManager;
1210
+ networkTracker;
1211
+ fileStorage;
1212
+ storagePath;
1213
+ globalFfmpegConfig;
1214
+ detectedFfmpegConfig;
1215
+ recordings = /* @__PURE__ */ new Map();
1216
+ policyTimer = null;
1217
+ retentionManager;
1218
+ playlistGenerator;
1219
+ storageEstimator;
1220
+ constructor(config) {
1221
+ this.db = config.db;
1222
+ this.logger = config.logger;
1223
+ this.eventBus = config.eventBus;
1224
+ this.streamingEngine = config.streamingEngine;
1225
+ this.pipelineManager = config.pipelineManager;
1226
+ this.networkTracker = config.networkTracker;
1227
+ this.fileStorage = config.fileStorage;
1228
+ this.storagePath = config.storagePath;
1229
+ this.globalFfmpegConfig = config.globalFfmpegConfig;
1230
+ this.detectedFfmpegConfig = config.detectedFfmpegConfig;
1231
+ this.retentionManager = new RetentionManager(
1232
+ this.db,
1233
+ this.logger.child("retention"),
1234
+ this.eventBus,
1235
+ this.fileStorage
1236
+ );
1237
+ this.playlistGenerator = new PlaylistGenerator(this.db);
1238
+ this.storageEstimator = new StorageEstimator(this.db, this.networkTracker);
1239
+ }
1240
+ async start() {
1241
+ this.logger.info("RecordingCoordinator starting");
1242
+ this.retentionManager.start();
1243
+ const enabledPolicies = this.db.getEnabledPolicies();
1244
+ for (const policy of enabledPolicies) {
1245
+ try {
1246
+ await this.enableRecording(policy.deviceId, {
1247
+ policy: {
1248
+ mode: policy.mode,
1249
+ streams: policy.streams,
1250
+ enabled: policy.enabled,
1251
+ preBufferSec: policy.preBufferSec,
1252
+ postBufferSec: policy.postBufferSec,
1253
+ scheduleRules: policy.scheduleRules
1254
+ }
1255
+ });
1256
+ } catch (err) {
1257
+ this.logger.error(`Failed to start recording for ${policy.deviceId}: ${err}`);
1258
+ }
1259
+ }
1260
+ this.policyTimer = setInterval(() => {
1261
+ this.evaluatePolicies();
1262
+ }, POLICY_EVAL_INTERVAL_MS);
1263
+ this.logger.info("RecordingCoordinator started");
1264
+ }
1265
+ stop() {
1266
+ this.logger.info("RecordingCoordinator stopping");
1267
+ if (this.policyTimer) {
1268
+ clearInterval(this.policyTimer);
1269
+ this.policyTimer = null;
1270
+ }
1271
+ this.retentionManager.stop();
1272
+ for (const [deviceId] of this.recordings) {
1273
+ this.stopRecordingInternal(deviceId);
1274
+ }
1275
+ this.recordings.clear();
1276
+ this.logger.info("RecordingCoordinator stopped");
1277
+ }
1278
+ async enableRecording(deviceId, config) {
1279
+ if (this.recordings.has(deviceId)) {
1280
+ this.stopRecordingInternal(deviceId);
1281
+ this.recordings.delete(deviceId);
1282
+ }
1283
+ const policy = {
1284
+ deviceId,
1285
+ mode: config.policy.mode,
1286
+ streams: config.policy.streams,
1287
+ enabled: config.policy.enabled,
1288
+ preBufferSec: config.policy.preBufferSec,
1289
+ postBufferSec: config.policy.postBufferSec,
1290
+ scheduleRules: config.policy.scheduleRules
1291
+ };
1292
+ this.db.upsertPolicy({
1293
+ deviceId,
1294
+ enabled: policy.enabled,
1295
+ mode: policy.mode,
1296
+ streams: policy.streams,
1297
+ preBufferSec: policy.preBufferSec,
1298
+ postBufferSec: policy.postBufferSec,
1299
+ scheduleRules: policy.scheduleRules
1300
+ });
1301
+ this.db.cancelCleanup(deviceId);
1302
+ const ffmpegConfig = resolveFfmpegConfig(
1303
+ config.ffmpegOverrides,
1304
+ this.globalFfmpegConfig,
1305
+ this.detectedFfmpegConfig
1306
+ );
1307
+ const writerMode = policy.mode === "motion" ? "buffer" : "continuous";
1308
+ const writers = [];
1309
+ for (const sp of policy.streams) {
1310
+ const storageConfig = this.db.resolveStorageConfig(deviceId, `recording:${sp.streamId}`);
1311
+ const storageName = storageConfig?.storageName ?? "recordings";
1312
+ const subDirectory = storageConfig?.subDirectory ?? `recordings/${sp.streamId}`;
1313
+ const writerConfig = {
1314
+ deviceId,
1315
+ streamId: sp.streamId,
1316
+ segmentDurationSec: 4,
1317
+ storagePath: this.storagePath,
1318
+ storageName,
1319
+ subDirectory,
1320
+ ffmpeg: ffmpegConfig,
1321
+ mode: writerMode,
1322
+ preBufferSec: policy.preBufferSec
1323
+ };
1324
+ const writer = new SegmentWriter(
1325
+ writerConfig,
1326
+ this.logger.child(`writer:${deviceId}:${sp.streamId}`),
1327
+ this.eventBus,
1328
+ this.db,
1329
+ this.networkTracker
1330
+ );
1331
+ const rtspUrl = this.streamingEngine.getStreamUrl(`${policy.deviceId}_${sp.streamId}`, "rtsp");
1332
+ if (rtspUrl) {
1333
+ await writer.start(rtspUrl);
1334
+ }
1335
+ writers.push(writer);
1336
+ }
1337
+ const thumbStorageConfig = this.db.resolveStorageConfig(deviceId, "thumbnail:scrub");
1338
+ const thumbConfig = {
1339
+ deviceId,
1340
+ storagePath: this.storagePath,
1341
+ storageName: thumbStorageConfig?.storageName ?? "recordings",
1342
+ subDirectory: thumbStorageConfig?.subDirectory ?? "thumbnails/scrub",
1343
+ maxWidthPx: 160,
1344
+ jpegQuality: 65
1345
+ };
1346
+ const thumbnailExtractor = new ThumbnailExtractor(
1347
+ thumbConfig,
1348
+ this.logger.child(`thumb:${deviceId}`),
1349
+ this.db
1350
+ );
1351
+ const pipeline = this.pipelineManager.getPipeline(deviceId);
1352
+ if (pipeline) {
1353
+ thumbnailExtractor.attachToPipeline(pipeline, deviceId);
1354
+ }
1355
+ if (policy.mode === "motion") {
1356
+ thumbnailExtractor.setActive(false);
1357
+ }
1358
+ const motionUnsubscribe = this.subscribeToMotionEvents(deviceId, policy);
1359
+ const state = {
1360
+ deviceId,
1361
+ policy,
1362
+ writers,
1363
+ thumbnailExtractor,
1364
+ motionUnsubscribe,
1365
+ motionActive: false,
1366
+ motionTimeout: null,
1367
+ motionFallbackTimeout: null,
1368
+ motionReceived: false
1369
+ };
1370
+ this.recordings.set(deviceId, state);
1371
+ if (policy.mode === "motion") {
1372
+ state.motionFallbackTimeout = setTimeout(() => {
1373
+ const currentState = this.recordings.get(deviceId);
1374
+ if (!currentState || currentState.motionReceived) return;
1375
+ this.logger.warn(`No motion events received for ${deviceId} within ${MOTION_FALLBACK_TIMEOUT_MS / 1e3}s \u2014 falling back to continuous recording`);
1376
+ this.eventBus.emit({
1377
+ id: `recording-policy-fallback-${deviceId}-${Date.now()}`,
1378
+ timestamp: /* @__PURE__ */ new Date(),
1379
+ source: { type: "addon", id: "recording-engine" },
1380
+ category: "recording.policy.fallback",
1381
+ data: {
1382
+ deviceId,
1383
+ originalMode: "motion",
1384
+ fallbackMode: "continuous",
1385
+ reason: "no_motion_events"
1386
+ }
1387
+ });
1388
+ for (const writer of currentState.writers) {
1389
+ writer.flushAndContinue().catch((err) => {
1390
+ this.logger.error(`Failed to flush buffer for ${deviceId} during fallback: ${err}`);
1391
+ });
1392
+ }
1393
+ currentState.thumbnailExtractor.setActive(true);
1394
+ }, MOTION_FALLBACK_TIMEOUT_MS);
1395
+ }
1396
+ this.eventBus.emit({
1397
+ id: `recording-started-${deviceId}-${Date.now()}`,
1398
+ timestamp: /* @__PURE__ */ new Date(),
1399
+ source: { type: "addon", id: "recording-engine" },
1400
+ category: "recording.started",
1401
+ data: {
1402
+ deviceId,
1403
+ mode: policy.mode,
1404
+ streams: policy.streams.map((s) => s.streamId)
1405
+ }
1406
+ });
1407
+ this.logger.info(`Recording enabled for ${deviceId}`, { mode: policy.mode });
1408
+ }
1409
+ async disableRecording(deviceId) {
1410
+ const state = this.recordings.get(deviceId);
1411
+ if (!state) {
1412
+ this.logger.warn(`No active recording for ${deviceId}`);
1413
+ return;
1414
+ }
1415
+ let totalSegmentCount = 0;
1416
+ let totalSizeBytes = 0;
1417
+ for (const sp of state.policy.streams) {
1418
+ const usage = this.db.getStorageUsage(deviceId, sp.streamId);
1419
+ totalSegmentCount += usage.segmentCount;
1420
+ totalSizeBytes += usage.totalBytes;
1421
+ }
1422
+ const totalMB = Math.round(totalSizeBytes / 1024 / 1024);
1423
+ this.stopRecordingInternal(deviceId);
1424
+ this.recordings.delete(deviceId);
1425
+ this.db.addToCleanupQueue(deviceId, Date.now());
1426
+ this.eventBus.emit({
1427
+ id: `recording-stopped-${deviceId}-${Date.now()}`,
1428
+ timestamp: /* @__PURE__ */ new Date(),
1429
+ source: { type: "addon", id: "recording-engine" },
1430
+ category: "recording.stopped",
1431
+ data: {
1432
+ deviceId,
1433
+ segmentCount: totalSegmentCount,
1434
+ totalMB
1435
+ }
1436
+ });
1437
+ this.logger.info(`Recording disabled for ${deviceId}`, { segmentCount: totalSegmentCount, totalMB });
1438
+ }
1439
+ isRecording(deviceId) {
1440
+ return this.recordings.has(deviceId);
1441
+ }
1442
+ evaluatePolicies() {
1443
+ const now = /* @__PURE__ */ new Date();
1444
+ for (const [deviceId, state] of this.recordings) {
1445
+ const { policy } = state;
1446
+ if (policy.mode === "scheduled" || policy.mode === "composite") {
1447
+ if (!policy.scheduleRules || policy.scheduleRules.length === 0) continue;
1448
+ const matchingRule = policy.scheduleRules.find(
1449
+ (rule) => _RecordingCoordinator.evaluateScheduleRule(rule, now)
1450
+ );
1451
+ if (matchingRule) {
1452
+ const targetMode = matchingRule.mode === "motion" ? "buffer" : "continuous";
1453
+ for (const writer of state.writers) {
1454
+ if (writer.mode !== targetMode) {
1455
+ if (targetMode === "buffer") {
1456
+ writer.switchToBuffer();
1457
+ }
1458
+ }
1459
+ }
1460
+ } else {
1461
+ for (const writer of state.writers) {
1462
+ if (writer.mode !== "buffer") {
1463
+ writer.switchToBuffer();
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+ }
1470
+ static evaluateScheduleRule(rule, date) {
1471
+ const dayOfWeek = date.getDay();
1472
+ const timeMinutes = date.getHours() * 60 + date.getMinutes();
1473
+ const [startH, startM] = rule.startTime.split(":").map(Number);
1474
+ const [endH, endM] = rule.endTime.split(":").map(Number);
1475
+ const startMinutes = startH * 60 + startM;
1476
+ const endMinutes = endH * 60 + endM;
1477
+ if (endMinutes > startMinutes) {
1478
+ return rule.days.includes(dayOfWeek) && timeMinutes >= startMinutes && timeMinutes < endMinutes;
1479
+ }
1480
+ if (rule.days.includes(dayOfWeek) && timeMinutes >= startMinutes) {
1481
+ return true;
1482
+ }
1483
+ const previousDay = (dayOfWeek + 6) % 7;
1484
+ if (rule.days.includes(previousDay) && timeMinutes < endMinutes) {
1485
+ return true;
1486
+ }
1487
+ return false;
1488
+ }
1489
+ subscribeToMotionEvents(deviceId, policy) {
1490
+ if (policy.mode !== "motion" && policy.mode !== "composite") {
1491
+ return null;
1492
+ }
1493
+ return this.eventBus.subscribe(
1494
+ { category: `motion.${deviceId}` },
1495
+ (event) => {
1496
+ this.handleMotionEvent(deviceId, event);
1497
+ }
1498
+ );
1499
+ }
1500
+ handleMotionEvent(deviceId, event) {
1501
+ const state = this.recordings.get(deviceId);
1502
+ if (!state) return;
1503
+ if (!state.motionReceived) {
1504
+ state.motionReceived = true;
1505
+ if (state.motionFallbackTimeout) {
1506
+ clearTimeout(state.motionFallbackTimeout);
1507
+ state.motionFallbackTimeout = null;
1508
+ }
1509
+ }
1510
+ const motionDetected = event.data.active === true || event.data.type === "start";
1511
+ if (motionDetected) {
1512
+ state.motionActive = true;
1513
+ if (state.motionTimeout) {
1514
+ clearTimeout(state.motionTimeout);
1515
+ state.motionTimeout = null;
1516
+ }
1517
+ for (const writer of state.writers) {
1518
+ writer.flushAndContinue().catch((err) => {
1519
+ this.logger.error(`Failed to flush buffer for ${deviceId}: ${err}`);
1520
+ });
1521
+ }
1522
+ state.thumbnailExtractor.setActive(true);
1523
+ } else {
1524
+ if (state.motionTimeout) {
1525
+ clearTimeout(state.motionTimeout);
1526
+ }
1527
+ state.motionTimeout = setTimeout(() => {
1528
+ state.motionActive = false;
1529
+ state.motionTimeout = null;
1530
+ for (const writer of state.writers) {
1531
+ writer.switchToBuffer();
1532
+ }
1533
+ if (state.policy.mode === "motion") {
1534
+ state.thumbnailExtractor.setActive(false);
1535
+ }
1536
+ }, state.policy.postBufferSec * 1e3);
1537
+ }
1538
+ }
1539
+ stopRecordingInternal(deviceId) {
1540
+ const state = this.recordings.get(deviceId);
1541
+ if (!state) return;
1542
+ for (const writer of state.writers) {
1543
+ writer.stop();
1544
+ }
1545
+ state.thumbnailExtractor.detachFromPipeline(deviceId);
1546
+ if (state.motionUnsubscribe) {
1547
+ state.motionUnsubscribe();
1548
+ }
1549
+ if (state.motionTimeout) {
1550
+ clearTimeout(state.motionTimeout);
1551
+ state.motionTimeout = null;
1552
+ }
1553
+ if (state.motionFallbackTimeout) {
1554
+ clearTimeout(state.motionFallbackTimeout);
1555
+ state.motionFallbackTimeout = null;
1556
+ }
1557
+ }
1558
+ };
1559
+ }
1560
+ });
1561
+
1562
+ // src/addon.ts
1563
+ var addon_exports = {};
1564
+ __export(addon_exports, {
1565
+ PipelineAddon: () => PipelineAddon,
1566
+ default: () => addon_default
1567
+ });
1568
+ module.exports = __toCommonJS(addon_exports);
1569
+
1570
+ // src/stream-broker/decoder-registry.ts
1571
+ var DecoderRegistry = class {
1572
+ entries = [];
1573
+ register(provider, priority) {
1574
+ this.entries.push({ provider, priority });
1575
+ this.entries.sort((a, b) => a.priority - b.priority);
1576
+ }
1577
+ getDecoder(codec) {
1578
+ return this.entries.find((e) => e.provider.supportsCodec(codec))?.provider;
1579
+ }
1580
+ };
1581
+
1582
+ // src/stream-broker/ffmpeg-decoder-session.ts
1583
+ var import_node_child_process = require("child_process");
1584
+
1585
+ // src/stream-broker/frame-dropper.ts
1586
+ var FrameDropper = class {
1587
+ intervalMs;
1588
+ lastPassedAt = -Infinity;
1589
+ constructor(maxFps) {
1590
+ this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
1591
+ }
1592
+ shouldKeep() {
1593
+ if (this.intervalMs === 0) return true;
1594
+ const now = Date.now();
1595
+ if (now - this.lastPassedAt >= this.intervalMs) {
1596
+ this.lastPassedAt = now;
1597
+ return true;
1598
+ }
1599
+ return false;
1600
+ }
1601
+ setMaxFps(maxFps) {
1602
+ this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
1603
+ }
1604
+ };
1605
+
1606
+ // src/stream-broker/ffmpeg-decoder-session.ts
1607
+ var SOI = Buffer.from([255, 216]);
1608
+ var EOI = Buffer.from([255, 217]);
1609
+ function codecToInputFormat(codec) {
1610
+ switch (codec) {
1611
+ case "h265":
1612
+ case "hevc":
1613
+ return "hevc";
1614
+ case "mjpeg":
1615
+ return "mjpeg";
1616
+ case "h264":
1617
+ default:
1618
+ return "h264";
1619
+ }
1620
+ }
1621
+ function buildFfmpegArgs(config) {
1622
+ const inputFormat = codecToInputFormat(config.codec);
1623
+ const args = ["-hide_banner", "-loglevel", "error", "-f", inputFormat, "-i", "pipe:0"];
1624
+ if (config.scale > 1) {
1625
+ args.push("-vf", `scale=iw/${config.scale}:ih/${config.scale}`);
1626
+ }
1627
+ args.push("-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", "pipe:1");
1628
+ return args;
1629
+ }
1630
+ var FfmpegDecoderSession = class {
1631
+ config;
1632
+ frameDropper;
1633
+ process = null;
1634
+ frameCallbacks = /* @__PURE__ */ new Set();
1635
+ outputBuffer = Buffer.alloc(0);
1636
+ destroyed = false;
1637
+ // Stats tracking
1638
+ inputPackets = 0;
1639
+ outputFrames = 0;
1640
+ droppedFrames = 0;
1641
+ totalDecodeTimeMs = 0;
1642
+ decodeCount = 0;
1643
+ startTime = Date.now();
1644
+ constructor(config) {
1645
+ this.config = { ...config };
1646
+ this.frameDropper = new FrameDropper(config.maxFps);
1647
+ this.spawnFfmpeg();
1648
+ }
1649
+ spawnFfmpeg() {
1650
+ if (this.destroyed) return;
1651
+ this.killFfmpeg();
1652
+ this.outputBuffer = Buffer.alloc(0);
1653
+ const args = buildFfmpegArgs(this.config);
1654
+ this.process = (0, import_node_child_process.spawn)("ffmpeg", args);
1655
+ this.process.stdin?.on("error", () => {
1656
+ });
1657
+ this.process.stdout?.on("data", (chunk) => {
1658
+ this.handleOutputData(chunk);
1659
+ });
1660
+ this.process.stderr?.on("data", (data) => {
1661
+ void data;
1662
+ });
1663
+ this.process.on("error", (_err) => {
1664
+ });
1665
+ this.process.on("close", () => {
1666
+ if (!this.destroyed) {
1667
+ this.process = null;
1668
+ }
1669
+ });
1670
+ }
1671
+ killFfmpeg() {
1672
+ if (this.process) {
1673
+ try {
1674
+ this.process.kill("SIGKILL");
1675
+ } catch {
1676
+ }
1677
+ this.process = null;
1678
+ }
1679
+ }
1680
+ handleOutputData(chunk) {
1681
+ this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
1682
+ let searchFrom = 0;
1683
+ while (true) {
1684
+ const soiIndex = this.outputBuffer.indexOf(SOI, searchFrom);
1685
+ if (soiIndex === -1) break;
1686
+ const eoiIndex = this.outputBuffer.indexOf(EOI, soiIndex + 2);
1687
+ if (eoiIndex === -1) break;
1688
+ const frameEnd = eoiIndex + 2;
1689
+ const jpegData = this.outputBuffer.slice(soiIndex, frameEnd);
1690
+ searchFrom = frameEnd;
1691
+ this.emitFrame(Buffer.from(jpegData));
1692
+ }
1693
+ if (searchFrom > 0) {
1694
+ this.outputBuffer = Buffer.from(this.outputBuffer.slice(searchFrom));
1695
+ }
1696
+ }
1697
+ emitFrame(data) {
1698
+ const decodeStart = Date.now();
1699
+ if (!this.frameDropper.shouldKeep()) {
1700
+ this.droppedFrames++;
1701
+ return;
1702
+ }
1703
+ const decodeTime = Date.now() - decodeStart;
1704
+ this.totalDecodeTimeMs += decodeTime;
1705
+ this.decodeCount++;
1706
+ this.outputFrames++;
1707
+ const frame = {
1708
+ data,
1709
+ width: 0,
1710
+ height: 0,
1711
+ format: "jpeg",
1712
+ timestamp: Date.now()
1713
+ };
1714
+ for (const cb of this.frameCallbacks) {
1715
+ cb(frame);
1716
+ }
1717
+ }
1718
+ pushPacket(packet) {
1719
+ if (this.destroyed || !this.process?.stdin) return;
1720
+ this.inputPackets++;
1721
+ try {
1722
+ this.process.stdin.write(packet.data);
1723
+ } catch {
1724
+ }
1725
+ }
1726
+ onFrame(callback) {
1727
+ this.frameCallbacks.add(callback);
1728
+ return () => {
1729
+ this.frameCallbacks.delete(callback);
1730
+ };
1731
+ }
1732
+ updateConfig(update) {
1733
+ const needsRestart = update.scale !== void 0 && update.scale !== this.config.scale || update.outputFormat !== void 0 && update.outputFormat !== this.config.outputFormat || update.codec !== void 0 && update.codec !== this.config.codec;
1734
+ this.config = { ...this.config, ...update };
1735
+ if (update.maxFps !== void 0) {
1736
+ this.frameDropper.setMaxFps(update.maxFps);
1737
+ }
1738
+ if (needsRestart) {
1739
+ this.spawnFfmpeg();
1740
+ }
1741
+ }
1742
+ async destroy() {
1743
+ if (this.destroyed) return;
1744
+ this.destroyed = true;
1745
+ this.killFfmpeg();
1746
+ this.frameCallbacks.clear();
1747
+ }
1748
+ getStats() {
1749
+ const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
1750
+ return {
1751
+ inputFps: this.inputPackets / uptimeSec,
1752
+ outputFps: this.outputFrames / uptimeSec,
1753
+ avgDecodeTimeMs: this.decodeCount > 0 ? this.totalDecodeTimeMs / this.decodeCount : 0,
1754
+ droppedFrames: this.droppedFrames
1755
+ };
1756
+ }
1757
+ };
1758
+
1759
+ // src/stream-broker/ffmpeg-decoder-provider.ts
1760
+ var SUPPORTED_CODECS = /* @__PURE__ */ new Set(["h264", "h265", "hevc", "mjpeg"]);
1761
+ var FfmpegDecoderProvider = class {
1762
+ id = "ffmpeg";
1763
+ name = "FFmpeg Decoder";
1764
+ supportsCodec(codec) {
1765
+ return SUPPORTED_CODECS.has(codec);
1766
+ }
1767
+ createSession(config) {
1768
+ return new FfmpegDecoderSession(config);
1769
+ }
1770
+ };
1771
+
1772
+ // src/stream-broker/stream-broker.ts
1773
+ var import_node_child_process2 = require("child_process");
1774
+
1775
+ // src/stream-broker/stream-pipe-server.ts
1776
+ var import_node_net = __toESM(require("net"), 1);
1777
+ var StreamPipeServer = class {
1778
+ server;
1779
+ clients = /* @__PURE__ */ new Set();
1780
+ port = 0;
1781
+ started = false;
1782
+ logger;
1783
+ constructor(logger) {
1784
+ this.logger = logger;
1785
+ this.server = import_node_net.default.createServer((socket) => {
1786
+ this.handleConnection(socket);
1787
+ });
1788
+ this.server.on("error", (err) => {
1789
+ this.logger?.error(`StreamPipeServer error: ${err.message}`);
1790
+ });
1791
+ }
1792
+ async start() {
1793
+ if (this.started) {
1794
+ return;
1795
+ }
1796
+ await new Promise((resolve, reject) => {
1797
+ this.server.once("error", reject);
1798
+ this.server.listen(0, "127.0.0.1", () => {
1799
+ this.server.removeListener("error", reject);
1800
+ const address = this.server.address();
1801
+ this.port = address.port;
1802
+ this.started = true;
1803
+ this.logger?.debug(`StreamPipeServer listening on port ${this.port}`);
1804
+ resolve();
1805
+ });
1806
+ });
1807
+ }
1808
+ /**
1809
+ * Broadcast raw encoded bytes to all connected clients.
1810
+ * Silently drops data for clients that are slow or disconnected.
1811
+ */
1812
+ broadcast(data) {
1813
+ for (const client of this.clients) {
1814
+ if (client.destroyed) {
1815
+ this.clients.delete(client);
1816
+ continue;
1817
+ }
1818
+ client.write(data, (err) => {
1819
+ if (err) {
1820
+ this.logger?.debug(
1821
+ `StreamPipeServer write error, removing client: ${err.message}`
1822
+ );
1823
+ this.removeClient(client);
1824
+ }
1825
+ });
1826
+ }
1827
+ }
1828
+ getPort() {
1829
+ return this.port;
1830
+ }
1831
+ getUrl() {
1832
+ return `tcp://127.0.0.1:${this.port}`;
1833
+ }
1834
+ getClientCount() {
1835
+ return this.clients.size;
1836
+ }
1837
+ isStarted() {
1838
+ return this.started;
1839
+ }
1840
+ async stop() {
1841
+ if (!this.started) {
1842
+ return;
1843
+ }
1844
+ this.started = false;
1845
+ for (const client of this.clients) {
1846
+ client.destroy();
1847
+ }
1848
+ this.clients.clear();
1849
+ await new Promise((resolve) => {
1850
+ this.server.close(() => {
1851
+ this.logger?.debug("StreamPipeServer stopped");
1852
+ resolve();
1853
+ });
1854
+ });
1855
+ }
1856
+ handleConnection(socket) {
1857
+ this.clients.add(socket);
1858
+ this.logger?.debug(
1859
+ `StreamPipeServer client connected (total: ${this.clients.size})`
1860
+ );
1861
+ socket.on("close", () => {
1862
+ this.removeClient(socket);
1863
+ });
1864
+ socket.on("error", () => {
1865
+ this.removeClient(socket);
1866
+ });
1867
+ }
1868
+ removeClient(socket) {
1869
+ const existed = this.clients.delete(socket);
1870
+ if (existed) {
1871
+ this.logger?.debug(
1872
+ `StreamPipeServer client disconnected (total: ${this.clients.size})`
1873
+ );
1874
+ }
1875
+ if (!socket.destroyed) {
1876
+ socket.destroy();
1877
+ }
1878
+ }
1879
+ };
1880
+
1881
+ // src/stream-broker/stream-broker.ts
1882
+ var DEFAULT_MAX_FPS = 5;
1883
+ var DEFAULT_FORMAT = "jpeg";
1884
+ var DEFAULT_SCALE = 1;
1885
+ var INITIAL_RECONNECT_DELAY_MS = 1e3;
1886
+ var MAX_RECONNECT_DELAY_MS = 3e4;
1887
+ var DECODER_TEARDOWN_GRACE_MS = 2e3;
1888
+ var H264_IDR_NAL_TYPE = 5;
1889
+ var H265_IRAP_NAL_TYPE_MIN = 16;
1890
+ var H265_IRAP_NAL_TYPE_MAX = 21;
1891
+ var StreamBroker = class {
1892
+ deviceId;
1893
+ _status = "idle";
1894
+ source;
1895
+ decoderSession = null;
1896
+ decoderUnsubscribe = null;
1897
+ decoderRegistry;
1898
+ logger;
1899
+ startedAt = Date.now();
1900
+ ffmpegProcess;
1901
+ reconnectTimer;
1902
+ reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
1903
+ manualStop = false;
1904
+ stopping = false;
1905
+ decoderTeardownTimer;
1906
+ restreamers = [];
1907
+ pipeServer;
1908
+ encodedCallbacks = /* @__PURE__ */ new Set();
1909
+ decodedSubscribers = /* @__PURE__ */ new Map();
1910
+ audioChunkCallbacks = /* @__PURE__ */ new Set();
1911
+ constructor(deviceId, decoderRegistry, logger) {
1912
+ this.deviceId = deviceId;
1913
+ this.decoderRegistry = decoderRegistry;
1914
+ this.logger = logger;
1915
+ this.pipeServer = new StreamPipeServer(logger?.child("pipe-server"));
1916
+ }
1917
+ get status() {
1918
+ return this._status;
1919
+ }
1920
+ setRestreamers(restreamers) {
1921
+ this.restreamers = restreamers;
1922
+ }
1923
+ async start(source) {
1924
+ this.source = source;
1925
+ this.manualStop = false;
1926
+ this.stopping = false;
1927
+ this._status = "connecting";
1928
+ await this.pipeServer.start();
1929
+ this.logger?.info(
1930
+ `Pipe server started for ${this.deviceId} at ${this.pipeServer.getUrl()}`
1931
+ );
1932
+ if (source.type === "rtsp") {
1933
+ this.startRtspReader(source);
1934
+ } else {
1935
+ this._status = "streaming";
1936
+ }
1937
+ }
1938
+ async stop() {
1939
+ this.stopping = true;
1940
+ this.manualStop = true;
1941
+ this._status = "stopped";
1942
+ this.killFfmpeg();
1943
+ if (this.reconnectTimer) {
1944
+ clearTimeout(this.reconnectTimer);
1945
+ this.reconnectTimer = void 0;
1946
+ }
1947
+ if (this.decoderTeardownTimer) {
1948
+ clearTimeout(this.decoderTeardownTimer);
1949
+ this.decoderTeardownTimer = void 0;
1950
+ }
1951
+ if (this.decoderSession) {
1952
+ await this.destroyDecoder();
1953
+ }
1954
+ await this.pipeServer.stop();
1955
+ this.encodedCallbacks.clear();
1956
+ this.decodedSubscribers.clear();
1957
+ this.audioChunkCallbacks.clear();
1958
+ }
1959
+ pushEncodedPacket(packet) {
1960
+ if (this.stopping) return;
1961
+ for (const cb of this.encodedCallbacks) {
1962
+ cb(packet);
1963
+ }
1964
+ if (packet.type === "video") {
1965
+ this.pipeServer.broadcast(packet.data);
1966
+ }
1967
+ for (const restreamer of this.restreamers) {
1968
+ const streamId = `${this.deviceId}/video`;
1969
+ restreamer.pushPacket(streamId, packet);
1970
+ }
1971
+ if (packet.type === "video" && this.decoderSession) {
1972
+ this.decoderSession.pushPacket(packet);
1973
+ }
1974
+ }
1975
+ onEncodedData(callback) {
1976
+ this.encodedCallbacks.add(callback);
1977
+ return () => {
1978
+ this.encodedCallbacks.delete(callback);
1979
+ };
1980
+ }
1981
+ onDecodedFrame(callback, options) {
1982
+ const maxFps = options?.maxFps ?? DEFAULT_MAX_FPS;
1983
+ const subscriberId = /* @__PURE__ */ Symbol("decoded-subscriber");
1984
+ const frameDropper = new FrameDropper(maxFps);
1985
+ const subscriber = { callback, frameDropper };
1986
+ this.decodedSubscribers.set(subscriberId, subscriber);
1987
+ if (this.decoderTeardownTimer) {
1988
+ clearTimeout(this.decoderTeardownTimer);
1989
+ this.decoderTeardownTimer = void 0;
1990
+ }
1991
+ if (!this.decoderSession && this.source) {
1992
+ this.createSharedDecoderSession(options);
1993
+ }
1994
+ return () => {
1995
+ this.decodedSubscribers.delete(subscriberId);
1996
+ if (this.decodedSubscribers.size === 0 && this.decoderSession) {
1997
+ this.decoderTeardownTimer = setTimeout(() => {
1998
+ this.decoderTeardownTimer = void 0;
1999
+ if (this.decodedSubscribers.size === 0 && this.decoderSession) {
2000
+ this.destroyDecoder();
2001
+ }
2002
+ }, DECODER_TEARDOWN_GRACE_MS);
2003
+ }
2004
+ };
2005
+ }
2006
+ onDecodedAudioChunk(callback) {
2007
+ this.audioChunkCallbacks.add(callback);
2008
+ return () => {
2009
+ this.audioChunkCallbacks.delete(callback);
2010
+ };
2011
+ }
2012
+ getStats() {
2013
+ const decoderStats = this.decoderSession?.getStats();
2014
+ return {
2015
+ status: this._status,
2016
+ inputFps: decoderStats?.inputFps ?? 0,
2017
+ decodeFps: decoderStats?.outputFps ?? 0,
2018
+ encodedSubscribers: this.encodedCallbacks.size,
2019
+ decodedSubscribers: this.decodedSubscribers.size,
2020
+ uptimeMs: Date.now() - this.startedAt
2021
+ };
2022
+ }
2023
+ /**
2024
+ * Returns the local TCP URL that restreamers should connect to
2025
+ * instead of the camera's RTSP URL. This ensures the broker is
2026
+ * the sole reader from the camera.
2027
+ */
2028
+ getLocalStreamUrl() {
2029
+ return this.pipeServer.getUrl();
2030
+ }
2031
+ /** Returns the number of TCP pipe clients currently connected */
2032
+ getPipeClientCount() {
2033
+ return this.pipeServer.getClientCount();
2034
+ }
2035
+ startRtspReader(source) {
2036
+ const codec = source.videoCodec ?? "h264";
2037
+ const isHevc = codec === "h265" || codec === "hevc";
2038
+ const args = [
2039
+ "-hide_banner",
2040
+ "-loglevel",
2041
+ "error",
2042
+ "-rtsp_transport",
2043
+ "tcp",
2044
+ "-i",
2045
+ source.url,
2046
+ "-c:v",
2047
+ "copy",
2048
+ "-f",
2049
+ isHevc ? "hevc" : "h264",
2050
+ "-bsf:v",
2051
+ isHevc ? "hevc_mp4toannexb" : "h264_mp4toannexb",
2052
+ "pipe:1"
2053
+ ];
2054
+ this.logger?.debug(`Spawning ffmpeg RTSP reader for ${this.deviceId}`, {
2055
+ url: source.url,
2056
+ codec
2057
+ });
2058
+ const proc = (0, import_node_child_process2.spawn)("ffmpeg", args, {
2059
+ stdio: ["pipe", "pipe", "pipe"]
2060
+ });
2061
+ this.ffmpegProcess = proc;
2062
+ proc.stdin?.on("error", () => {
2063
+ });
2064
+ proc.stdout?.on("data", (chunk) => {
2065
+ if (this.stopping) return;
2066
+ this._status = "streaming";
2067
+ this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
2068
+ const keyframe = isHevc ? isHevcKeyframe(chunk) : isH264Keyframe(chunk);
2069
+ const packet = {
2070
+ type: "video",
2071
+ data: chunk,
2072
+ pts: Date.now(),
2073
+ dts: Date.now(),
2074
+ keyframe,
2075
+ codec
2076
+ };
2077
+ this.pushEncodedPacket(packet);
2078
+ });
2079
+ proc.stderr?.on("data", (data) => {
2080
+ const msg = data.toString().trim();
2081
+ if (msg) {
2082
+ this.logger?.warn(`ffmpeg RTSP reader stderr [${this.deviceId}]: ${msg}`);
2083
+ }
2084
+ });
2085
+ proc.on("exit", (code, signal) => {
2086
+ this.ffmpegProcess = void 0;
2087
+ if (this.manualStop) {
2088
+ return;
2089
+ }
2090
+ this.logger?.warn(`ffmpeg RTSP reader exited for ${this.deviceId}`, {
2091
+ code,
2092
+ signal
2093
+ });
2094
+ this._status = "error";
2095
+ this.scheduleReconnect();
2096
+ });
2097
+ proc.on("error", (err) => {
2098
+ this.ffmpegProcess = void 0;
2099
+ if (this.manualStop) {
2100
+ return;
2101
+ }
2102
+ this.logger?.error(`ffmpeg RTSP reader error for ${this.deviceId}: ${err.message}`);
2103
+ this._status = "error";
2104
+ this.scheduleReconnect();
2105
+ });
2106
+ }
2107
+ scheduleReconnect() {
2108
+ if (this.manualStop || !this.source) {
2109
+ return;
2110
+ }
2111
+ this.logger?.info(
2112
+ `Reconnecting RTSP reader for ${this.deviceId} in ${this.reconnectDelayMs}ms`
2113
+ );
2114
+ this.reconnectTimer = setTimeout(() => {
2115
+ this.reconnectTimer = void 0;
2116
+ if (!this.manualStop && this.source) {
2117
+ this._status = "connecting";
2118
+ this.startRtspReader(this.source);
2119
+ }
2120
+ }, this.reconnectDelayMs);
2121
+ this.reconnectDelayMs = Math.min(
2122
+ this.reconnectDelayMs * 2,
2123
+ MAX_RECONNECT_DELAY_MS
2124
+ );
2125
+ }
2126
+ destroyDecoder() {
2127
+ if (this.decoderUnsubscribe) {
2128
+ this.decoderUnsubscribe();
2129
+ this.decoderUnsubscribe = null;
2130
+ }
2131
+ const session = this.decoderSession;
2132
+ this.decoderSession = null;
2133
+ return session?.destroy() ?? Promise.resolve();
2134
+ }
2135
+ killFfmpeg() {
2136
+ if (this.ffmpegProcess) {
2137
+ this.ffmpegProcess.kill("SIGTERM");
2138
+ this.ffmpegProcess = void 0;
2139
+ }
2140
+ }
2141
+ createSharedDecoderSession(options) {
2142
+ const codec = this.source?.videoCodec ?? "h264";
2143
+ const provider = this.decoderRegistry.getDecoder(codec);
2144
+ if (!provider) {
2145
+ return;
2146
+ }
2147
+ const config = {
2148
+ codec,
2149
+ maxFps: 0,
2150
+ outputFormat: options?.format ?? DEFAULT_FORMAT,
2151
+ scale: options?.scale ?? DEFAULT_SCALE
2152
+ };
2153
+ this.decoderSession = provider.createSession(config);
2154
+ this.decoderUnsubscribe = this.decoderSession.onFrame((frame) => {
2155
+ for (const subscriber of this.decodedSubscribers.values()) {
2156
+ if (subscriber.frameDropper.shouldKeep()) {
2157
+ subscriber.callback(frame);
2158
+ }
2159
+ }
2160
+ });
2161
+ }
2162
+ };
2163
+ function isH264Keyframe(data) {
2164
+ for (let i = 0; i < data.length - 4; i++) {
2165
+ if (data[i] === 0 && data[i + 1] === 0) {
2166
+ let nalStart = -1;
2167
+ if (data[i + 2] === 1) {
2168
+ nalStart = i + 3;
2169
+ } else if (data[i + 2] === 0 && data[i + 3] === 1) {
2170
+ nalStart = i + 4;
2171
+ }
2172
+ if (nalStart >= 0 && nalStart < data.length) {
2173
+ const nalType = data[nalStart] & 31;
2174
+ if (nalType === H264_IDR_NAL_TYPE) {
2175
+ return true;
2176
+ }
2177
+ }
2178
+ }
2179
+ }
2180
+ return false;
2181
+ }
2182
+ function isHevcKeyframe(data) {
2183
+ for (let i = 0; i < data.length - 5; i++) {
2184
+ if (data[i] === 0 && data[i + 1] === 0) {
2185
+ let nalStart = -1;
2186
+ if (data[i + 2] === 1) {
2187
+ nalStart = i + 3;
2188
+ } else if (data[i + 2] === 0 && data[i + 3] === 1) {
2189
+ nalStart = i + 4;
2190
+ }
2191
+ if (nalStart >= 0 && nalStart < data.length) {
2192
+ const nalType = data[nalStart] >> 1 & 63;
2193
+ if (nalType >= H265_IRAP_NAL_TYPE_MIN && nalType <= H265_IRAP_NAL_TYPE_MAX) {
2194
+ return true;
2195
+ }
2196
+ }
2197
+ }
2198
+ }
2199
+ return false;
2200
+ }
2201
+
2202
+ // src/stream-broker/stream-broker-manager.ts
2203
+ var StreamBrokerManager = class {
2204
+ constructor(decoderRegistry, logger) {
2205
+ this.decoderRegistry = decoderRegistry;
2206
+ this.logger = logger;
2207
+ }
2208
+ brokers = /* @__PURE__ */ new Map();
2209
+ restreamers = [];
2210
+ logger;
2211
+ setRestreamers(restreamers) {
2212
+ this.restreamers = restreamers;
2213
+ for (const broker of this.brokers.values()) {
2214
+ broker.setRestreamers(restreamers);
2215
+ }
2216
+ this.logger.info(`Updated restreamers (count: ${restreamers.length})`);
2217
+ }
2218
+ getRestreamers() {
2219
+ return this.restreamers;
2220
+ }
2221
+ /**
2222
+ * Create and start a broker.
2223
+ * brokerId can be a simple deviceId (backward compat) or a compound key like `${deviceId}/${streamId}`.
2224
+ */
2225
+ async createBroker(brokerId, source) {
2226
+ const brokerLogger = this.logger.child(`broker:${brokerId}`);
2227
+ const broker = new StreamBroker(brokerId, this.decoderRegistry, brokerLogger);
2228
+ broker.setRestreamers(this.restreamers);
2229
+ await broker.start(source);
2230
+ this.brokers.set(brokerId, broker);
2231
+ return broker;
2232
+ }
2233
+ getBroker(brokerId) {
2234
+ return this.brokers.get(brokerId);
2235
+ }
2236
+ async destroyBroker(brokerId) {
2237
+ const broker = this.brokers.get(brokerId);
2238
+ if (broker) {
2239
+ await broker.stop();
2240
+ this.brokers.delete(brokerId);
2241
+ }
2242
+ }
2243
+ /** Destroy all brokers matching a prefix (e.g., `${deviceId}/` to destroy all streams for a device) */
2244
+ async destroyBrokersForDevice(deviceId) {
2245
+ const prefix = `${deviceId}/`;
2246
+ const toDestroy = [];
2247
+ for (const brokerId of this.brokers.keys()) {
2248
+ if (brokerId === deviceId || brokerId.startsWith(prefix)) {
2249
+ toDestroy.push(brokerId);
2250
+ }
2251
+ }
2252
+ await Promise.all(toDestroy.map((id) => this.destroyBroker(id)));
2253
+ }
2254
+ listBrokers() {
2255
+ return [...this.brokers.values()];
2256
+ }
2257
+ async destroyAll() {
2258
+ const stopPromises = [...this.brokers.values()].map((broker) => broker.stop());
2259
+ await Promise.all(stopPromises);
2260
+ this.brokers.clear();
2261
+ }
2262
+ };
2263
+
2264
+ // src/persistence/event-persistence.ts
2265
+ function classifyMediaPath(path2) {
2266
+ if (path2.endsWith("-thumb.jpg") && path2.includes("/crops/")) return "crop-thumb";
2267
+ if (path2.endsWith("-full.jpg") && path2.includes("/crops/")) return "crop-full";
2268
+ if (path2.includes("debug-annotated-thumb.jpg")) return "debug-annotated-thumb";
2269
+ if (path2.includes("debug-annotated-full.jpg")) return "debug-annotated-full";
2270
+ if (path2.includes("original-thumb.jpg")) return "original-thumb";
2271
+ if (path2.includes("original.jpg")) return "original";
2272
+ if (path2.includes("crop.jpg")) return "inline-crop";
2273
+ return "unknown";
2274
+ }
2275
+ var EventPersistenceService = class {
2276
+ constructor(deps) {
2277
+ this.deps = deps;
2278
+ this.logger = deps.logger;
2279
+ this.getStorageLocation = deps.getStorageLocation;
2280
+ }
2281
+ eventBuffer = [];
2282
+ mediaBuffer = [];
2283
+ eventFlushTimer;
2284
+ mediaFlushTimer;
2285
+ EVENT_FLUSH_INTERVAL_MS = 5e3;
2286
+ MEDIA_FLUSH_INTERVAL_MS = 3e3;
2287
+ MAX_EVENT_BUFFER = 50;
2288
+ MAX_MEDIA_BUFFER_MB = 50;
2289
+ logger;
2290
+ getStorageLocation;
2291
+ unsubscribe;
2292
+ start() {
2293
+ this.unsubscribe = this.deps.subscribe(
2294
+ { category: "detection.event" },
2295
+ (event) => this.bufferEvent(event)
2296
+ );
2297
+ this.eventFlushTimer = setInterval(() => {
2298
+ this.flushEvents().catch((e) => this.logger.warn(`Event flush failed: ${e}`));
2299
+ }, this.EVENT_FLUSH_INTERVAL_MS);
2300
+ this.mediaFlushTimer = setInterval(() => {
2301
+ this.flushMedia().catch((e) => this.logger.warn(`Media flush failed: ${e}`));
2302
+ }, this.MEDIA_FLUSH_INTERVAL_MS);
2303
+ }
2304
+ stop() {
2305
+ this.unsubscribe?.();
2306
+ clearInterval(this.eventFlushTimer);
2307
+ clearInterval(this.mediaFlushTimer);
2308
+ this.flushEvents().catch(() => {
2309
+ });
2310
+ this.flushMedia().catch(() => {
2311
+ });
2312
+ }
2313
+ // --- Public: save media associated with an event ---
2314
+ /** Save object crops (thumbnail + full-res) for an event */
2315
+ saveDetectionCrops(eventId, crops) {
2316
+ for (const crop of crops) {
2317
+ if (crop.thumbnail) {
2318
+ this.bufferMedia(eventId, `events/${eventId}/crops/${crop.trackId}-thumb.jpg`, crop.thumbnail, "high");
2319
+ }
2320
+ if (crop.fullCrop) {
2321
+ this.bufferMedia(eventId, `events/${eventId}/crops/${crop.trackId}-full.jpg`, crop.fullCrop, "normal");
2322
+ }
2323
+ }
2324
+ }
2325
+ /** Save annotated frame (thumbnail + full-res) for an event */
2326
+ saveAnnotatedFrame(eventId, result) {
2327
+ this.bufferMedia(eventId, `events/${eventId}/debug-annotated-thumb.jpg`, result.thumbnail, "high");
2328
+ this.bufferMedia(eventId, `events/${eventId}/debug-annotated-full.jpg`, result.full, "normal");
2329
+ }
2330
+ /** Save original frame for an event */
2331
+ async saveOriginalFrame(eventId, frame) {
2332
+ const sharp2 = (await import("sharp")).default;
2333
+ const jpeg = await sharp2(frame.data, {
2334
+ raw: { width: frame.width, height: frame.height, channels: 3 }
2335
+ }).jpeg({ quality: 85 }).toBuffer();
2336
+ this.bufferMedia(eventId, `events/${eventId}/original.jpg`, jpeg, "normal");
2337
+ const thumb = await sharp2(jpeg).resize(320, 240, { fit: "inside" }).jpeg({ quality: 75 }).toBuffer();
2338
+ this.bufferMedia(eventId, `events/${eventId}/original-thumb.jpg`, thumb, "high");
2339
+ }
2340
+ // --- Public: query track media ---
2341
+ /**
2342
+ * Get all media files for a specific event (from buffer or storage).
2343
+ * Returns immediately — serves from in-memory buffer if not yet flushed.
2344
+ */
2345
+ async getEventMedia(eventId) {
2346
+ const results = [];
2347
+ for (const entry of this.mediaBuffer) {
2348
+ if (entry.eventId === eventId) {
2349
+ results.push({
2350
+ path: entry.path,
2351
+ type: classifyMediaPath(entry.path),
2352
+ data: entry.data,
2353
+ source: "buffer"
2354
+ });
2355
+ }
2356
+ }
2357
+ try {
2358
+ const location = this.getStorageLocation("events");
2359
+ if (location.files) {
2360
+ const files = await location.files.listFiles(`events/${eventId}/`);
2361
+ for (const file of files) {
2362
+ if (results.some((r) => r.path === file)) continue;
2363
+ try {
2364
+ const data = await location.files.readFile(file);
2365
+ results.push({
2366
+ path: file,
2367
+ type: classifyMediaPath(file),
2368
+ data,
2369
+ source: "storage"
2370
+ });
2371
+ } catch {
2372
+ }
2373
+ }
2374
+ }
2375
+ } catch {
2376
+ }
2377
+ return results;
2378
+ }
2379
+ /**
2380
+ * Get all media for a specific track across all events.
2381
+ * Useful for building a track timeline with all snapshots.
2382
+ */
2383
+ async getTrackMedia(trackId) {
2384
+ const results = [];
2385
+ for (const entry of this.mediaBuffer) {
2386
+ if (entry.path.includes(trackId)) {
2387
+ results.push({
2388
+ path: entry.path,
2389
+ type: classifyMediaPath(entry.path),
2390
+ data: entry.data,
2391
+ source: "buffer"
2392
+ });
2393
+ }
2394
+ }
2395
+ const bufferedEventIds = this.eventBuffer.filter((e) => e.trackId === trackId).map((e) => e.id);
2396
+ try {
2397
+ const location = this.getStorageLocation("events");
2398
+ if (location.structured) {
2399
+ const stored = await location.structured.query("detection_events", {
2400
+ where: { trackId },
2401
+ orderBy: { field: "timestamp", direction: "asc" },
2402
+ limit: 100
2403
+ });
2404
+ const allEventIds = /* @__PURE__ */ new Set([
2405
+ ...bufferedEventIds,
2406
+ ...stored.map((r) => r.id)
2407
+ ]);
2408
+ for (const eventId of allEventIds) {
2409
+ const eventMedia = await this.getEventMedia(eventId);
2410
+ for (const media of eventMedia) {
2411
+ if (!results.some((r) => r.path === media.path)) {
2412
+ results.push(media);
2413
+ }
2414
+ }
2415
+ }
2416
+ }
2417
+ } catch {
2418
+ }
2419
+ return results;
2420
+ }
2421
+ /**
2422
+ * Get all media for a device within a time range.
2423
+ */
2424
+ async getDeviceMedia(deviceId, since, until) {
2425
+ const results = [];
2426
+ try {
2427
+ const location = this.getStorageLocation("events");
2428
+ if (!location.structured) return results;
2429
+ const events = await location.structured.query("detection_events", {
2430
+ where: { deviceId },
2431
+ whereBetween: since || until ? { timestamp: [since ?? 0, until ?? Date.now()] } : void 0,
2432
+ orderBy: { field: "timestamp", direction: "desc" },
2433
+ limit: 50
2434
+ });
2435
+ for (const event of events) {
2436
+ const media = await this.getEventMedia(event.id);
2437
+ results.push(...media);
2438
+ }
2439
+ } catch {
2440
+ }
2441
+ return results;
2442
+ }
2443
+ // --- Public: status ---
2444
+ getBufferStatus() {
2445
+ return {
2446
+ eventCount: this.eventBuffer.length,
2447
+ mediaCount: this.mediaBuffer.length,
2448
+ mediaSizeMB: this.mediaBuffer.reduce((sum, m) => sum + m.data.length, 0) / (1024 * 1024)
2449
+ };
2450
+ }
2451
+ /** Force flush everything */
2452
+ async flush() {
2453
+ await this.flushEvents();
2454
+ await this.flushMedia();
2455
+ }
2456
+ // --- Internal: buffering ---
2457
+ bufferEvent(event) {
2458
+ const mediaPaths = this.mediaBuffer.filter((m) => m.eventId === event.id).map((m) => m.path);
2459
+ const persistable = {
2460
+ id: event.id,
2461
+ timestamp: Date.now(),
2462
+ deviceId: event.data.deviceId ?? "",
2463
+ category: event.category,
2464
+ className: event.data.className ?? "",
2465
+ score: event.data.score ?? 0,
2466
+ trackId: event.data.trackId ?? "",
2467
+ severity: event.data.severity ?? "",
2468
+ description: event.data.description ?? "",
2469
+ data: event.data,
2470
+ mediaFiles: mediaPaths
2471
+ };
2472
+ this.eventBuffer.push(persistable);
2473
+ const detection = event.data.detection;
2474
+ if (detection?.crop && Buffer.isBuffer(detection.crop)) {
2475
+ this.bufferMedia(event.id, `events/${event.id}/crop.jpg`, detection.crop, "high");
2476
+ }
2477
+ if (this.eventBuffer.length >= this.MAX_EVENT_BUFFER) {
2478
+ this.flushEvents().catch((e) => this.logger.warn(`Auto-flush failed: ${e}`));
2479
+ }
2480
+ }
2481
+ bufferMedia(eventId, path2, data, priority) {
2482
+ this.mediaBuffer.push({ eventId, path: path2, data, priority });
2483
+ const totalMB = this.mediaBuffer.reduce((sum, m) => sum + m.data.length, 0) / (1024 * 1024);
2484
+ if (totalMB >= this.MAX_MEDIA_BUFFER_MB) {
2485
+ this.flushMedia().catch((e) => this.logger.warn(`Media overflow flush failed: ${e}`));
2486
+ }
2487
+ }
2488
+ // --- Internal: flushing ---
2489
+ async flushEvents() {
2490
+ if (this.eventBuffer.length === 0) return;
2491
+ const events = [...this.eventBuffer];
2492
+ this.eventBuffer = [];
2493
+ try {
2494
+ const location = this.getStorageLocation("events");
2495
+ if (location.structured) {
2496
+ for (const event of events) {
2497
+ await location.structured.insert({
2498
+ collection: "detection_events",
2499
+ id: event.id,
2500
+ data: event
2501
+ });
2502
+ }
2503
+ this.logger.debug(`Flushed ${events.length} events`);
2504
+ }
2505
+ } catch (err) {
2506
+ this.logger.warn(`Failed to flush events: ${err}`);
2507
+ if (this.eventBuffer.length < this.MAX_EVENT_BUFFER * 2) {
2508
+ this.eventBuffer.push(...events);
2509
+ }
2510
+ }
2511
+ }
2512
+ async flushMedia() {
2513
+ if (this.mediaBuffer.length === 0) return;
2514
+ const sorted = [...this.mediaBuffer].sort((a, b) => {
2515
+ if (a.priority === "high" && b.priority !== "high") return -1;
2516
+ if (a.priority !== "high" && b.priority === "high") return 1;
2517
+ return 0;
2518
+ });
2519
+ this.mediaBuffer = [];
2520
+ try {
2521
+ const location = this.getStorageLocation("events");
2522
+ if (location.files) {
2523
+ for (const entry of sorted) {
2524
+ await location.files.writeFile(entry.path, entry.data);
2525
+ }
2526
+ const totalMB = sorted.reduce((s, e) => s + e.data.length, 0) / (1024 * 1024);
2527
+ this.logger.debug(`Flushed ${sorted.length} media files (${totalMB.toFixed(1)}MB)`);
2528
+ }
2529
+ } catch (err) {
2530
+ this.logger.warn(`Failed to flush media: ${err}`);
2531
+ }
2532
+ }
2533
+ };
2534
+
2535
+ // src/persistence/known-faces.ts
2536
+ var import_node_crypto = require("crypto");
2537
+ function cosineSimilarity(a, b) {
2538
+ if (a.length !== b.length) return 0;
2539
+ let dotProduct = 0;
2540
+ let normA = 0;
2541
+ let normB = 0;
2542
+ for (let i = 0; i < a.length; i++) {
2543
+ dotProduct += a[i] * b[i];
2544
+ normA += a[i] * a[i];
2545
+ normB += b[i] * b[i];
2546
+ }
2547
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
2548
+ return denom === 0 ? 0 : dotProduct / denom;
2549
+ }
2550
+ var COLLECTION = "known_faces";
2551
+ var KnownFacesService = class {
2552
+ logger;
2553
+ getStructuredStorage;
2554
+ constructor(deps) {
2555
+ this.logger = deps.logger;
2556
+ this.getStructuredStorage = deps.getStructuredStorage;
2557
+ }
2558
+ getStorage() {
2559
+ return this.getStructuredStorage();
2560
+ }
2561
+ /** Register a known face */
2562
+ async register(entry) {
2563
+ const storage = this.getStorage();
2564
+ await storage.insert({
2565
+ collection: COLLECTION,
2566
+ id: entry.id,
2567
+ data: {
2568
+ label: entry.label,
2569
+ group: entry.group ?? null,
2570
+ embedding: JSON.stringify(Array.from(entry.embedding)),
2571
+ cropBase64: entry.cropBase64,
2572
+ createdAt: entry.createdAt,
2573
+ updatedAt: entry.updatedAt,
2574
+ source: entry.source ?? null,
2575
+ metadata: entry.metadata ? JSON.stringify(entry.metadata) : null
2576
+ }
2577
+ });
2578
+ this.logger.info(`Registered known face: ${entry.label} (${entry.id})`);
2579
+ }
2580
+ /** Get all known faces */
2581
+ async listAll() {
2582
+ const storage = this.getStorage();
2583
+ const records = await storage.query(COLLECTION);
2584
+ return records.map((r) => recordToEntry(r.id, r.data));
2585
+ }
2586
+ /** Delete a known face */
2587
+ async delete(id) {
2588
+ const storage = this.getStorage();
2589
+ await storage.delete(COLLECTION, id);
2590
+ this.logger.info(`Deleted known face: ${id}`);
2591
+ }
2592
+ /** Update a known face (reassign label or group) */
2593
+ async update(id, updates) {
2594
+ const storage = this.getStorage();
2595
+ const data = { updatedAt: Date.now() };
2596
+ if (updates.label !== void 0) data.label = updates.label;
2597
+ if (updates.group !== void 0) data.group = updates.group;
2598
+ await storage.update(COLLECTION, id, data);
2599
+ this.logger.info(`Updated known face ${id}: ${JSON.stringify(updates)}`);
2600
+ }
2601
+ /** Recalculate embedding for a face (re-run CLIP on the stored crop) */
2602
+ async recalculateEmbedding(id, clipRecognizer) {
2603
+ const storage = this.getStorage();
2604
+ const records = await storage.query(COLLECTION, { where: { id } });
2605
+ if (records.length === 0) throw new Error(`Known face not found: ${id}`);
2606
+ const record = records[0];
2607
+ const cropBase64 = record.data.cropBase64;
2608
+ const cropBuffer = Buffer.from(cropBase64, "base64");
2609
+ const embedding = await clipRecognizer.getEmbedding(cropBuffer);
2610
+ await storage.update(COLLECTION, id, {
2611
+ embedding: JSON.stringify(Array.from(embedding)),
2612
+ updatedAt: Date.now()
2613
+ });
2614
+ this.logger.info(`Recalculated embedding for known face: ${id}`);
2615
+ }
2616
+ /** Find best match for an embedding */
2617
+ async findMatch(embedding, threshold) {
2618
+ const entries = await this.listAll();
2619
+ let bestEntry = null;
2620
+ let bestSimilarity = -1;
2621
+ for (const entry of entries) {
2622
+ const knownEmbedding = new Float32Array(entry.embedding);
2623
+ const similarity = cosineSimilarity(embedding, knownEmbedding);
2624
+ if (similarity > bestSimilarity) {
2625
+ bestSimilarity = similarity;
2626
+ bestEntry = entry;
2627
+ }
2628
+ }
2629
+ if (bestEntry && bestSimilarity >= threshold) {
2630
+ return { entry: bestEntry, similarity: bestSimilarity };
2631
+ }
2632
+ return null;
2633
+ }
2634
+ /** Batch import faces from crops */
2635
+ async batchRegister(entries, clipRecognizer) {
2636
+ const results = [];
2637
+ for (const input of entries) {
2638
+ const cropBuffer = Buffer.from(input.cropBase64, "base64");
2639
+ const embedding = await clipRecognizer.getEmbedding(cropBuffer);
2640
+ const now = Date.now();
2641
+ const entry = {
2642
+ id: (0, import_node_crypto.randomUUID)(),
2643
+ label: input.label,
2644
+ group: input.group,
2645
+ embedding: Array.from(embedding),
2646
+ cropBase64: input.cropBase64,
2647
+ createdAt: now,
2648
+ updatedAt: now
2649
+ };
2650
+ await this.register(entry);
2651
+ results.push(entry);
2652
+ }
2653
+ this.logger.info(`Batch registered ${results.length} known faces`);
2654
+ return results;
2655
+ }
2656
+ };
2657
+ function recordToEntry(id, data) {
2658
+ return {
2659
+ id,
2660
+ label: data.label,
2661
+ group: data.group ?? void 0,
2662
+ embedding: JSON.parse(data.embedding),
2663
+ cropBase64: data.cropBase64,
2664
+ createdAt: data.createdAt,
2665
+ updatedAt: data.updatedAt,
2666
+ source: data.source ?? void 0,
2667
+ metadata: data.metadata ? JSON.parse(data.metadata) : void 0
2668
+ };
2669
+ }
2670
+
2671
+ // src/persistence/session-tracker.ts
2672
+ var import_node_crypto2 = require("crypto");
2673
+ var MAX_POSITIONS = 300;
2674
+ var TRACK_EXPIRY_MS = 3e4;
2675
+ var SessionTrackerService = class {
2676
+ /** Active tracks per device: deviceId -> Map<trackId, SessionTrack> */
2677
+ activeTracks = /* @__PURE__ */ new Map();
2678
+ /** Global re-id pool: globalId -> GlobalIdentity (same person across cameras) */
2679
+ globalPool = /* @__PURE__ */ new Map();
2680
+ logger;
2681
+ eventBus;
2682
+ constructor(deps) {
2683
+ this.logger = deps.logger;
2684
+ this.eventBus = deps.eventBus;
2685
+ }
2686
+ /** Update tracks from an analysis result */
2687
+ updateFromAnalysis(deviceId, ctx) {
2688
+ const deviceTracks = this.getDeviceTracks(deviceId);
2689
+ const now = ctx.timestamp;
2690
+ for (const td of ctx.trackedDetections) {
2691
+ const existing = deviceTracks.get(td.trackId);
2692
+ if (existing) {
2693
+ const newPositions = [
2694
+ ...existing.positions,
2695
+ { x: td.detection.boundingBox?.[0] ?? 0, y: td.detection.boundingBox?.[1] ?? 0, t: now }
2696
+ ];
2697
+ const trimmedPositions = newPositions.length > MAX_POSITIONS ? newPositions.slice(newPositions.length - MAX_POSITIONS) : newPositions;
2698
+ const updatedEmbedding = td.recognitions.length > 0 && td.recognitions[0]?.embedding ? new Float32Array(td.recognitions[0].embedding) : existing.embedding;
2699
+ const updated = {
2700
+ ...existing,
2701
+ lastSeen: now,
2702
+ totalFrames: existing.totalFrames + 1,
2703
+ lastDetection: td,
2704
+ state: td.tracking.state,
2705
+ positions: trimmedPositions,
2706
+ embedding: updatedEmbedding
2707
+ };
2708
+ deviceTracks.set(td.trackId, updated);
2709
+ } else {
2710
+ const track = {
2711
+ trackId: td.trackId,
2712
+ deviceId,
2713
+ className: td.detection.className,
2714
+ label: td.detection.label,
2715
+ firstSeen: now,
2716
+ lastSeen: now,
2717
+ totalFrames: 1,
2718
+ lastDetection: td,
2719
+ state: td.tracking.state,
2720
+ positions: [{ x: td.detection.boundingBox?.[0] ?? 0, y: td.detection.boundingBox?.[1] ?? 0, t: now }],
2721
+ embedding: td.recognitions[0]?.embedding ? new Float32Array(td.recognitions[0].embedding) : void 0,
2722
+ globalId: void 0,
2723
+ bestCrop: td.crop
2724
+ };
2725
+ deviceTracks.set(td.trackId, track);
2726
+ this.eventBus.emit({
2727
+ id: (0, import_node_crypto2.randomUUID)(),
2728
+ timestamp: /* @__PURE__ */ new Date(),
2729
+ source: { type: "core", id: "session-tracker" },
2730
+ category: "session.track.new",
2731
+ data: { deviceId, trackId: td.trackId, className: td.detection.className }
2732
+ });
2733
+ }
2734
+ }
2735
+ for (const [trackId, track] of deviceTracks) {
2736
+ if (now - track.lastSeen > TRACK_EXPIRY_MS) {
2737
+ deviceTracks.delete(trackId);
2738
+ this.eventBus.emit({
2739
+ id: (0, import_node_crypto2.randomUUID)(),
2740
+ timestamp: /* @__PURE__ */ new Date(),
2741
+ source: { type: "core", id: "session-tracker" },
2742
+ category: "session.track.expired",
2743
+ data: { deviceId, trackId, className: track.className, duration: now - track.firstSeen }
2744
+ });
2745
+ }
2746
+ }
2747
+ }
2748
+ /** Get all active tracks for a device */
2749
+ getActiveTracks(deviceId) {
2750
+ return [...this.activeTracks.get(deviceId)?.values() ?? []];
2751
+ }
2752
+ /** Get all active tracks across all devices */
2753
+ getAllActiveTracks() {
2754
+ const all = [];
2755
+ for (const tracks of this.activeTracks.values()) {
2756
+ all.push(...tracks.values());
2757
+ }
2758
+ return all;
2759
+ }
2760
+ /** Get a specific track */
2761
+ getTrack(deviceId, trackId) {
2762
+ return this.activeTracks.get(deviceId)?.get(trackId) ?? null;
2763
+ }
2764
+ /** Get track count per device */
2765
+ getTrackCounts() {
2766
+ const counts = {};
2767
+ for (const [deviceId, tracks] of this.activeTracks) {
2768
+ const byClass = {};
2769
+ for (const track of tracks.values()) {
2770
+ byClass[track.className] = (byClass[track.className] ?? 0) + 1;
2771
+ }
2772
+ counts[deviceId] = { total: tracks.size, byClass };
2773
+ }
2774
+ return counts;
2775
+ }
2776
+ /** Clear all tracks for a device */
2777
+ clearDevice(deviceId) {
2778
+ this.activeTracks.delete(deviceId);
2779
+ }
2780
+ /** Clear all tracks */
2781
+ clearAll() {
2782
+ this.activeTracks.clear();
2783
+ this.globalPool.clear();
2784
+ }
2785
+ /** Get the global identity pool (for diagnostics) */
2786
+ getGlobalPool() {
2787
+ return this.globalPool;
2788
+ }
2789
+ getDeviceTracks(deviceId) {
2790
+ if (!this.activeTracks.has(deviceId)) {
2791
+ this.activeTracks.set(deviceId, /* @__PURE__ */ new Map());
2792
+ }
2793
+ return this.activeTracks.get(deviceId);
2794
+ }
2795
+ };
2796
+
2797
+ // src/persistence/retention.ts
2798
+ var import_node_crypto3 = require("crypto");
2799
+ var DEFAULT_RETENTION = {
2800
+ cleanupIntervalMs: 60 * 60 * 1e3,
2801
+ // 1 hour
2802
+ detectionEventsDays: 30,
2803
+ audioLevelsDays: 7,
2804
+ snapshotsDays: 14
2805
+ };
2806
+ var RetentionService = class {
2807
+ constructor(deps) {
2808
+ this.deps = deps;
2809
+ this.logger = deps.logger;
2810
+ this.getStorageLocation = deps.getStorageLocation;
2811
+ this.eventBus = deps.eventBus;
2812
+ const retention = deps.getRetentionConfig();
2813
+ this.config = retention ? { ...DEFAULT_RETENTION, ...retention } : { ...DEFAULT_RETENTION };
2814
+ }
2815
+ cleanupTimer;
2816
+ initialTimer;
2817
+ config;
2818
+ logger;
2819
+ getStorageLocation;
2820
+ eventBus;
2821
+ start() {
2822
+ this.initialTimer = setTimeout(() => {
2823
+ this.runCleanup().catch((err) => {
2824
+ this.logger.warn(`Initial cleanup failed: ${err}`);
2825
+ });
2826
+ }, 5 * 60 * 1e3);
2827
+ this.cleanupTimer = setInterval(() => {
2828
+ this.runCleanup().catch((err) => {
2829
+ this.logger.warn(`Periodic cleanup failed: ${err}`);
2830
+ });
2831
+ }, this.config.cleanupIntervalMs);
2832
+ }
2833
+ stop() {
2834
+ if (this.initialTimer) {
2835
+ clearTimeout(this.initialTimer);
2836
+ this.initialTimer = void 0;
2837
+ }
2838
+ if (this.cleanupTimer) {
2839
+ clearInterval(this.cleanupTimer);
2840
+ this.cleanupTimer = void 0;
2841
+ }
2842
+ }
2843
+ /** Run all retention cleanup tasks */
2844
+ async runCleanup() {
2845
+ const report = {
2846
+ deletedEvents: 0,
2847
+ deletedAudioRecords: 0,
2848
+ deletedSnapshots: 0
2849
+ };
2850
+ try {
2851
+ const eventCutoff = Date.now() - this.config.detectionEventsDays * 24 * 60 * 60 * 1e3;
2852
+ const { dbDeleted: eventsDeleted, filesDeleted: snapshotsDeleted } = await this.cleanEventsWithMedia("detection_events", eventCutoff);
2853
+ const audioCutoff = Date.now() - this.config.audioLevelsDays * 24 * 60 * 60 * 1e3;
2854
+ const audioDeleted = await this.cleanCollection("audio_levels", audioCutoff);
2855
+ const finalReport = {
2856
+ deletedEvents: eventsDeleted,
2857
+ deletedAudioRecords: audioDeleted,
2858
+ deletedSnapshots: snapshotsDeleted
2859
+ };
2860
+ if (finalReport.deletedEvents > 0 || finalReport.deletedAudioRecords > 0 || finalReport.deletedSnapshots > 0) {
2861
+ this.logger.info(
2862
+ `Cleanup: ${finalReport.deletedEvents} events, ${finalReport.deletedAudioRecords} audio records, ${finalReport.deletedSnapshots} snapshots`
2863
+ );
2864
+ this.eventBus.emit({
2865
+ id: (0, import_node_crypto3.randomUUID)(),
2866
+ timestamp: /* @__PURE__ */ new Date(),
2867
+ source: { type: "core", id: "retention" },
2868
+ category: "retention.cleanup",
2869
+ data: finalReport
2870
+ });
2871
+ }
2872
+ return finalReport;
2873
+ } catch (err) {
2874
+ this.logger.warn(`Retention cleanup failed: ${err}`);
2875
+ }
2876
+ return report;
2877
+ }
2878
+ /** Set retention config at runtime */
2879
+ setConfig(update) {
2880
+ this.config = { ...this.config, ...update };
2881
+ }
2882
+ /** Get current retention config */
2883
+ getConfig() {
2884
+ return { ...this.config };
2885
+ }
2886
+ /** Force immediate cleanup */
2887
+ async forceCleanup() {
2888
+ return this.runCleanup();
2889
+ }
2890
+ /**
2891
+ * Delete old events AND their associated media files.
2892
+ * Media is stored at events/{eventId}/ — delete the entire directory.
2893
+ */
2894
+ async cleanEventsWithMedia(collection, cutoffTimestamp) {
2895
+ let dbDeleted = 0;
2896
+ let filesDeleted = 0;
2897
+ try {
2898
+ const location = this.getStorageLocation("events");
2899
+ if (!location.structured) return { dbDeleted, filesDeleted };
2900
+ const old = await location.structured.query(collection, {
2901
+ whereBetween: { timestamp: [0, cutoffTimestamp] },
2902
+ limit: 1e3
2903
+ });
2904
+ for (const record of old) {
2905
+ const eventId = record.id;
2906
+ if (location.files) {
2907
+ try {
2908
+ const files = await location.files.listFiles(`events/${eventId}/`);
2909
+ for (const file of files) {
2910
+ await location.files.deleteFile(file);
2911
+ filesDeleted++;
2912
+ }
2913
+ } catch {
2914
+ }
2915
+ }
2916
+ await location.structured.delete(collection, eventId);
2917
+ dbDeleted++;
2918
+ }
2919
+ } catch {
2920
+ }
2921
+ return { dbDeleted, filesDeleted };
2922
+ }
2923
+ async cleanCollection(collection, cutoffTimestamp) {
2924
+ try {
2925
+ const location = this.getStorageLocation("events");
2926
+ if (!location.structured) return 0;
2927
+ const old = await location.structured.query(collection, {
2928
+ whereBetween: { timestamp: [0, cutoffTimestamp] },
2929
+ limit: 1e3
2930
+ // batch delete
2931
+ });
2932
+ let deleted = 0;
2933
+ for (const record of old) {
2934
+ await location.structured.delete(collection, record.id);
2935
+ deleted++;
2936
+ }
2937
+ return deleted;
2938
+ } catch {
2939
+ return 0;
2940
+ }
2941
+ }
2942
+ };
2943
+
2944
+ // src/persistence/track-trail.ts
2945
+ var DEFAULT_CONFIG = {
2946
+ enabled: false,
2947
+ snapshotIntervalMs: 2e3,
2948
+ maxTrailLength: 500,
2949
+ saveThumbnails: true,
2950
+ thumbnailSize: { width: 120, height: 120 }
2951
+ };
2952
+ var TrackTrailService = class {
2953
+ logger;
2954
+ getStorageLocation;
2955
+ /** Per-device config */
2956
+ deviceConfigs = /* @__PURE__ */ new Map();
2957
+ /** Active trails: trackId -> mutable trail state */
2958
+ activeTrails = /* @__PURE__ */ new Map();
2959
+ /** Last snapshot time per track (for interval throttling) */
2960
+ lastSnapshotTime = /* @__PURE__ */ new Map();
2961
+ constructor(deps) {
2962
+ this.logger = deps.logger;
2963
+ this.getStorageLocation = deps.getStorageLocation;
2964
+ }
2965
+ // --- Config ---
2966
+ setConfig(deviceId, config) {
2967
+ this.deviceConfigs.set(deviceId, { ...DEFAULT_CONFIG, ...config });
2968
+ }
2969
+ getConfig(deviceId) {
2970
+ return this.deviceConfigs.get(deviceId) ?? { ...DEFAULT_CONFIG };
2971
+ }
2972
+ // --- Called after each analysis pass ---
2973
+ /**
2974
+ * Record trail data from an analysis context.
2975
+ * Called by the analysis pipeline addon after each frame's analysis.
2976
+ */
2977
+ async recordFrame(ctx) {
2978
+ const config = this.getConfig(ctx.deviceId);
2979
+ if (!config.enabled) return;
2980
+ const activeTrackIds = /* @__PURE__ */ new Set();
2981
+ for (const td of ctx.trackedDetections) {
2982
+ activeTrackIds.add(td.trackId);
2983
+ await this.updateTrail(ctx, td, config);
2984
+ }
2985
+ for (const [trackId, trail] of this.activeTrails) {
2986
+ if (trail.deviceId === ctx.deviceId && !activeTrackIds.has(trackId)) {
2987
+ trail.active = false;
2988
+ await this.persistTrail(trail);
2989
+ this.activeTrails.delete(trackId);
2990
+ this.lastSnapshotTime.delete(trackId);
2991
+ }
2992
+ }
2993
+ }
2994
+ // --- Query ---
2995
+ /** Get the live trail for an active track (from memory) */
2996
+ getActiveTrail(trackId) {
2997
+ const trail = this.activeTrails.get(trackId);
2998
+ return trail ? this.toImmutable(trail) : null;
2999
+ }
3000
+ /** Get all active trails for a device */
3001
+ getActiveTrails(deviceId) {
3002
+ const results = [];
3003
+ for (const trail of this.activeTrails.values()) {
3004
+ if (trail.deviceId === deviceId) {
3005
+ results.push(this.toImmutable(trail));
3006
+ }
3007
+ }
3008
+ return results;
3009
+ }
3010
+ /** Get a persisted trail from storage (after track ended) */
3011
+ async getPersistedTrail(trackId) {
3012
+ try {
3013
+ const location = this.getStorageLocation("events");
3014
+ if (!location.structured) return null;
3015
+ const results = await location.structured.query("track_trails", {
3016
+ where: { trackId },
3017
+ limit: 1
3018
+ });
3019
+ if (results.length === 0) return null;
3020
+ return results[0].data;
3021
+ } catch {
3022
+ return null;
3023
+ }
3024
+ }
3025
+ /** Get trail from memory (active) or storage (persisted) */
3026
+ async getTrail(trackId) {
3027
+ return this.getActiveTrail(trackId) ?? this.getPersistedTrail(trackId);
3028
+ }
3029
+ /** List recent persisted trails for a device */
3030
+ async listTrails(deviceId, options) {
3031
+ const results = [];
3032
+ for (const trail of this.activeTrails.values()) {
3033
+ if (trail.deviceId === deviceId) {
3034
+ if (options?.className && trail.className !== options.className) continue;
3035
+ results.push(this.toImmutable(trail));
3036
+ }
3037
+ }
3038
+ try {
3039
+ const location = this.getStorageLocation("events");
3040
+ if (location.structured) {
3041
+ const stored = await location.structured.query("track_trails", {
3042
+ where: { deviceId, ...options?.className ? { className: options.className } : {} },
3043
+ whereBetween: options?.since || options?.until ? { firstSeen: [options.since ?? 0, options.until ?? Date.now()] } : void 0,
3044
+ orderBy: { field: "firstSeen", direction: "desc" },
3045
+ limit: options?.limit ?? 50
3046
+ });
3047
+ for (const record of stored) {
3048
+ const trail = record.data;
3049
+ if (!this.activeTrails.has(trail.trackId)) {
3050
+ results.push(trail);
3051
+ }
3052
+ }
3053
+ }
3054
+ } catch {
3055
+ }
3056
+ return results;
3057
+ }
3058
+ // --- Internal ---
3059
+ async updateTrail(ctx, td, config) {
3060
+ let trail = this.activeTrails.get(td.trackId);
3061
+ const bbox = td.detection.boundingBox;
3062
+ if (!bbox || bbox.length < 4) return;
3063
+ const center = { x: bbox[0] + bbox[2] / 2, y: bbox[1] + bbox[3] / 2 };
3064
+ const position = {
3065
+ x: center.x,
3066
+ y: center.y,
3067
+ timestamp: ctx.timestamp,
3068
+ bbox: [bbox[0], bbox[1], bbox[2], bbox[3]]
3069
+ };
3070
+ if (!trail) {
3071
+ trail = {
3072
+ trackId: td.trackId,
3073
+ deviceId: ctx.deviceId,
3074
+ className: td.detection.className,
3075
+ label: td.detection.label,
3076
+ firstSeen: ctx.timestamp,
3077
+ lastSeen: ctx.timestamp,
3078
+ positions: [position],
3079
+ snapshots: [],
3080
+ totalDistance: 0,
3081
+ zonesVisited: [...td.zones],
3082
+ active: true
3083
+ };
3084
+ this.activeTrails.set(td.trackId, trail);
3085
+ } else {
3086
+ const lastPos = trail.positions[trail.positions.length - 1];
3087
+ const dist = lastPos ? Math.sqrt((center.x - lastPos.x) ** 2 + (center.y - lastPos.y) ** 2) : 0;
3088
+ trail.lastSeen = ctx.timestamp;
3089
+ trail.totalDistance += dist;
3090
+ if (trail.positions.length >= config.maxTrailLength) {
3091
+ const half = Math.floor(trail.positions.length / 2);
3092
+ const downsampled = trail.positions.filter((_, i) => i >= half || i % 2 === 0);
3093
+ trail.positions = [...downsampled, position];
3094
+ } else {
3095
+ trail.positions = [...trail.positions, position];
3096
+ }
3097
+ for (const zone of td.zones) {
3098
+ if (!trail.zonesVisited.includes(zone)) {
3099
+ trail.zonesVisited = [...trail.zonesVisited, zone];
3100
+ }
3101
+ }
3102
+ }
3103
+ const lastSnapshot = this.lastSnapshotTime.get(td.trackId) ?? 0;
3104
+ if (ctx.timestamp - lastSnapshot >= config.snapshotIntervalMs && td.crop) {
3105
+ await this.captureSnapshot(trail, td, position, config);
3106
+ this.lastSnapshotTime.set(td.trackId, ctx.timestamp);
3107
+ }
3108
+ }
3109
+ async captureSnapshot(trail, td, position, config) {
3110
+ if (!td.crop || !config.saveThumbnails) return;
3111
+ try {
3112
+ const sharp2 = (await import("sharp")).default;
3113
+ const thumb = await sharp2(td.crop).resize(config.thumbnailSize.width, config.thumbnailSize.height, { fit: "cover" }).jpeg({ quality: 75 }).toBuffer();
3114
+ const path2 = `debug-trails/${trail.trackId}/${position.timestamp}.jpg`;
3115
+ const location = this.getStorageLocation("events");
3116
+ if (location.files) {
3117
+ await location.files.writeFile(path2, thumb);
3118
+ }
3119
+ trail.snapshots = [...trail.snapshots, {
3120
+ timestamp: position.timestamp,
3121
+ position,
3122
+ thumbnailPath: path2
3123
+ }];
3124
+ } catch {
3125
+ }
3126
+ }
3127
+ async persistTrail(trail) {
3128
+ try {
3129
+ const location = this.getStorageLocation("events");
3130
+ if (!location.structured) return;
3131
+ await location.structured.insert({
3132
+ collection: "track_trails",
3133
+ id: trail.trackId,
3134
+ data: this.toImmutable(trail)
3135
+ });
3136
+ this.logger.debug(
3137
+ `Trail persisted: ${trail.trackId} (${trail.positions.length} positions, ${trail.snapshots.length} snapshots, ${trail.totalDistance.toFixed(3)} distance)`
3138
+ );
3139
+ } catch (err) {
3140
+ this.logger.warn(`Failed to persist trail ${trail.trackId}: ${err}`);
3141
+ }
3142
+ }
3143
+ toImmutable(trail) {
3144
+ return {
3145
+ trackId: trail.trackId,
3146
+ deviceId: trail.deviceId,
3147
+ className: trail.className,
3148
+ label: trail.label,
3149
+ firstSeen: trail.firstSeen,
3150
+ lastSeen: trail.lastSeen,
3151
+ positions: [...trail.positions],
3152
+ snapshots: [...trail.snapshots],
3153
+ totalDistance: trail.totalDistance,
3154
+ zonesVisited: [...trail.zonesVisited],
3155
+ active: trail.active
3156
+ };
3157
+ }
3158
+ };
3159
+
3160
+ // src/addon.ts
3161
+ var PipelineAddon = class {
3162
+ manifest = {
3163
+ id: "pipeline",
3164
+ name: "CamStack Pipeline",
3165
+ version: "0.1.0",
3166
+ capabilities: [
3167
+ { name: "stream-broker", mode: "singleton" },
3168
+ { name: "recording-engine", mode: "singleton" },
3169
+ { name: "analysis-pipeline", mode: "singleton" },
3170
+ { name: "analysis-data-persistence", mode: "singleton" },
3171
+ { name: "webrtc", mode: "collection" }
3172
+ ]
3173
+ };
3174
+ // Stream broker
3175
+ brokerManager = null;
3176
+ // Recording engine
3177
+ coordinator = null;
3178
+ recordingDb = null;
3179
+ sqliteDb = null;
3180
+ recordingDeps = null;
3181
+ currentRecordingConfig = {
3182
+ ffmpegPath: "ffmpeg",
3183
+ hwaccel: void 0,
3184
+ threads: void 0
3185
+ };
3186
+ // Analysis pipeline
3187
+ analysisPipeline = null;
3188
+ analysisLogger = null;
3189
+ // Analysis persistence
3190
+ persistenceFacade = null;
3191
+ setRecordingDependencies(deps) {
3192
+ this.recordingDeps = deps;
3193
+ }
3194
+ async initialize(context) {
3195
+ const registry = new DecoderRegistry();
3196
+ registry.register(new FfmpegDecoderProvider(), 50);
3197
+ this.brokerManager = new StreamBrokerManager(registry, context.logger);
3198
+ context.logger.info("Stream broker manager initialized");
3199
+ if (this.recordingDeps) {
3200
+ try {
3201
+ const Database = (await import("better-sqlite3")).default;
3202
+ const path2 = await import("path");
3203
+ const { detectPlatformDefaults: detectPlatformDefaults2 } = await Promise.resolve().then(() => (init_ffmpeg_config(), ffmpeg_config_exports));
3204
+ const { RecordingDb: RecDb } = await Promise.resolve().then(() => (init_recording_db(), recording_db_exports));
3205
+ const { RecordingCoordinator: RecCoord } = await Promise.resolve().then(() => (init_recording_coordinator(), recording_coordinator_exports));
3206
+ const storagePath = context.locationPaths.recordings;
3207
+ const dbPath = path2.join(context.locationPaths.data, "camstack.db");
3208
+ this.sqliteDb = new Database(dbPath);
3209
+ this.recordingDb = new RecDb(this.sqliteDb);
3210
+ this.recordingDb.initialize();
3211
+ const ffmpegPath = context.addonConfig.ffmpegPath ?? this.currentRecordingConfig.ffmpegPath;
3212
+ const detectedFfmpegConfig = detectPlatformDefaults2(ffmpegPath);
3213
+ const globalFfmpegConfig = {
3214
+ path: ffmpegPath,
3215
+ hwaccel: context.addonConfig.hwaccel ?? this.currentRecordingConfig.hwaccel,
3216
+ threads: context.addonConfig.threads ?? this.currentRecordingConfig.threads
3217
+ };
3218
+ this.currentRecordingConfig = {
3219
+ ffmpegPath,
3220
+ hwaccel: globalFfmpegConfig.hwaccel,
3221
+ threads: globalFfmpegConfig.threads
3222
+ };
3223
+ const fileStorage = context.storage.files;
3224
+ if (!fileStorage) {
3225
+ throw new Error("PipelineAddon:file storage not available in addon context");
3226
+ }
3227
+ this.coordinator = new RecCoord({
3228
+ db: this.recordingDb,
3229
+ logger: context.logger,
3230
+ eventBus: context.eventBus,
3231
+ streamingEngine: this.recordingDeps.streamingEngine,
3232
+ pipelineManager: this.recordingDeps.pipelineManager,
3233
+ networkTracker: this.recordingDeps.networkTracker,
3234
+ fileStorage,
3235
+ storagePath,
3236
+ globalFfmpegConfig,
3237
+ detectedFfmpegConfig
3238
+ });
3239
+ await this.coordinator.start();
3240
+ context.logger.info("Recording Engine initialized");
3241
+ } catch (error) {
3242
+ const msg = error instanceof Error ? error.message : String(error);
3243
+ context.logger.warn(`Recording Engine failed to initialize: ${msg}`);
3244
+ }
3245
+ }
3246
+ this.analysisLogger = context.logger;
3247
+ try {
3248
+ const mod = await import("@camstack/lib-pipeline-analysis");
3249
+ const instance = new mod.AnalysisPipeline();
3250
+ this.analysisPipeline = instance;
3251
+ this.analysisLogger.info("Analysis pipeline loaded successfully");
3252
+ } catch (error) {
3253
+ const msg = error instanceof Error ? error.message : String(error);
3254
+ this.analysisLogger.warn(`Analysis pipeline not available: ${msg} -- analysis features disabled`);
3255
+ }
3256
+ const eventPersistence = new EventPersistenceService({
3257
+ getStorageLocation: () => context.storage,
3258
+ subscribe: (filter, handler) => context.eventBus.subscribe(filter, handler),
3259
+ logger: context.logger.child("EventPersistence")
3260
+ });
3261
+ const knownFaces = new KnownFacesService({
3262
+ getStructuredStorage: () => {
3263
+ if (!context.storage.structured) {
3264
+ throw new Error("Structured storage not available for analysis persistence");
3265
+ }
3266
+ return context.storage.structured;
3267
+ },
3268
+ logger: context.logger.child("KnownFaces")
3269
+ });
3270
+ const sessionTracker = new SessionTrackerService({
3271
+ eventBus: context.eventBus,
3272
+ logger: context.logger.child("SessionTracker")
3273
+ });
3274
+ const retention = new RetentionService({
3275
+ getStorageLocation: () => context.storage,
3276
+ getRetentionConfig: () => context.addonConfig.retention,
3277
+ eventBus: context.eventBus,
3278
+ logger: context.logger.child("Retention")
3279
+ });
3280
+ const trackTrail = new TrackTrailService({
3281
+ getStorageLocation: () => context.storage,
3282
+ logger: context.logger.child("TrackTrail")
3283
+ });
3284
+ eventPersistence.start();
3285
+ retention.start();
3286
+ this.persistenceFacade = {
3287
+ eventPersistence,
3288
+ knownFaces,
3289
+ sessionTracker,
3290
+ retention,
3291
+ trackTrail
3292
+ };
3293
+ context.logger.info("Analysis persistence initialized");
3294
+ }
3295
+ async shutdown() {
3296
+ await this.brokerManager?.destroyAll();
3297
+ this.brokerManager = null;
3298
+ if (this.coordinator) {
3299
+ this.coordinator.stop();
3300
+ this.coordinator = null;
3301
+ }
3302
+ if (this.sqliteDb) {
3303
+ this.sqliteDb.close();
3304
+ this.sqliteDb = null;
3305
+ }
3306
+ this.recordingDb = null;
3307
+ this.analysisPipeline = null;
3308
+ if (this.persistenceFacade) {
3309
+ this.persistenceFacade.eventPersistence.stop();
3310
+ this.persistenceFacade.retention.stop();
3311
+ this.persistenceFacade = null;
3312
+ }
3313
+ }
3314
+ getCapabilityProvider(name) {
3315
+ switch (name) {
3316
+ case "stream-broker":
3317
+ return this.brokerManager;
3318
+ case "recording-engine":
3319
+ return this.coordinator;
3320
+ case "analysis-pipeline":
3321
+ return this.analysisPipeline;
3322
+ case "analysis-data-persistence":
3323
+ return this.persistenceFacade;
3324
+ case "webrtc":
3325
+ return null;
3326
+ // WebRTC is provided externally or via collection
3327
+ default:
3328
+ return null;
3329
+ }
3330
+ }
3331
+ // --- Recording engine accessors ---
3332
+ getCoordinator() {
3333
+ if (!this.coordinator) throw new Error("PipelineAddon recording not initialized");
3334
+ return this.coordinator;
3335
+ }
3336
+ getRecordingDb() {
3337
+ if (!this.recordingDb) throw new Error("PipelineAddon recording not initialized");
3338
+ return this.recordingDb;
3339
+ }
3340
+ /** Whether the analysis pipeline package loaded successfully */
3341
+ isAnalysisAvailable() {
3342
+ return this.analysisPipeline !== null;
3343
+ }
3344
+ // --- IConfigurable ---
3345
+ getConfigSchema() {
3346
+ return {
3347
+ sections: [
3348
+ {
3349
+ id: "ffmpeg",
3350
+ title: "FFmpeg Settings",
3351
+ columns: 2,
3352
+ fields: [
3353
+ {
3354
+ type: "text",
3355
+ key: "ffmpegPath",
3356
+ label: "FFmpeg Binary Path",
3357
+ description: 'Path to the ffmpeg executable, or just "ffmpeg" if it is in your PATH',
3358
+ placeholder: "ffmpeg"
3359
+ },
3360
+ {
3361
+ type: "select",
3362
+ key: "hwaccel",
3363
+ label: "Hardware Acceleration",
3364
+ description: "Enable GPU-accelerated video encoding if supported by your hardware",
3365
+ options: [
3366
+ { value: "", label: "None (software)", description: "CPU-only encoding" },
3367
+ { value: "nvenc", label: "NVIDIA NVENC", description: "NVIDIA GPU encoding" },
3368
+ { value: "vaapi", label: "Intel VAAPI", description: "Intel GPU encoding (Linux)" },
3369
+ { value: "videotoolbox", label: "Apple VideoToolbox", description: "macOS hardware encoding" }
3370
+ ]
3371
+ },
3372
+ {
3373
+ type: "number",
3374
+ key: "threads",
3375
+ label: "FFmpeg Threads",
3376
+ description: "Number of CPU threads for software encoding (0 = auto)",
3377
+ min: 0,
3378
+ max: 64,
3379
+ step: 1,
3380
+ unit: "threads"
3381
+ }
3382
+ ]
3383
+ }
3384
+ ]
3385
+ };
3386
+ }
3387
+ getConfig() {
3388
+ return { ...this.currentRecordingConfig };
3389
+ }
3390
+ async onConfigChange(config) {
3391
+ this.currentRecordingConfig = {
3392
+ ffmpegPath: config.ffmpegPath ?? this.currentRecordingConfig.ffmpegPath,
3393
+ hwaccel: config.hwaccel ?? this.currentRecordingConfig.hwaccel,
3394
+ threads: config.threads ?? this.currentRecordingConfig.threads
3395
+ };
3396
+ }
3397
+ };
3398
+ var addon_default = PipelineAddon;
3399
+ // Annotate the CommonJS export names for ESM import in node:
3400
+ 0 && (module.exports = {
3401
+ PipelineAddon
3402
+ });
3403
+ //# sourceMappingURL=addon.cjs.map