@camstack/addon-pipeline 1.1.60 → 1.1.62

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-DynD1EC-.js → dist-DlMmkOsE.js} +389 -7
  6. package/dist/{dist-CcqqznPf.mjs → dist-LkwlodgP.mjs} +372 -8
  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 +798 -30
  14. package/dist/recorder/index.mjs +799 -31
  15. package/dist/{step-definitions-DhhwklAL.js → step-definitions-CxwB0fEq.js} +85 -2
  16. package/dist/{step-definitions-CvwB_Otk.mjs → step-definitions-DxN9tmQP.mjs} +85 -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-3gAFmxjh.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-DHJB3xUN.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-C4pcysOQ.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-C02YjaoP.mjs → hostInit-B1aDV0u0.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-C7IhxQQ_.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BIzNJmu0.js → MotionZonesSettings-NcxxQN8r-DZXzBUU8.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BHuYOg7Q.js → PrivacyMaskSettings-APgPLF7p-Dz9ttl4A.js} +1 -1
  28. package/embed-dist/assets/index-DRsQUyLF.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,11 +1,12 @@
1
- import { A as recordingCapability, D as nodePin, L as errMsg, N as storageEvictableCapability, R as BaseAddon, T as migrateConfigToBands, U as hydrateSchema, V as DeviceType, W as isEvent, Y as selectAssignedProfileSlots, at as record, ct as EventCategory, j as resolveScrubThumbnailGeometry, ot as string, s as EVENT_PAD_MS, u as RecordingConfigSchema } from "../dist-CcqqznPf.mjs";
1
+ import { B as errMsg, D as migrateConfigToBands, I as storageEvictableCapability, K as hydrateSchema, M as recordingCapability, N as recordingExportCapability, P as resolveScrubThumbnailGeometry, Q as selectAssignedProfileSlots, V as BaseAddon, W as DeviceType, ct as record, d as OpsLogEntrySchema, dt as EventCategory, f as RecordingConfigSchema, k as nodePin, l as ExportRecordSchema, lt as string, q as isEvent, s as EVENT_PAD_MS, tt as array } from "../dist-LkwlodgP.mjs";
2
2
  import { n as computeSheetGeometry, r as resolveHubHostname, t as allTilePlacements } from "../sheet-geometry-DcpuGTCm.mjs";
3
- import { t as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-0wh7OK3s.mjs";
3
+ import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
4
4
  import { spawn } from "node:child_process";
5
5
  import sharp from "sharp";
6
- import { promises } from "node:fs";
6
+ import { createReadStream, promises } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { tmpdir } from "node:os";
9
+ import { randomUUID } from "node:crypto";
9
10
  //#region src/recorder/segment-path.ts
10
11
  function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
11
12
  if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
@@ -872,6 +873,15 @@ function noPlayback(deviceId) {
872
873
  function playbackDirFor(dataDir, deviceId) {
873
874
  return path.join(dataDir, "playback", String(deviceId));
874
875
  }
876
+ /**
877
+ * Absolute path of ONE profile's variant playlist inside the playback dir.
878
+ * `buildPlaybackManifest` writes `<profile>.m3u8` next to the master whenever
879
+ * that profile has overlapping footage — the export engine renders this variant
880
+ * (a single-profile source) rather than the multi-variant master.
881
+ */
882
+ function playbackVariantPlaylistPath(dataDir, deviceId, profile) {
883
+ return path.join(playbackDirFor(dataDir, deviceId), `${profile}.m3u8`);
884
+ }
875
885
  /** Prefix window the CODECS probe reads — the fragment's moov (with its
876
886
  * avcC/hvcC) leads the file and is ~1-2KB, so 64KB is a generous bound. */
877
887
  var PROBE_PREFIX_BYTES = 65536;
@@ -992,6 +1002,34 @@ function segmentsByLocation(rows) {
992
1002
  return out;
993
1003
  }
994
1004
  /**
1005
+ * Evict a set of victim segments through the SegmentStore, grouped by their
1006
+ * storage location, and return the reclaimed bytes + deleted-bucket count.
1007
+ * Per-location isolated: one location's evict failure is logged and never aborts
1008
+ * the others. Shared by `pruneFootage` (retention) and `deleteFootprint`
1009
+ * (manual).
1010
+ */
1011
+ async function evictVictims(deps, deviceId, victims) {
1012
+ let reclaimedBytes = 0;
1013
+ let deletedBuckets = 0;
1014
+ for (const [locationId, locVictims] of segmentsByLocation(victims)) try {
1015
+ const freed = await deps.segmentStore.evict(locationId, locVictims);
1016
+ reclaimedBytes += freed;
1017
+ if (freed > 0) deletedBuckets += locVictims.length;
1018
+ } catch (err) {
1019
+ deps.logger.warn("recorder evict failed for location", {
1020
+ tags: { deviceId },
1021
+ meta: {
1022
+ locationId,
1023
+ error: errMsg(err)
1024
+ }
1025
+ });
1026
+ }
1027
+ return {
1028
+ reclaimedBytes,
1029
+ deletedBuckets
1030
+ };
1031
+ }
1032
+ /**
995
1033
  * Build the `IRecordingProvider`. The provider is a thin façade over the v2
996
1034
  * core; all storage I/O flows through the injected deps.
997
1035
  */
@@ -1088,37 +1126,63 @@ function buildRecordingProvider(deps) {
1088
1126
  rescanStorage: async ({ deviceId }) => {
1089
1127
  const locations = await deps.refreshLocations();
1090
1128
  await deps.hydrateDevice(deviceId, locations);
1091
- return statusFor(deps, deviceId, await loadDeviceConfig(deps.configStore, deviceId));
1129
+ const config = await loadDeviceConfig(deps.configStore, deviceId);
1130
+ await deps.opsLog?.append({
1131
+ op: "rescan",
1132
+ reason: "operator",
1133
+ deviceId,
1134
+ itemsAffected: deps.index.segments(deviceId).length,
1135
+ bytesReclaimed: 0,
1136
+ detail: "rescanned footage from disk"
1137
+ });
1138
+ return statusFor(deps, deviceId, config);
1092
1139
  },
1093
- pruneFootage: async ({ deviceId }) => {
1140
+ pruneFootage: async ({ deviceId, reason }) => {
1094
1141
  const retention = (await loadDeviceConfig(deps.configStore, deviceId)).retention;
1095
1142
  const nowMs = Date.now();
1096
- const victims = selectEvictions(deps.index.segments(deviceId), {
1143
+ const { reclaimedBytes, deletedBuckets } = await evictVictims(deps, deviceId, selectEvictions(deps.index.segments(deviceId), {
1097
1144
  maxAgeDays: retention?.maxAgeDays,
1098
1145
  maxSizeGb: retention?.maxSizeGb,
1099
1146
  nowMs
1147
+ }));
1148
+ const floorMs = deps.index.accounting(deviceId).oldestMs ?? (retention?.maxAgeDays ? nowMs - retention.maxAgeDays * DAY_MS : null);
1149
+ const opReason = reason ?? "retention";
1150
+ await deps.opsLog?.append({
1151
+ op: "prune",
1152
+ reason: opReason,
1153
+ deviceId,
1154
+ itemsAffected: deletedBuckets,
1155
+ bytesReclaimed: reclaimedBytes,
1156
+ detail: floorMs === null ? "no footage remaining" : `floor moved to ${floorMs}`
1157
+ });
1158
+ return {
1159
+ floorMs,
1160
+ deletedBuckets,
1161
+ reclaimedBytes
1162
+ };
1163
+ },
1164
+ deleteFootprint: async ({ deviceId, fromMs, toMs }) => {
1165
+ const lo = fromMs ?? Number.NEGATIVE_INFINITY;
1166
+ const hi = toMs ?? Number.POSITIVE_INFINITY;
1167
+ const { reclaimedBytes, deletedBuckets } = await evictVictims(deps, deviceId, deps.index.segments(deviceId).filter((s) => s.startMs < hi && s.startMs + s.durMs > lo));
1168
+ const windowed = fromMs !== void 0 || toMs !== void 0;
1169
+ await deps.opsLog?.append({
1170
+ op: "manual-delete",
1171
+ reason: "manual",
1172
+ deviceId,
1173
+ itemsAffected: deletedBuckets,
1174
+ bytesReclaimed: reclaimedBytes,
1175
+ detail: windowed ? `deleted footage in [${fromMs ?? "−∞"}, ${toMs ?? "+∞"})` : "deleted all footage"
1100
1176
  });
1101
- let reclaimedBytes = 0;
1102
- let deletedBuckets = 0;
1103
- for (const [locationId, locVictims] of segmentsByLocation(victims)) try {
1104
- const freed = await deps.segmentStore.evict(locationId, locVictims);
1105
- reclaimedBytes += freed;
1106
- if (freed > 0) deletedBuckets += locVictims.length;
1107
- } catch (err) {
1108
- deps.logger.warn("recorder pruneFootage evict failed for location", {
1109
- tags: { deviceId },
1110
- meta: {
1111
- locationId,
1112
- error: errMsg(err)
1113
- }
1114
- });
1115
- }
1116
1177
  return {
1117
- floorMs: deps.index.accounting(deviceId).oldestMs ?? (retention?.maxAgeDays ? nowMs - retention.maxAgeDays * DAY_MS : null),
1118
1178
  deletedBuckets,
1119
1179
  reclaimedBytes
1120
1180
  };
1121
1181
  },
1182
+ listOpsLog: async ({ deviceId, limit }) => await deps.opsLog?.list({
1183
+ deviceId,
1184
+ limit
1185
+ }) ?? [],
1122
1186
  getDeviceSettingsContribution: async ({ deviceId }) => {
1123
1187
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
1124
1188
  return hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -1135,6 +1199,482 @@ function buildRecordingProvider(deps) {
1135
1199
  };
1136
1200
  }
1137
1201
  //#endregion
1202
+ //#region src/recorder/addon/export-store.ts
1203
+ /**
1204
+ * Recording-export persistence for the recorder addon.
1205
+ *
1206
+ * Every export job/history row lives in ONE addon-store blob keyed by export
1207
+ * id. The whole Zod-validated blob round-trips on every read/write via a
1208
+ * {@link DurableState} handle (same pattern as `config-store.ts`), so no field
1209
+ * is silently dropped on persist. History rows are never removed by the system
1210
+ * (audit) — `deleteExport`/janitor flip `state` and clear the file, keeping the
1211
+ * row. A corrupt blob reads back as EMPTY rather than crashing boot.
1212
+ */
1213
+ /**
1214
+ * The addon-store key holding the exportId → ExportRecord map. Production
1215
+ * builds the durable handle via `BaseAddon.state(RECORDING_EXPORTS_KEY,
1216
+ * RecordingExportsBlobSchema, {})`; the pure functions below take that handle,
1217
+ * so this module never touches the @internal raw addon-store primitive.
1218
+ */
1219
+ var RECORDING_EXPORTS_KEY = "recordingExports";
1220
+ /** Persisted shape: export id → full ExportRecord. */
1221
+ var RecordingExportsBlobSchema = record(string(), ExportRecordSchema);
1222
+ /** Read every persisted export row, keyed by export id. */
1223
+ async function readExports(state) {
1224
+ const blob = await state.get();
1225
+ return new Map(Object.entries(blob));
1226
+ }
1227
+ /** Insert or replace one export row. Validates at the boundary. Returns it. */
1228
+ async function upsertExport(state, record) {
1229
+ const validated = ExportRecordSchema.parse(record);
1230
+ const map = await readExports(state);
1231
+ map.set(validated.id, validated);
1232
+ await state.set(Object.fromEntries(map));
1233
+ return validated;
1234
+ }
1235
+ /**
1236
+ * Shallow-merge a patch into one export row and persist. Returns the merged
1237
+ * record, or null when the id is unknown.
1238
+ */
1239
+ async function patchExport(state, id, patch) {
1240
+ const map = await readExports(state);
1241
+ const existing = map.get(id);
1242
+ if (!existing) return null;
1243
+ const merged = ExportRecordSchema.parse({
1244
+ ...existing,
1245
+ ...patch
1246
+ });
1247
+ map.set(id, merged);
1248
+ await state.set(Object.fromEntries(map));
1249
+ return merged;
1250
+ }
1251
+ /** Read one export row by id, or null. */
1252
+ async function getExportRecord(state, id) {
1253
+ return (await readExports(state)).get(id) ?? null;
1254
+ }
1255
+ /**
1256
+ * List export rows, newest-first, optionally scoped to one device.
1257
+ */
1258
+ async function listExportsFor(state, deviceId) {
1259
+ return [...(await readExports(state)).values()].filter((r) => deviceId === void 0 || r.deviceId === deviceId).toSorted((a, b) => b.createdAt - a.createdAt);
1260
+ }
1261
+ //#endregion
1262
+ //#region src/recorder/addon/ops-log-store.ts
1263
+ /**
1264
+ * Recordings ops-log persistence for the recorder addon.
1265
+ *
1266
+ * A bounded, append-only audit ring lives in ONE addon-store blob (same
1267
+ * DurableState pattern as `export-store.ts` / `config-store.ts`) — no SQLite in
1268
+ * the recorder. The whole Zod-validated array round-trips on every read/write,
1269
+ * so no row is silently dropped on persist. Appends evict the oldest rows once
1270
+ * the ring exceeds its cap, so the blob stays bounded regardless of churn. A
1271
+ * corrupt blob reads back as EMPTY rather than crashing boot.
1272
+ *
1273
+ * Writes are BEST-EFFORT: `createRecordingOpsLogSink().append` never throws — a
1274
+ * failed audit write must not fail the operation it records.
1275
+ */
1276
+ /** The addon-store key holding the bounded ops-log ring. */
1277
+ var RECORDING_OPS_LOG_KEY = "recordingOpsLog";
1278
+ /** Persisted shape: a flat array of ops-log rows (oldest-first on disk). */
1279
+ var RecordingOpsLogBlobSchema = array(OpsLogEntrySchema);
1280
+ /**
1281
+ * Append one row and persist, evicting the oldest rows so the ring never grows
1282
+ * past `maxEntries`. Validates at the boundary. Returns the stored row. Rows are
1283
+ * kept oldest-first on disk (the tail is newest); {@link listOpsLog} reverses.
1284
+ */
1285
+ async function appendOpsLog(state, entry, maxEntries = 500) {
1286
+ const validated = OpsLogEntrySchema.parse(entry);
1287
+ const next = [...await state.get(), validated];
1288
+ const bounded = next.length > maxEntries ? next.slice(next.length - maxEntries) : next;
1289
+ await state.set(bounded);
1290
+ return validated;
1291
+ }
1292
+ /**
1293
+ * List ops-log rows newest-first, optionally scoped to one device, capped at
1294
+ * `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
1295
+ */
1296
+ async function listOpsLog(state, query) {
1297
+ const rows = await state.get();
1298
+ return (query.deviceId === void 0 ? rows : rows.filter((r) => r.deviceId === query.deviceId)).toSorted((a, b) => b.at - a.at).slice(0, query.limit ?? 200);
1299
+ }
1300
+ /**
1301
+ * Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
1302
+ * `nodeId`, `id` and `at`, then persists best-effort — a failed write is logged,
1303
+ * never thrown, so it cannot fail the operation being audited.
1304
+ */
1305
+ function createRecordingOpsLogSink(deps) {
1306
+ return {
1307
+ append: async (input) => {
1308
+ const entry = {
1309
+ id: deps.newId(),
1310
+ at: deps.now(),
1311
+ domain: "recording",
1312
+ op: input.op,
1313
+ reason: input.reason,
1314
+ deviceId: input.deviceId,
1315
+ nodeId: deps.nodeId,
1316
+ itemsAffected: input.itemsAffected,
1317
+ bytesReclaimed: input.bytesReclaimed,
1318
+ detail: input.detail ?? null,
1319
+ actor: input.actor ?? "operator"
1320
+ };
1321
+ try {
1322
+ await appendOpsLog(deps.state, entry, deps.maxEntries);
1323
+ } catch (err) {
1324
+ deps.logger.warn("recorder: ops-log append failed (best-effort)", {
1325
+ tags: { deviceId: input.deviceId ?? void 0 },
1326
+ meta: {
1327
+ op: input.op,
1328
+ error: err instanceof Error ? err.message : String(err)
1329
+ }
1330
+ });
1331
+ }
1332
+ },
1333
+ list: (query) => listOpsLog(deps.state, query)
1334
+ };
1335
+ }
1336
+ //#endregion
1337
+ //#region src/recorder/addon/export-ffmpeg-args.ts
1338
+ /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
1339
+ * band audio is dropped rather than chained (kept simple + predictable). */
1340
+ var ATEMPO_MIN = .5;
1341
+ var ATEMPO_MAX = 2;
1342
+ var X264 = [
1343
+ "-c:v",
1344
+ "libx264",
1345
+ "-preset",
1346
+ "veryfast",
1347
+ "-pix_fmt",
1348
+ "yuv420p"
1349
+ ];
1350
+ var FASTSTART = ["-movflags", "+faststart"];
1351
+ /** Build the ffmpeg argv for one export render. */
1352
+ function buildExportArgs(input) {
1353
+ const { inputPlaylist, outPath, options } = input;
1354
+ const inArgs = ["-i", inputPlaylist];
1355
+ if (options.timelapse) {
1356
+ const everySec = options.timelapse.everyMs / 1e3;
1357
+ const fps = options.timelapse.outputFps ?? 30;
1358
+ return [
1359
+ ...inArgs,
1360
+ "-vf",
1361
+ `select='not(mod(t,${everySec}))',setpts=N/FRAME_RATE/TB,fps=${fps}`,
1362
+ "-an",
1363
+ ...X264,
1364
+ ...FASTSTART,
1365
+ outPath
1366
+ ];
1367
+ }
1368
+ const speed = options.speed ?? 1;
1369
+ if (speed === 1) return [
1370
+ ...inArgs,
1371
+ "-map",
1372
+ "0",
1373
+ "-c",
1374
+ "copy",
1375
+ ...options.includeAudio ? [] : ["-an"],
1376
+ ...FASTSTART,
1377
+ outPath
1378
+ ];
1379
+ const keepAudio = options.includeAudio && speed >= ATEMPO_MIN && speed <= ATEMPO_MAX;
1380
+ return [
1381
+ ...inArgs,
1382
+ "-filter:v",
1383
+ `setpts=PTS/${speed}`,
1384
+ ...keepAudio ? [
1385
+ "-filter:a",
1386
+ `atempo=${speed}`,
1387
+ "-c:a",
1388
+ "aac"
1389
+ ] : ["-an"],
1390
+ ...X264,
1391
+ ...FASTSTART,
1392
+ outPath
1393
+ ];
1394
+ }
1395
+ //#endregion
1396
+ //#region src/recorder/addon/export-engine.ts
1397
+ /**
1398
+ * Render engine for recording exports.
1399
+ *
1400
+ * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
1401
+ * protects the recording node). Each job: resolve the range's local HLS master
1402
+ * playlist → build argv (`buildExportArgs`) → spawn ffmpeg → parse `time=` from
1403
+ * stderr for progress → on exit 0 stat the file and mark `ready`; on non-zero
1404
+ * mark `failed` and remove the partial. `cancel` SIGKILLs an in-flight render.
1405
+ * State transitions patch the DurableState store and emit telemetry events.
1406
+ *
1407
+ * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
1408
+ * re-queue of interrupted jobs is the provider's concern.
1409
+ */
1410
+ var STDERR_TAIL_LINES$1 = 12;
1411
+ var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
1412
+ /** Expected OUTPUT duration (seconds) for progress scaling. */
1413
+ function expectedOutSeconds(rec) {
1414
+ const rangeSec = Math.max(.001, (rec.toMs - rec.fromMs) / 1e3);
1415
+ if (rec.options.timelapse) {
1416
+ const everySec = rec.options.timelapse.everyMs / 1e3;
1417
+ const fps = rec.options.timelapse.outputFps ?? 30;
1418
+ return Math.max(.001, rangeSec / everySec / fps);
1419
+ }
1420
+ return rangeSec / (rec.options.speed ?? 1);
1421
+ }
1422
+ var ExportEngine = class {
1423
+ deps;
1424
+ queue = [];
1425
+ activeId = null;
1426
+ activeProc = null;
1427
+ cancelled = /* @__PURE__ */ new Set();
1428
+ constructor(deps) {
1429
+ this.deps = deps;
1430
+ }
1431
+ /** Queue a persisted `queued` record for rendering. */
1432
+ enqueue(id) {
1433
+ if (this.queue.includes(id) || this.activeId === id) return;
1434
+ this.queue.push(id);
1435
+ this.pump();
1436
+ }
1437
+ /** Abort a queued or in-flight render. */
1438
+ async cancel(id) {
1439
+ this.cancelled.add(id);
1440
+ const idx = this.queue.indexOf(id);
1441
+ if (idx >= 0) this.queue.splice(idx, 1);
1442
+ if (this.activeId === id && this.activeProc) try {
1443
+ this.activeProc.kill("SIGKILL");
1444
+ } catch {}
1445
+ else if (idx >= 0) await this.fail(id, "cancelled");
1446
+ }
1447
+ async pump() {
1448
+ if (this.activeId) return;
1449
+ const id = this.queue.shift();
1450
+ if (!id) return;
1451
+ if (this.cancelled.has(id)) {
1452
+ this.cancelled.delete(id);
1453
+ await this.fail(id, "cancelled");
1454
+ this.pump();
1455
+ return;
1456
+ }
1457
+ this.activeId = id;
1458
+ try {
1459
+ await this.render(id);
1460
+ } catch (err) {
1461
+ await this.fail(id, err instanceof Error ? err.message : String(err));
1462
+ } finally {
1463
+ this.activeId = null;
1464
+ this.activeProc = null;
1465
+ this.pump();
1466
+ }
1467
+ }
1468
+ async render(id) {
1469
+ const rec = await patchExport(this.deps.state, id, {
1470
+ state: "rendering",
1471
+ progressPct: 0
1472
+ });
1473
+ if (!rec) return;
1474
+ const playlist = await this.deps.resolvePlaylist(rec);
1475
+ if (!playlist) {
1476
+ await this.fail(id, "no footage for range");
1477
+ return;
1478
+ }
1479
+ const outPath = this.deps.outPath(rec);
1480
+ const args = buildExportArgs({
1481
+ inputPlaylist: playlist,
1482
+ outPath,
1483
+ options: rec.options
1484
+ });
1485
+ const totalSec = expectedOutSeconds(rec);
1486
+ await new Promise((resolve) => {
1487
+ const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1488
+ this.activeProc = proc;
1489
+ const stderrTail = [];
1490
+ proc.stderr?.on("data", (chunk) => {
1491
+ const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
1492
+ for (const line of text.split("\n")) {
1493
+ const trimmed = line.trim();
1494
+ if (trimmed) {
1495
+ stderrTail.push(trimmed);
1496
+ if (stderrTail.length > STDERR_TAIL_LINES$1) stderrTail.shift();
1497
+ }
1498
+ const m = TIME_RE.exec(line);
1499
+ if (m) {
1500
+ const sec = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
1501
+ const pct = Math.min(99, Math.round(sec / totalSec * 100));
1502
+ patchExport(this.deps.state, id, { progressPct: pct });
1503
+ this.deps.emit(EventCategory.RecordingExportProgress, {
1504
+ exportId: id,
1505
+ progressPct: pct
1506
+ });
1507
+ }
1508
+ }
1509
+ });
1510
+ proc.on("exit", (code) => {
1511
+ this.activeProc = null;
1512
+ this.finish(id, typeof code === "number" ? code : null, outPath, stderrTail).then(resolve);
1513
+ });
1514
+ });
1515
+ }
1516
+ async finish(id, code, outPath, stderrTail) {
1517
+ if (this.cancelled.has(id)) {
1518
+ this.cancelled.delete(id);
1519
+ await this.deps.removeFile(outPath);
1520
+ await this.fail(id, "cancelled");
1521
+ return;
1522
+ }
1523
+ if (code !== 0) {
1524
+ await this.deps.removeFile(outPath);
1525
+ await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.join(" | ")}`);
1526
+ return;
1527
+ }
1528
+ let bytes = 0;
1529
+ try {
1530
+ bytes = await this.deps.statBytes(outPath);
1531
+ } catch (err) {
1532
+ await this.fail(id, err instanceof Error ? err.message : String(err));
1533
+ return;
1534
+ }
1535
+ await patchExport(this.deps.state, id, {
1536
+ state: "ready",
1537
+ progressPct: 100,
1538
+ fileBytes: bytes,
1539
+ error: null
1540
+ });
1541
+ this.deps.emit(EventCategory.RecordingExportCompleted, {
1542
+ exportId: id,
1543
+ fileBytes: bytes
1544
+ });
1545
+ }
1546
+ async fail(id, error) {
1547
+ await patchExport(this.deps.state, id, {
1548
+ state: "failed",
1549
+ progressPct: null,
1550
+ error
1551
+ });
1552
+ this.deps.emit(EventCategory.RecordingExportFailed, {
1553
+ exportId: id,
1554
+ error
1555
+ });
1556
+ }
1557
+ };
1558
+ //#endregion
1559
+ //#region src/recorder/addon/recording-export-provider.ts
1560
+ /**
1561
+ * True when at least one indexed segment overlaps `[fromMs, toMs)`. Mirrors the
1562
+ * `buildPlaybackManifest` overlap idiom: a segment `[startMs, startMs+durMs)`
1563
+ * overlaps the window iff it starts before the window ends AND ends after the
1564
+ * window starts.
1565
+ */
1566
+ function hasFootage(index, deviceId, profile, fromMs, toMs) {
1567
+ return index.segments(deviceId, profile).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
1568
+ }
1569
+ /** Load a record or throw a clear NOT_FOUND-style error. */
1570
+ async function requireRecord(state, exportId) {
1571
+ const rec = await getExportRecord(state, exportId);
1572
+ if (!rec) throw new Error(`recordingExport: unknown export "${exportId}"`);
1573
+ return rec;
1574
+ }
1575
+ /** Build the `recordingExport` provider over the injected deps. */
1576
+ function buildRecordingExportProvider(deps) {
1577
+ return {
1578
+ createExport: async ({ deviceId, profile, fromMs, toMs, options }) => {
1579
+ if (!(fromMs < toMs)) throw new Error(`recordingExport: invalid range ${fromMs}..${toMs}`);
1580
+ if (!hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recordingExport: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
1581
+ const now = deps.now();
1582
+ const record = {
1583
+ id: deps.newId(),
1584
+ deviceId,
1585
+ profile,
1586
+ fromMs,
1587
+ toMs,
1588
+ options,
1589
+ state: "queued",
1590
+ progressPct: null,
1591
+ fileBytes: null,
1592
+ expiresAt: now + options.maxLifeMs,
1593
+ deleteAfterDownload: options.deleteAfterDownload,
1594
+ downloadedAt: null,
1595
+ createdAt: now,
1596
+ createdBy: deps.createdBy(),
1597
+ error: null
1598
+ };
1599
+ const saved = await upsertExport(deps.state, record);
1600
+ deps.engine.enqueue(saved.id);
1601
+ return saved;
1602
+ },
1603
+ listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
1604
+ getExport: ({ exportId }) => requireRecord(deps.state, exportId),
1605
+ cancelExport: async ({ exportId }) => {
1606
+ const existing = await requireRecord(deps.state, exportId);
1607
+ await deps.engine.cancel(exportId);
1608
+ return await getExportRecord(deps.state, exportId) ?? existing;
1609
+ },
1610
+ deleteExport: async ({ exportId }) => {
1611
+ const existing = await requireRecord(deps.state, exportId);
1612
+ await deps.removeFile(deps.outPath(existing));
1613
+ return await patchExport(deps.state, exportId, {
1614
+ state: "deleted",
1615
+ fileBytes: null
1616
+ }) ?? existing;
1617
+ },
1618
+ getDownloadUrl: async ({ exportId }) => {
1619
+ await requireRecord(deps.state, exportId);
1620
+ const url = deps.downloadUrlFor(exportId);
1621
+ return {
1622
+ url,
1623
+ endpoints: [url]
1624
+ };
1625
+ }
1626
+ };
1627
+ }
1628
+ //#endregion
1629
+ //#region src/recorder/addon/export-janitor.ts
1630
+ /** States whose file still exists on disk and can be reclaimed. */
1631
+ var LIVE_STATES = new Set(["ready"]);
1632
+ /**
1633
+ * Delete the files of every export past `expiresAt` and flip them to `expired`
1634
+ * (row kept). Only `ready` exports hold a file; `queued`/`rendering` are left
1635
+ * for the engine, terminal rows are skipped. Returns the expired ids.
1636
+ */
1637
+ async function sweepExpiredExports(now, deps) {
1638
+ const rows = await listExportsFor(deps.state);
1639
+ const expired = [];
1640
+ for (const rec of rows) {
1641
+ if (!LIVE_STATES.has(rec.state)) continue;
1642
+ if (rec.expiresAt > now) continue;
1643
+ try {
1644
+ await deps.removeFile(deps.outPath(rec));
1645
+ } catch {}
1646
+ await patchExport(deps.state, rec.id, {
1647
+ state: "expired",
1648
+ fileBytes: null
1649
+ });
1650
+ expired.push(rec.id);
1651
+ }
1652
+ return { expired };
1653
+ }
1654
+ /**
1655
+ * Record a completed download. Stamps `downloadedAt`; when the export is
1656
+ * `deleteAfterDownload`, removes the file after `graceMs` (protects immediate
1657
+ * client retries) and flips to `deleted`. `scheduleDelete` is injected so the
1658
+ * caller controls timer semantics (real setTimeout in prod, immediate in test).
1659
+ */
1660
+ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
1661
+ const rec = await patchExport(deps.state, id, { downloadedAt: now });
1662
+ if (!rec || !rec.deleteAfterDownload) return;
1663
+ scheduleDelete(() => {
1664
+ (async () => {
1665
+ const current = (await listExportsFor(deps.state)).find((r) => r.id === id);
1666
+ if (!current || current.state !== "ready") return;
1667
+ try {
1668
+ await deps.removeFile(deps.outPath(current));
1669
+ } catch {}
1670
+ await patchExport(deps.state, id, {
1671
+ state: "deleted",
1672
+ fileBytes: null
1673
+ });
1674
+ })();
1675
+ }, graceMs);
1676
+ }
1677
+ //#endregion
1138
1678
  //#region src/recorder/addon/roots.ts
1139
1679
  /**
1140
1680
  * Storage-root resolution + boot hydrate for the recorder addon.
@@ -3052,6 +3592,14 @@ var MARKER_PAD_POST_MS = EVENT_PAD_MS.postMs;
3052
3592
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
3053
3593
  var RECORDER_ADDON_ID = "recorder";
3054
3594
  var PLAYBACK_PREFIX = "playback";
3595
+ /** Data-plane prefix for export MP4 downloads → `/addon/recorder/exports/<id>.mp4`. */
3596
+ var EXPORTS_PREFIX = "exports";
3597
+ /** How often the export janitor sweeps expired exports. */
3598
+ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
3599
+ /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
3600
+ var EXPORT_DELETE_GRACE_MS = 6e4;
3601
+ /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
3602
+ var EXPORT_FILENAME_RE = /^([A-Za-z0-9][A-Za-z0-9_-]*)\.mp4$/;
3055
3603
  /** Narrows the `unknown` extras `SegmentStore`'s logger contract accepts down
3056
3604
  * to the structured `{ tags, error }` shape the device-scoped call sites
3057
3605
  * (stat/relocate failures) pass — as opposed to the raw caught error most
@@ -3079,6 +3627,10 @@ var RecorderV2Addon = class extends BaseAddon {
3079
3627
  playbackBaseUrl = null;
3080
3628
  /** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
3081
3629
  thumbnailService = null;
3630
+ /** Render engine for recording exports. Null on non-recording nodes. */
3631
+ exportEngine = null;
3632
+ /** Periodic export-expiry sweep timer. Cleared on shutdown. */
3633
+ exportSweepTimer = null;
3082
3634
  constructor() {
3083
3635
  super({ ...DEFAULT_CONFIG });
3084
3636
  }
@@ -3156,12 +3708,20 @@ var RecorderV2Addon = class extends BaseAddon {
3156
3708
  }),
3157
3709
  brokerCall: makeBrokerCall(brokerHandle)
3158
3710
  });
3159
- const recordingProvider = buildRecordingProvider({
3711
+ const opsLog = createRecordingOpsLogSink({
3712
+ state: this.opsLogStore(),
3713
+ nodeId: this.nodeId,
3714
+ now: () => Date.now(),
3715
+ newId: () => randomUUID(),
3716
+ logger: this.ctx.logger.child("ops-log")
3717
+ });
3718
+ const recordingDeps = {
3160
3719
  index: this.index,
3161
3720
  segmentStore: this.segmentStore,
3162
3721
  nodeId: this.nodeId,
3163
3722
  logger: this.ctx.logger,
3164
3723
  configStore: this.configStore(),
3724
+ opsLog,
3165
3725
  locations: () => this.resolvedLocations,
3166
3726
  refreshLocations: () => this.refreshLocations(),
3167
3727
  capacity: (root) => this.locationCapacity(root),
@@ -3177,11 +3737,42 @@ var RecorderV2Addon = class extends BaseAddon {
3177
3737
  return true;
3178
3738
  }
3179
3739
  }
3180
- });
3740
+ };
3741
+ const recordingProvider = buildRecordingProvider(recordingDeps);
3181
3742
  const evictableProvider = new StorageEvictableProvider({
3182
3743
  index: this.index,
3183
3744
  store: this.segmentStore
3184
3745
  });
3746
+ const exportsState = this.exportsStore();
3747
+ const exportCodecCache = /* @__PURE__ */ new Map();
3748
+ const exportOutPath = (rec) => path.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`);
3749
+ const removeExportFile = async (p) => {
3750
+ await promises.rm(p, { force: true });
3751
+ };
3752
+ this.exportEngine = new ExportEngine({
3753
+ spawn,
3754
+ logger: this.ctx.logger.child("export"),
3755
+ state: exportsState,
3756
+ resolvePlaylist: async (rec) => {
3757
+ if ((await buildPlaybackManifest(recordingDeps, exportCodecCache, rec.deviceId, rec.fromMs, rec.toMs)).localMasterPath === null) return null;
3758
+ return playbackVariantPlaylistPath(this.ctx.dataDir, rec.deviceId, rec.profile);
3759
+ },
3760
+ outPath: exportOutPath,
3761
+ statBytes: async (p) => (await promises.stat(p)).size,
3762
+ removeFile: removeExportFile,
3763
+ emit: (category, data) => this.emitExportEvent(category, data)
3764
+ });
3765
+ const exportProvider = buildRecordingExportProvider({
3766
+ state: exportsState,
3767
+ engine: this.exportEngine,
3768
+ index: this.index,
3769
+ now: () => Date.now(),
3770
+ newId: () => randomUUID(),
3771
+ createdBy: () => "operator",
3772
+ outPath: exportOutPath,
3773
+ removeFile: removeExportFile,
3774
+ downloadUrlFor: (id) => `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}/${id}.mp4`
3775
+ });
3185
3776
  try {
3186
3777
  const handler = createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
3187
3778
  const served = await this.ctx.dataPlane?.serve({
@@ -3197,6 +3788,19 @@ var RecorderV2Addon = class extends BaseAddon {
3197
3788
  } catch (err) {
3198
3789
  this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: errMsg(err) } });
3199
3790
  }
3791
+ try {
3792
+ const served = await this.ctx.dataPlane?.serve({
3793
+ prefix: EXPORTS_PREFIX,
3794
+ access: "authenticated",
3795
+ handler: (req, res) => this.serveExportDownload(exportsState, exportOutPath, req, res)
3796
+ });
3797
+ this.ctx.logger.info("recorder exports data-plane served", { meta: {
3798
+ clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}` : "(no dataPlane facility)",
3799
+ internal: served?.baseUrl
3800
+ } });
3801
+ } catch (err) {
3802
+ this.ctx.logger.warn("recorder exports data-plane failed to serve", { meta: { error: errMsg(err) } });
3803
+ }
3200
3804
  await this.ensureThumbnailService().serveEarly();
3201
3805
  this.ctx.logger.info("recorder addon started (continuous write path active)", {
3202
3806
  tags: { nodeId: this.nodeId },
@@ -3205,13 +3809,20 @@ var RecorderV2Addon = class extends BaseAddon {
3205
3809
  watchIntervalMs: this.config.watchIntervalMs
3206
3810
  }
3207
3811
  });
3208
- return { providers: [{
3209
- capability: recordingCapability,
3210
- provider: recordingProvider
3211
- }, {
3212
- capability: storageEvictableCapability,
3213
- provider: evictableProvider
3214
- }] };
3812
+ return { providers: [
3813
+ {
3814
+ capability: recordingCapability,
3815
+ provider: recordingProvider
3816
+ },
3817
+ {
3818
+ capability: storageEvictableCapability,
3819
+ provider: evictableProvider
3820
+ },
3821
+ {
3822
+ capability: recordingExportCapability,
3823
+ provider: exportProvider
3824
+ }
3825
+ ] };
3215
3826
  }
3216
3827
  async onShutdown() {
3217
3828
  this.eventUnsub?.();
@@ -3221,6 +3832,11 @@ var RecorderV2Addon = class extends BaseAddon {
3221
3832
  this.segmentStore = null;
3222
3833
  await this.thumbnailService?.stop();
3223
3834
  this.thumbnailService = null;
3835
+ if (this.exportSweepTimer !== null) {
3836
+ clearInterval(this.exportSweepTimer);
3837
+ this.exportSweepTimer = null;
3838
+ }
3839
+ this.exportEngine = null;
3224
3840
  }
3225
3841
  /**
3226
3842
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
@@ -3278,6 +3894,39 @@ var RecorderV2Addon = class extends BaseAddon {
3278
3894
  } catch (err) {
3279
3895
  this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: errMsg(err) } });
3280
3896
  }
3897
+ await this.bootstrapExports();
3898
+ }
3899
+ /**
3900
+ * Resume interrupted exports and arm the expiry sweep. A row left `rendering`
3901
+ * by a crash/restart has no live ffmpeg, so demote it to `queued` and re-enqueue
3902
+ * it alongside any still-`queued` rows. The sweep runs immediately then on an
3903
+ * interval; both are best-effort (a failure never blocks boot).
3904
+ */
3905
+ async bootstrapExports() {
3906
+ const engine = this.exportEngine;
3907
+ if (!engine) return;
3908
+ const state = this.exportsStore();
3909
+ try {
3910
+ for (const rec of await listExportsFor(state)) {
3911
+ if (rec.state === "rendering") await patchExport(state, rec.id, {
3912
+ state: "queued",
3913
+ progressPct: null
3914
+ });
3915
+ if (rec.state === "queued" || rec.state === "rendering") engine.enqueue(rec.id);
3916
+ }
3917
+ } catch (err) {
3918
+ this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: errMsg(err) } });
3919
+ }
3920
+ if (this.exportSweepTimer === null) {
3921
+ const sweep = () => {
3922
+ sweepExpiredExports(Date.now(), this.exportJanitorDeps()).catch((err) => {
3923
+ this.ctx.logger.warn("recorder: export expiry sweep failed", { meta: { error: errMsg(err) } });
3924
+ });
3925
+ };
3926
+ sweep();
3927
+ this.exportSweepTimer = setInterval(sweep, EXPORT_SWEEP_INTERVAL_MS);
3928
+ this.exportSweepTimer.unref?.();
3929
+ }
3281
3930
  }
3282
3931
  /** Construct the thumbnail service once — every dep is a lazy closure, so
3283
3932
  * construction is safe in `onInitialize` (before locations resolve). */
@@ -3406,6 +4055,125 @@ var RecorderV2Addon = class extends BaseAddon {
3406
4055
  configStore() {
3407
4056
  return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
3408
4057
  }
4058
+ /** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
4059
+ * wrapper as the config store). */
4060
+ exportsStore() {
4061
+ return this.state(RECORDING_EXPORTS_KEY, RecordingExportsBlobSchema, {});
4062
+ }
4063
+ /** Durable handle over the bounded recordings ops-log ring. */
4064
+ opsLogStore() {
4065
+ return this.state(RECORDING_OPS_LOG_KEY, RecordingOpsLogBlobSchema, []);
4066
+ }
4067
+ /** Retention deps shared by the sweep and the download-complete hook. */
4068
+ exportJanitorDeps() {
4069
+ return {
4070
+ state: this.exportsStore(),
4071
+ outPath: (rec) => path.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`),
4072
+ removeFile: async (p) => {
4073
+ await promises.rm(p, { force: true });
4074
+ }
4075
+ };
4076
+ }
4077
+ /** Emit an export telemetry event through the addon event bus (untyped
4078
+ * category — the export categories are telemetry, not in the typed catalog). */
4079
+ emitExportEvent(category, data) {
4080
+ const source = {
4081
+ type: "addon",
4082
+ id: RECORDER_ADDON_ID,
4083
+ nodeId: this.nodeId
4084
+ };
4085
+ this.ctx.eventBus.emit({
4086
+ id: randomUUID(),
4087
+ timestamp: /* @__PURE__ */ new Date(),
4088
+ source,
4089
+ category,
4090
+ data
4091
+ });
4092
+ }
4093
+ /**
4094
+ * Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
4095
+ * row's file (traversal-guarded by the id regex + the exact stored path),
4096
+ * streams it with HTTP `Range`, and — when the FINAL byte of a full-file GET is
4097
+ * flushed — records the download so `deleteAfterDownload` can reclaim the file.
4098
+ */
4099
+ async serveExportDownload(state, outPath, req, res) {
4100
+ if (req.method !== "GET" && req.method !== "HEAD") {
4101
+ res.writeHead(405).end();
4102
+ return;
4103
+ }
4104
+ const urlPath = (req.url ?? "/").split("?")[0] ?? "/";
4105
+ const match = EXPORT_FILENAME_RE.exec(urlPath.replace(/^\/+/, ""));
4106
+ if (!match) {
4107
+ res.writeHead(404).end();
4108
+ return;
4109
+ }
4110
+ const exportId = match[1];
4111
+ const rec = await getExportRecord(state, exportId);
4112
+ if (!rec || rec.state !== "ready") {
4113
+ res.writeHead(404).end();
4114
+ return;
4115
+ }
4116
+ const absPath = outPath(rec);
4117
+ let size;
4118
+ try {
4119
+ const st = await promises.stat(absPath);
4120
+ if (!st.isFile()) {
4121
+ res.writeHead(404).end();
4122
+ return;
4123
+ }
4124
+ size = st.size;
4125
+ } catch {
4126
+ res.writeHead(404).end();
4127
+ return;
4128
+ }
4129
+ const baseHeaders = {
4130
+ "content-type": contentTypeFor(absPath),
4131
+ "accept-ranges": "bytes",
4132
+ "cache-control": "no-cache"
4133
+ };
4134
+ const range = parseRangeHeader(req.headers.range, size);
4135
+ const servesFinalByte = range ? range.end === size - 1 : true;
4136
+ const recordDownloadOnFinish = () => {
4137
+ if (req.method !== "GET" || !servesFinalByte) return;
4138
+ res.once("finish", () => {
4139
+ markDownloaded(exportId, Date.now(), this.exportJanitorDeps(), (fn, ms) => {
4140
+ setTimeout(fn, ms).unref?.();
4141
+ }, EXPORT_DELETE_GRACE_MS).catch((err) => {
4142
+ this.ctx.logger.warn("recorder: markDownloaded failed", { meta: {
4143
+ exportId,
4144
+ error: errMsg(err)
4145
+ } });
4146
+ });
4147
+ });
4148
+ };
4149
+ if (range) {
4150
+ res.writeHead(206, {
4151
+ ...baseHeaders,
4152
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
4153
+ "content-length": String(range.end - range.start + 1)
4154
+ });
4155
+ if (req.method === "HEAD") {
4156
+ res.end();
4157
+ return;
4158
+ }
4159
+ recordDownloadOnFinish();
4160
+ createReadStream(absPath, {
4161
+ start: range.start,
4162
+ end: range.end
4163
+ }).pipe(res);
4164
+ return;
4165
+ }
4166
+ res.writeHead(200, {
4167
+ ...baseHeaders,
4168
+ "content-length": String(size)
4169
+ });
4170
+ if (req.method === "HEAD") {
4171
+ res.end();
4172
+ return;
4173
+ }
4174
+ recordDownloadOnFinish();
4175
+ createReadStream(absPath).pipe(res);
4176
+ }
3409
4177
  };
3410
4178
  //#endregion
3411
4179
  export { RecorderV2Addon as default };