@camstack/addon-pipeline 1.1.60 → 1.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +3 -3
  4. package/dist/detection-pipeline/index.mjs +3 -3
  5. package/dist/{dist-CcqqznPf.mjs → dist-D16_dBsr.mjs} +214 -6
  6. package/dist/{dist-DynD1EC-.js → dist-p_JqOxtk.js} +225 -5
  7. package/dist/{model-download-service-Cp9f4dk6-0wh7OK3s.mjs → model-download-service-Cp9f4dk6-CwUb5v3Y.mjs} +1 -1
  8. package/dist/{model-download-service-Cp9f4dk6-Cp_GTKTw.js → model-download-service-Cp9f4dk6-Dv-oFwjN.js} +12 -0
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +2 -2
  12. package/dist/pipeline-runner/index.mjs +2 -2
  13. package/dist/recorder/index.js +638 -11
  14. package/dist/recorder/index.mjs +639 -12
  15. package/dist/{step-definitions-DhhwklAL.js → step-definitions-CoxF5G31.js} +18 -2
  16. package/dist/{step-definitions-CvwB_Otk.mjs → step-definitions-DxAcpUV-.mjs} +18 -2
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B07QYb7Q.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Wu4TlSuq.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BPujpbZ0.mjs +26 -0
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSXctoaw.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DGxcX3Wf.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-C02YjaoP.mjs → hostInit-D_dSciqF.mjs} +3 -3
  22. package/dist/stream-broker/index.js +29 -9
  23. package/dist/stream-broker/index.mjs +29 -9
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-ColkZGA1.js → MaskShapeCanvas-DI4BY7W2-BldL9_Ee.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BIzNJmu0.js → MotionZonesSettings-NcxxQN8r-BgWqMe4f.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BHuYOg7Q.js → PrivacyMaskSettings-APgPLF7p-YBgaXffP.js} +1 -1
  28. package/embed-dist/assets/index-e0bQ8yep.js +81 -0
  29. package/embed-dist/index.html +1 -1
  30. package/package.json +5 -1
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DYQ6RHcb.mjs +0 -26
  32. package/embed-dist/assets/index-n1_ZSmy0.js +0 -81
@@ -1,7 +1,7 @@
1
1
  const require_chunk = require("../chunk-D6vf50IK.js");
2
- const require_dist = require("../dist-DynD1EC-.js");
2
+ const require_dist = require("../dist-p_JqOxtk.js");
3
3
  const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.js");
4
- const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Cp_GTKTw.js");
4
+ const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Dv-oFwjN.js");
5
5
  let node_child_process = require("node:child_process");
6
6
  let sharp = require("sharp");
7
7
  sharp = require_chunk.__toESM(sharp);
@@ -9,6 +9,7 @@ let node_fs = require("node:fs");
9
9
  let node_path = require("node:path");
10
10
  node_path = require_chunk.__toESM(node_path);
11
11
  let node_os = require("node:os");
12
+ let node_crypto = require("node:crypto");
12
13
  //#region src/recorder/segment-path.ts
13
14
  function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
14
15
  if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
@@ -875,6 +876,15 @@ function noPlayback(deviceId) {
875
876
  function playbackDirFor(dataDir, deviceId) {
876
877
  return node_path.default.join(dataDir, "playback", String(deviceId));
877
878
  }
879
+ /**
880
+ * Absolute path of ONE profile's variant playlist inside the playback dir.
881
+ * `buildPlaybackManifest` writes `<profile>.m3u8` next to the master whenever
882
+ * that profile has overlapping footage — the export engine renders this variant
883
+ * (a single-profile source) rather than the multi-variant master.
884
+ */
885
+ function playbackVariantPlaylistPath(dataDir, deviceId, profile) {
886
+ return node_path.default.join(playbackDirFor(dataDir, deviceId), `${profile}.m3u8`);
887
+ }
878
888
  /** Prefix window the CODECS probe reads — the fragment's moov (with its
879
889
  * avcC/hvcC) leads the file and is ~1-2KB, so 64KB is a generous bound. */
880
890
  var PROBE_PREFIX_BYTES = 65536;
@@ -1138,6 +1148,407 @@ function buildRecordingProvider(deps) {
1138
1148
  };
1139
1149
  }
1140
1150
  //#endregion
1151
+ //#region src/recorder/addon/export-store.ts
1152
+ /**
1153
+ * Recording-export persistence for the recorder addon.
1154
+ *
1155
+ * Every export job/history row lives in ONE addon-store blob keyed by export
1156
+ * id. The whole Zod-validated blob round-trips on every read/write via a
1157
+ * {@link DurableState} handle (same pattern as `config-store.ts`), so no field
1158
+ * is silently dropped on persist. History rows are never removed by the system
1159
+ * (audit) — `deleteExport`/janitor flip `state` and clear the file, keeping the
1160
+ * row. A corrupt blob reads back as EMPTY rather than crashing boot.
1161
+ */
1162
+ /**
1163
+ * The addon-store key holding the exportId → ExportRecord map. Production
1164
+ * builds the durable handle via `BaseAddon.state(RECORDING_EXPORTS_KEY,
1165
+ * RecordingExportsBlobSchema, {})`; the pure functions below take that handle,
1166
+ * so this module never touches the @internal raw addon-store primitive.
1167
+ */
1168
+ var RECORDING_EXPORTS_KEY = "recordingExports";
1169
+ /** Persisted shape: export id → full ExportRecord. */
1170
+ var RecordingExportsBlobSchema = require_dist.record(require_dist.string(), require_dist.ExportRecordSchema);
1171
+ /** Read every persisted export row, keyed by export id. */
1172
+ async function readExports(state) {
1173
+ const blob = await state.get();
1174
+ return new Map(Object.entries(blob));
1175
+ }
1176
+ /** Insert or replace one export row. Validates at the boundary. Returns it. */
1177
+ async function upsertExport(state, record) {
1178
+ const validated = require_dist.ExportRecordSchema.parse(record);
1179
+ const map = await readExports(state);
1180
+ map.set(validated.id, validated);
1181
+ await state.set(Object.fromEntries(map));
1182
+ return validated;
1183
+ }
1184
+ /**
1185
+ * Shallow-merge a patch into one export row and persist. Returns the merged
1186
+ * record, or null when the id is unknown.
1187
+ */
1188
+ async function patchExport(state, id, patch) {
1189
+ const map = await readExports(state);
1190
+ const existing = map.get(id);
1191
+ if (!existing) return null;
1192
+ const merged = require_dist.ExportRecordSchema.parse({
1193
+ ...existing,
1194
+ ...patch
1195
+ });
1196
+ map.set(id, merged);
1197
+ await state.set(Object.fromEntries(map));
1198
+ return merged;
1199
+ }
1200
+ /** Read one export row by id, or null. */
1201
+ async function getExportRecord(state, id) {
1202
+ return (await readExports(state)).get(id) ?? null;
1203
+ }
1204
+ /**
1205
+ * List export rows, newest-first, optionally scoped to one device.
1206
+ */
1207
+ async function listExportsFor(state, deviceId) {
1208
+ return [...(await readExports(state)).values()].filter((r) => deviceId === void 0 || r.deviceId === deviceId).toSorted((a, b) => b.createdAt - a.createdAt);
1209
+ }
1210
+ //#endregion
1211
+ //#region src/recorder/addon/export-ffmpeg-args.ts
1212
+ /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
1213
+ * band audio is dropped rather than chained (kept simple + predictable). */
1214
+ var ATEMPO_MIN = .5;
1215
+ var ATEMPO_MAX = 2;
1216
+ var X264 = [
1217
+ "-c:v",
1218
+ "libx264",
1219
+ "-preset",
1220
+ "veryfast",
1221
+ "-pix_fmt",
1222
+ "yuv420p"
1223
+ ];
1224
+ var FASTSTART = ["-movflags", "+faststart"];
1225
+ /** Build the ffmpeg argv for one export render. */
1226
+ function buildExportArgs(input) {
1227
+ const { inputPlaylist, outPath, options } = input;
1228
+ const inArgs = ["-i", inputPlaylist];
1229
+ if (options.timelapse) {
1230
+ const everySec = options.timelapse.everyMs / 1e3;
1231
+ const fps = options.timelapse.outputFps ?? 30;
1232
+ return [
1233
+ ...inArgs,
1234
+ "-vf",
1235
+ `select='not(mod(t,${everySec}))',setpts=N/FRAME_RATE/TB,fps=${fps}`,
1236
+ "-an",
1237
+ ...X264,
1238
+ ...FASTSTART,
1239
+ outPath
1240
+ ];
1241
+ }
1242
+ const speed = options.speed ?? 1;
1243
+ if (speed === 1) return [
1244
+ ...inArgs,
1245
+ "-map",
1246
+ "0",
1247
+ "-c",
1248
+ "copy",
1249
+ ...options.includeAudio ? [] : ["-an"],
1250
+ ...FASTSTART,
1251
+ outPath
1252
+ ];
1253
+ const keepAudio = options.includeAudio && speed >= ATEMPO_MIN && speed <= ATEMPO_MAX;
1254
+ return [
1255
+ ...inArgs,
1256
+ "-filter:v",
1257
+ `setpts=PTS/${speed}`,
1258
+ ...keepAudio ? [
1259
+ "-filter:a",
1260
+ `atempo=${speed}`,
1261
+ "-c:a",
1262
+ "aac"
1263
+ ] : ["-an"],
1264
+ ...X264,
1265
+ ...FASTSTART,
1266
+ outPath
1267
+ ];
1268
+ }
1269
+ //#endregion
1270
+ //#region src/recorder/addon/export-engine.ts
1271
+ /**
1272
+ * Render engine for recording exports.
1273
+ *
1274
+ * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
1275
+ * protects the recording node). Each job: resolve the range's local HLS master
1276
+ * playlist → build argv (`buildExportArgs`) → spawn ffmpeg → parse `time=` from
1277
+ * stderr for progress → on exit 0 stat the file and mark `ready`; on non-zero
1278
+ * mark `failed` and remove the partial. `cancel` SIGKILLs an in-flight render.
1279
+ * State transitions patch the DurableState store and emit telemetry events.
1280
+ *
1281
+ * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
1282
+ * re-queue of interrupted jobs is the provider's concern.
1283
+ */
1284
+ var STDERR_TAIL_LINES$1 = 12;
1285
+ var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
1286
+ /** Expected OUTPUT duration (seconds) for progress scaling. */
1287
+ function expectedOutSeconds(rec) {
1288
+ const rangeSec = Math.max(.001, (rec.toMs - rec.fromMs) / 1e3);
1289
+ if (rec.options.timelapse) {
1290
+ const everySec = rec.options.timelapse.everyMs / 1e3;
1291
+ const fps = rec.options.timelapse.outputFps ?? 30;
1292
+ return Math.max(.001, rangeSec / everySec / fps);
1293
+ }
1294
+ return rangeSec / (rec.options.speed ?? 1);
1295
+ }
1296
+ var ExportEngine = class {
1297
+ deps;
1298
+ queue = [];
1299
+ activeId = null;
1300
+ activeProc = null;
1301
+ cancelled = /* @__PURE__ */ new Set();
1302
+ constructor(deps) {
1303
+ this.deps = deps;
1304
+ }
1305
+ /** Queue a persisted `queued` record for rendering. */
1306
+ enqueue(id) {
1307
+ if (this.queue.includes(id) || this.activeId === id) return;
1308
+ this.queue.push(id);
1309
+ this.pump();
1310
+ }
1311
+ /** Abort a queued or in-flight render. */
1312
+ async cancel(id) {
1313
+ this.cancelled.add(id);
1314
+ const idx = this.queue.indexOf(id);
1315
+ if (idx >= 0) this.queue.splice(idx, 1);
1316
+ if (this.activeId === id && this.activeProc) try {
1317
+ this.activeProc.kill("SIGKILL");
1318
+ } catch {}
1319
+ else if (idx >= 0) await this.fail(id, "cancelled");
1320
+ }
1321
+ async pump() {
1322
+ if (this.activeId) return;
1323
+ const id = this.queue.shift();
1324
+ if (!id) return;
1325
+ if (this.cancelled.has(id)) {
1326
+ this.cancelled.delete(id);
1327
+ await this.fail(id, "cancelled");
1328
+ this.pump();
1329
+ return;
1330
+ }
1331
+ this.activeId = id;
1332
+ try {
1333
+ await this.render(id);
1334
+ } catch (err) {
1335
+ await this.fail(id, err instanceof Error ? err.message : String(err));
1336
+ } finally {
1337
+ this.activeId = null;
1338
+ this.activeProc = null;
1339
+ this.pump();
1340
+ }
1341
+ }
1342
+ async render(id) {
1343
+ const rec = await patchExport(this.deps.state, id, {
1344
+ state: "rendering",
1345
+ progressPct: 0
1346
+ });
1347
+ if (!rec) return;
1348
+ const playlist = await this.deps.resolvePlaylist(rec);
1349
+ if (!playlist) {
1350
+ await this.fail(id, "no footage for range");
1351
+ return;
1352
+ }
1353
+ const outPath = this.deps.outPath(rec);
1354
+ const args = buildExportArgs({
1355
+ inputPlaylist: playlist,
1356
+ outPath,
1357
+ options: rec.options
1358
+ });
1359
+ const totalSec = expectedOutSeconds(rec);
1360
+ await new Promise((resolve) => {
1361
+ const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1362
+ this.activeProc = proc;
1363
+ const stderrTail = [];
1364
+ proc.stderr?.on("data", (chunk) => {
1365
+ const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
1366
+ for (const line of text.split("\n")) {
1367
+ const trimmed = line.trim();
1368
+ if (trimmed) {
1369
+ stderrTail.push(trimmed);
1370
+ if (stderrTail.length > STDERR_TAIL_LINES$1) stderrTail.shift();
1371
+ }
1372
+ const m = TIME_RE.exec(line);
1373
+ if (m) {
1374
+ const sec = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
1375
+ const pct = Math.min(99, Math.round(sec / totalSec * 100));
1376
+ patchExport(this.deps.state, id, { progressPct: pct });
1377
+ this.deps.emit(require_dist.EventCategory.RecordingExportProgress, {
1378
+ exportId: id,
1379
+ progressPct: pct
1380
+ });
1381
+ }
1382
+ }
1383
+ });
1384
+ proc.on("exit", (code) => {
1385
+ this.activeProc = null;
1386
+ this.finish(id, typeof code === "number" ? code : null, outPath, stderrTail).then(resolve);
1387
+ });
1388
+ });
1389
+ }
1390
+ async finish(id, code, outPath, stderrTail) {
1391
+ if (this.cancelled.has(id)) {
1392
+ this.cancelled.delete(id);
1393
+ await this.deps.removeFile(outPath);
1394
+ await this.fail(id, "cancelled");
1395
+ return;
1396
+ }
1397
+ if (code !== 0) {
1398
+ await this.deps.removeFile(outPath);
1399
+ await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.join(" | ")}`);
1400
+ return;
1401
+ }
1402
+ let bytes = 0;
1403
+ try {
1404
+ bytes = await this.deps.statBytes(outPath);
1405
+ } catch (err) {
1406
+ await this.fail(id, err instanceof Error ? err.message : String(err));
1407
+ return;
1408
+ }
1409
+ await patchExport(this.deps.state, id, {
1410
+ state: "ready",
1411
+ progressPct: 100,
1412
+ fileBytes: bytes,
1413
+ error: null
1414
+ });
1415
+ this.deps.emit(require_dist.EventCategory.RecordingExportCompleted, {
1416
+ exportId: id,
1417
+ fileBytes: bytes
1418
+ });
1419
+ }
1420
+ async fail(id, error) {
1421
+ await patchExport(this.deps.state, id, {
1422
+ state: "failed",
1423
+ progressPct: null,
1424
+ error
1425
+ });
1426
+ this.deps.emit(require_dist.EventCategory.RecordingExportFailed, {
1427
+ exportId: id,
1428
+ error
1429
+ });
1430
+ }
1431
+ };
1432
+ //#endregion
1433
+ //#region src/recorder/addon/recording-export-provider.ts
1434
+ /**
1435
+ * True when at least one indexed segment overlaps `[fromMs, toMs)`. Mirrors the
1436
+ * `buildPlaybackManifest` overlap idiom: a segment `[startMs, startMs+durMs)`
1437
+ * overlaps the window iff it starts before the window ends AND ends after the
1438
+ * window starts.
1439
+ */
1440
+ function hasFootage(index, deviceId, profile, fromMs, toMs) {
1441
+ return index.segments(deviceId, profile).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
1442
+ }
1443
+ /** Load a record or throw a clear NOT_FOUND-style error. */
1444
+ async function requireRecord(state, exportId) {
1445
+ const rec = await getExportRecord(state, exportId);
1446
+ if (!rec) throw new Error(`recordingExport: unknown export "${exportId}"`);
1447
+ return rec;
1448
+ }
1449
+ /** Build the `recordingExport` provider over the injected deps. */
1450
+ function buildRecordingExportProvider(deps) {
1451
+ return {
1452
+ createExport: async ({ deviceId, profile, fromMs, toMs, options }) => {
1453
+ if (!(fromMs < toMs)) throw new Error(`recordingExport: invalid range ${fromMs}..${toMs}`);
1454
+ if (!hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recordingExport: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
1455
+ const now = deps.now();
1456
+ const record = {
1457
+ id: deps.newId(),
1458
+ deviceId,
1459
+ profile,
1460
+ fromMs,
1461
+ toMs,
1462
+ options,
1463
+ state: "queued",
1464
+ progressPct: null,
1465
+ fileBytes: null,
1466
+ expiresAt: now + options.maxLifeMs,
1467
+ deleteAfterDownload: options.deleteAfterDownload,
1468
+ downloadedAt: null,
1469
+ createdAt: now,
1470
+ createdBy: deps.createdBy(),
1471
+ error: null
1472
+ };
1473
+ const saved = await upsertExport(deps.state, record);
1474
+ deps.engine.enqueue(saved.id);
1475
+ return saved;
1476
+ },
1477
+ listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
1478
+ getExport: ({ exportId }) => requireRecord(deps.state, exportId),
1479
+ cancelExport: async ({ exportId }) => {
1480
+ const existing = await requireRecord(deps.state, exportId);
1481
+ await deps.engine.cancel(exportId);
1482
+ return await getExportRecord(deps.state, exportId) ?? existing;
1483
+ },
1484
+ deleteExport: async ({ exportId }) => {
1485
+ const existing = await requireRecord(deps.state, exportId);
1486
+ await deps.removeFile(deps.outPath(existing));
1487
+ return await patchExport(deps.state, exportId, {
1488
+ state: "deleted",
1489
+ fileBytes: null
1490
+ }) ?? existing;
1491
+ },
1492
+ getDownloadUrl: async ({ exportId }) => {
1493
+ await requireRecord(deps.state, exportId);
1494
+ const url = deps.downloadUrlFor(exportId);
1495
+ return {
1496
+ url,
1497
+ endpoints: [url]
1498
+ };
1499
+ }
1500
+ };
1501
+ }
1502
+ //#endregion
1503
+ //#region src/recorder/addon/export-janitor.ts
1504
+ /** States whose file still exists on disk and can be reclaimed. */
1505
+ var LIVE_STATES = new Set(["ready"]);
1506
+ /**
1507
+ * Delete the files of every export past `expiresAt` and flip them to `expired`
1508
+ * (row kept). Only `ready` exports hold a file; `queued`/`rendering` are left
1509
+ * for the engine, terminal rows are skipped. Returns the expired ids.
1510
+ */
1511
+ async function sweepExpiredExports(now, deps) {
1512
+ const rows = await listExportsFor(deps.state);
1513
+ const expired = [];
1514
+ for (const rec of rows) {
1515
+ if (!LIVE_STATES.has(rec.state)) continue;
1516
+ if (rec.expiresAt > now) continue;
1517
+ try {
1518
+ await deps.removeFile(deps.outPath(rec));
1519
+ } catch {}
1520
+ await patchExport(deps.state, rec.id, {
1521
+ state: "expired",
1522
+ fileBytes: null
1523
+ });
1524
+ expired.push(rec.id);
1525
+ }
1526
+ return { expired };
1527
+ }
1528
+ /**
1529
+ * Record a completed download. Stamps `downloadedAt`; when the export is
1530
+ * `deleteAfterDownload`, removes the file after `graceMs` (protects immediate
1531
+ * client retries) and flips to `deleted`. `scheduleDelete` is injected so the
1532
+ * caller controls timer semantics (real setTimeout in prod, immediate in test).
1533
+ */
1534
+ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
1535
+ const rec = await patchExport(deps.state, id, { downloadedAt: now });
1536
+ if (!rec || !rec.deleteAfterDownload) return;
1537
+ scheduleDelete(() => {
1538
+ (async () => {
1539
+ const current = (await listExportsFor(deps.state)).find((r) => r.id === id);
1540
+ if (!current || current.state !== "ready") return;
1541
+ try {
1542
+ await deps.removeFile(deps.outPath(current));
1543
+ } catch {}
1544
+ await patchExport(deps.state, id, {
1545
+ state: "deleted",
1546
+ fileBytes: null
1547
+ });
1548
+ })();
1549
+ }, graceMs);
1550
+ }
1551
+ //#endregion
1141
1552
  //#region src/recorder/addon/roots.ts
1142
1553
  /**
1143
1554
  * Storage-root resolution + boot hydrate for the recorder addon.
@@ -3055,6 +3466,14 @@ var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
3055
3466
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
3056
3467
  var RECORDER_ADDON_ID = "recorder";
3057
3468
  var PLAYBACK_PREFIX = "playback";
3469
+ /** Data-plane prefix for export MP4 downloads → `/addon/recorder/exports/<id>.mp4`. */
3470
+ var EXPORTS_PREFIX = "exports";
3471
+ /** How often the export janitor sweeps expired exports. */
3472
+ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
3473
+ /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
3474
+ var EXPORT_DELETE_GRACE_MS = 6e4;
3475
+ /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
3476
+ var EXPORT_FILENAME_RE = /^([A-Za-z0-9][A-Za-z0-9_-]*)\.mp4$/;
3058
3477
  /** Narrows the `unknown` extras `SegmentStore`'s logger contract accepts down
3059
3478
  * to the structured `{ tags, error }` shape the device-scoped call sites
3060
3479
  * (stat/relocate failures) pass — as opposed to the raw caught error most
@@ -3082,6 +3501,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3082
3501
  playbackBaseUrl = null;
3083
3502
  /** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
3084
3503
  thumbnailService = null;
3504
+ /** Render engine for recording exports. Null on non-recording nodes. */
3505
+ exportEngine = null;
3506
+ /** Periodic export-expiry sweep timer. Cleared on shutdown. */
3507
+ exportSweepTimer = null;
3085
3508
  constructor() {
3086
3509
  super({ ...DEFAULT_CONFIG });
3087
3510
  }
@@ -3159,7 +3582,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3159
3582
  }),
3160
3583
  brokerCall: makeBrokerCall(brokerHandle)
3161
3584
  });
3162
- const recordingProvider = buildRecordingProvider({
3585
+ const recordingDeps = {
3163
3586
  index: this.index,
3164
3587
  segmentStore: this.segmentStore,
3165
3588
  nodeId: this.nodeId,
@@ -3180,11 +3603,42 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3180
3603
  return true;
3181
3604
  }
3182
3605
  }
3183
- });
3606
+ };
3607
+ const recordingProvider = buildRecordingProvider(recordingDeps);
3184
3608
  const evictableProvider = new StorageEvictableProvider({
3185
3609
  index: this.index,
3186
3610
  store: this.segmentStore
3187
3611
  });
3612
+ const exportsState = this.exportsStore();
3613
+ const exportCodecCache = /* @__PURE__ */ new Map();
3614
+ const exportOutPath = (rec) => node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`);
3615
+ const removeExportFile = async (p) => {
3616
+ await node_fs.promises.rm(p, { force: true });
3617
+ };
3618
+ this.exportEngine = new ExportEngine({
3619
+ spawn: node_child_process.spawn,
3620
+ logger: this.ctx.logger.child("export"),
3621
+ state: exportsState,
3622
+ resolvePlaylist: async (rec) => {
3623
+ if ((await buildPlaybackManifest(recordingDeps, exportCodecCache, rec.deviceId, rec.fromMs, rec.toMs)).localMasterPath === null) return null;
3624
+ return playbackVariantPlaylistPath(this.ctx.dataDir, rec.deviceId, rec.profile);
3625
+ },
3626
+ outPath: exportOutPath,
3627
+ statBytes: async (p) => (await node_fs.promises.stat(p)).size,
3628
+ removeFile: removeExportFile,
3629
+ emit: (category, data) => this.emitExportEvent(category, data)
3630
+ });
3631
+ const exportProvider = buildRecordingExportProvider({
3632
+ state: exportsState,
3633
+ engine: this.exportEngine,
3634
+ index: this.index,
3635
+ now: () => Date.now(),
3636
+ newId: () => (0, node_crypto.randomUUID)(),
3637
+ createdBy: () => "operator",
3638
+ outPath: exportOutPath,
3639
+ removeFile: removeExportFile,
3640
+ downloadUrlFor: (id) => `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}/${id}.mp4`
3641
+ });
3188
3642
  try {
3189
3643
  const handler = require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
3190
3644
  const served = await this.ctx.dataPlane?.serve({
@@ -3200,6 +3654,19 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3200
3654
  } catch (err) {
3201
3655
  this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
3202
3656
  }
3657
+ try {
3658
+ const served = await this.ctx.dataPlane?.serve({
3659
+ prefix: EXPORTS_PREFIX,
3660
+ access: "authenticated",
3661
+ handler: (req, res) => this.serveExportDownload(exportsState, exportOutPath, req, res)
3662
+ });
3663
+ this.ctx.logger.info("recorder exports data-plane served", { meta: {
3664
+ clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}` : "(no dataPlane facility)",
3665
+ internal: served?.baseUrl
3666
+ } });
3667
+ } catch (err) {
3668
+ this.ctx.logger.warn("recorder exports data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
3669
+ }
3203
3670
  await this.ensureThumbnailService().serveEarly();
3204
3671
  this.ctx.logger.info("recorder addon started (continuous write path active)", {
3205
3672
  tags: { nodeId: this.nodeId },
@@ -3208,13 +3675,20 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3208
3675
  watchIntervalMs: this.config.watchIntervalMs
3209
3676
  }
3210
3677
  });
3211
- return { providers: [{
3212
- capability: require_dist.recordingCapability,
3213
- provider: recordingProvider
3214
- }, {
3215
- capability: require_dist.storageEvictableCapability,
3216
- provider: evictableProvider
3217
- }] };
3678
+ return { providers: [
3679
+ {
3680
+ capability: require_dist.recordingCapability,
3681
+ provider: recordingProvider
3682
+ },
3683
+ {
3684
+ capability: require_dist.storageEvictableCapability,
3685
+ provider: evictableProvider
3686
+ },
3687
+ {
3688
+ capability: require_dist.recordingExportCapability,
3689
+ provider: exportProvider
3690
+ }
3691
+ ] };
3218
3692
  }
3219
3693
  async onShutdown() {
3220
3694
  this.eventUnsub?.();
@@ -3224,6 +3698,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3224
3698
  this.segmentStore = null;
3225
3699
  await this.thumbnailService?.stop();
3226
3700
  this.thumbnailService = null;
3701
+ if (this.exportSweepTimer !== null) {
3702
+ clearInterval(this.exportSweepTimer);
3703
+ this.exportSweepTimer = null;
3704
+ }
3705
+ this.exportEngine = null;
3227
3706
  }
3228
3707
  /**
3229
3708
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
@@ -3281,6 +3760,39 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3281
3760
  } catch (err) {
3282
3761
  this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: require_dist.errMsg(err) } });
3283
3762
  }
3763
+ await this.bootstrapExports();
3764
+ }
3765
+ /**
3766
+ * Resume interrupted exports and arm the expiry sweep. A row left `rendering`
3767
+ * by a crash/restart has no live ffmpeg, so demote it to `queued` and re-enqueue
3768
+ * it alongside any still-`queued` rows. The sweep runs immediately then on an
3769
+ * interval; both are best-effort (a failure never blocks boot).
3770
+ */
3771
+ async bootstrapExports() {
3772
+ const engine = this.exportEngine;
3773
+ if (!engine) return;
3774
+ const state = this.exportsStore();
3775
+ try {
3776
+ for (const rec of await listExportsFor(state)) {
3777
+ if (rec.state === "rendering") await patchExport(state, rec.id, {
3778
+ state: "queued",
3779
+ progressPct: null
3780
+ });
3781
+ if (rec.state === "queued" || rec.state === "rendering") engine.enqueue(rec.id);
3782
+ }
3783
+ } catch (err) {
3784
+ this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: require_dist.errMsg(err) } });
3785
+ }
3786
+ if (this.exportSweepTimer === null) {
3787
+ const sweep = () => {
3788
+ sweepExpiredExports(Date.now(), this.exportJanitorDeps()).catch((err) => {
3789
+ this.ctx.logger.warn("recorder: export expiry sweep failed", { meta: { error: require_dist.errMsg(err) } });
3790
+ });
3791
+ };
3792
+ sweep();
3793
+ this.exportSweepTimer = setInterval(sweep, EXPORT_SWEEP_INTERVAL_MS);
3794
+ this.exportSweepTimer.unref?.();
3795
+ }
3284
3796
  }
3285
3797
  /** Construct the thumbnail service once — every dep is a lazy closure, so
3286
3798
  * construction is safe in `onInitialize` (before locations resolve). */
@@ -3409,6 +3921,121 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3409
3921
  configStore() {
3410
3922
  return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
3411
3923
  }
3924
+ /** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
3925
+ * wrapper as the config store). */
3926
+ exportsStore() {
3927
+ return this.state(RECORDING_EXPORTS_KEY, RecordingExportsBlobSchema, {});
3928
+ }
3929
+ /** Retention deps shared by the sweep and the download-complete hook. */
3930
+ exportJanitorDeps() {
3931
+ return {
3932
+ state: this.exportsStore(),
3933
+ outPath: (rec) => node_path.default.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`),
3934
+ removeFile: async (p) => {
3935
+ await node_fs.promises.rm(p, { force: true });
3936
+ }
3937
+ };
3938
+ }
3939
+ /** Emit an export telemetry event through the addon event bus (untyped
3940
+ * category — the export categories are telemetry, not in the typed catalog). */
3941
+ emitExportEvent(category, data) {
3942
+ const source = {
3943
+ type: "addon",
3944
+ id: RECORDER_ADDON_ID,
3945
+ nodeId: this.nodeId
3946
+ };
3947
+ this.ctx.eventBus.emit({
3948
+ id: (0, node_crypto.randomUUID)(),
3949
+ timestamp: /* @__PURE__ */ new Date(),
3950
+ source,
3951
+ category,
3952
+ data
3953
+ });
3954
+ }
3955
+ /**
3956
+ * Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
3957
+ * row's file (traversal-guarded by the id regex + the exact stored path),
3958
+ * streams it with HTTP `Range`, and — when the FINAL byte of a full-file GET is
3959
+ * flushed — records the download so `deleteAfterDownload` can reclaim the file.
3960
+ */
3961
+ async serveExportDownload(state, outPath, req, res) {
3962
+ if (req.method !== "GET" && req.method !== "HEAD") {
3963
+ res.writeHead(405).end();
3964
+ return;
3965
+ }
3966
+ const urlPath = (req.url ?? "/").split("?")[0] ?? "/";
3967
+ const match = EXPORT_FILENAME_RE.exec(urlPath.replace(/^\/+/, ""));
3968
+ if (!match) {
3969
+ res.writeHead(404).end();
3970
+ return;
3971
+ }
3972
+ const exportId = match[1];
3973
+ const rec = await getExportRecord(state, exportId);
3974
+ if (!rec || rec.state !== "ready") {
3975
+ res.writeHead(404).end();
3976
+ return;
3977
+ }
3978
+ const absPath = outPath(rec);
3979
+ let size;
3980
+ try {
3981
+ const st = await node_fs.promises.stat(absPath);
3982
+ if (!st.isFile()) {
3983
+ res.writeHead(404).end();
3984
+ return;
3985
+ }
3986
+ size = st.size;
3987
+ } catch {
3988
+ res.writeHead(404).end();
3989
+ return;
3990
+ }
3991
+ const baseHeaders = {
3992
+ "content-type": require_model_download_service_Cp9f4dk6.contentTypeFor(absPath),
3993
+ "accept-ranges": "bytes",
3994
+ "cache-control": "no-cache"
3995
+ };
3996
+ const range = require_model_download_service_Cp9f4dk6.parseRangeHeader(req.headers.range, size);
3997
+ const servesFinalByte = range ? range.end === size - 1 : true;
3998
+ const recordDownloadOnFinish = () => {
3999
+ if (req.method !== "GET" || !servesFinalByte) return;
4000
+ res.once("finish", () => {
4001
+ markDownloaded(exportId, Date.now(), this.exportJanitorDeps(), (fn, ms) => {
4002
+ setTimeout(fn, ms).unref?.();
4003
+ }, EXPORT_DELETE_GRACE_MS).catch((err) => {
4004
+ this.ctx.logger.warn("recorder: markDownloaded failed", { meta: {
4005
+ exportId,
4006
+ error: require_dist.errMsg(err)
4007
+ } });
4008
+ });
4009
+ });
4010
+ };
4011
+ if (range) {
4012
+ res.writeHead(206, {
4013
+ ...baseHeaders,
4014
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
4015
+ "content-length": String(range.end - range.start + 1)
4016
+ });
4017
+ if (req.method === "HEAD") {
4018
+ res.end();
4019
+ return;
4020
+ }
4021
+ recordDownloadOnFinish();
4022
+ (0, node_fs.createReadStream)(absPath, {
4023
+ start: range.start,
4024
+ end: range.end
4025
+ }).pipe(res);
4026
+ return;
4027
+ }
4028
+ res.writeHead(200, {
4029
+ ...baseHeaders,
4030
+ "content-length": String(size)
4031
+ });
4032
+ if (req.method === "HEAD") {
4033
+ res.end();
4034
+ return;
4035
+ }
4036
+ recordDownloadOnFinish();
4037
+ (0, node_fs.createReadStream)(absPath).pipe(res);
4038
+ }
3412
4039
  };
3413
4040
  //#endregion
3414
4041
  module.exports = RecorderV2Addon;