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