@camstack/addon-pipeline 1.1.59 → 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 (34) 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 +42 -4
  4. package/dist/detection-pipeline/index.mjs +42 -4
  5. package/dist/{dist-DKeNH_5z.mjs → dist-D16_dBsr.mjs} +281 -7
  6. package/dist/{dist-B1u-QN05.js → dist-p_JqOxtk.js} +298 -6
  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 +4 -4
  12. package/dist/pipeline-runner/index.mjs +4 -4
  13. package/dist/recorder/index.js +685 -30
  14. package/dist/recorder/index.mjs +686 -31
  15. package/dist/{sheet-geometry-BN80zpCa.js → sheet-geometry-BR-ip0CP.js} +2 -2
  16. package/dist/{sheet-geometry-Bk3fR4s7.mjs → sheet-geometry-DcpuGTCm.mjs} +2 -2
  17. package/dist/{step-definitions-CPch37vh.js → step-definitions-CoxF5G31.js} +18 -2
  18. package/dist/{step-definitions-DUB27V8Y.mjs → step-definitions-DxAcpUV-.mjs} +18 -2
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdkfKDgw.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Wu4TlSuq.mjs} +3 -3
  21. 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
  22. 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
  23. package/dist/stream-broker/{hostInit-BLn2Pv8Q.mjs → hostInit-D_dSciqF.mjs} +3 -3
  24. package/dist/stream-broker/index.js +29 -9
  25. package/dist/stream-broker/index.mjs +29 -9
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B0_nx15B.js → MaskShapeCanvas-DI4BY7W2-BldL9_Ee.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-B8uM-VeO.js → MotionZonesSettings-NcxxQN8r-BgWqMe4f.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js → PrivacyMaskSettings-APgPLF7p-YBgaXffP.js} +1 -1
  30. package/embed-dist/assets/index-e0bQ8yep.js +81 -0
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +5 -1
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D3h_W9uu.mjs +0 -26
  34. package/embed-dist/assets/index-W9-H5LTj.js +0 -81
@@ -1,11 +1,12 @@
1
- import { A as recordingCapability, B as DeviceType, D as nodePin, H as hydrateSchema, I as errMsg, J as selectAssignedProfileSlots, L as BaseAddon, M as storageEvictableCapability, T as migrateConfigToBands, U as isEvent, at as string, it as record, s as EVENT_PAD_MS, st as EventCategory, u as RecordingConfigSchema } from "../dist-DKeNH_5z.mjs";
2
- import { n as computeSheetGeometry, r as resolveHubHostname, t as allTilePlacements } from "../sheet-geometry-Bk3fR4s7.mjs";
3
- import { t as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-0wh7OK3s.mjs";
1
+ import { B as BaseAddon, E as migrateConfigToBands, F as storageEvictableCapability, G as hydrateSchema, K as isEvent, M as recordingExportCapability, N as resolveScrubThumbnailGeometry, O as nodePin, U as DeviceType, Z as selectAssignedProfileSlots, ct as string, d as RecordingConfigSchema, j as recordingCapability, l as ExportRecordSchema, s as EVENT_PAD_MS, st as record, ut as EventCategory, z as errMsg } from "../dist-D16_dBsr.mjs";
2
+ import { n as computeSheetGeometry, r as resolveHubHostname, t as allTilePlacements } from "../sheet-geometry-DcpuGTCm.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;
@@ -1135,6 +1145,407 @@ function buildRecordingProvider(deps) {
1135
1145
  };
1136
1146
  }
1137
1147
  //#endregion
1148
+ //#region src/recorder/addon/export-store.ts
1149
+ /**
1150
+ * Recording-export persistence for the recorder addon.
1151
+ *
1152
+ * Every export job/history row lives in ONE addon-store blob keyed by export
1153
+ * id. The whole Zod-validated blob round-trips on every read/write via a
1154
+ * {@link DurableState} handle (same pattern as `config-store.ts`), so no field
1155
+ * is silently dropped on persist. History rows are never removed by the system
1156
+ * (audit) — `deleteExport`/janitor flip `state` and clear the file, keeping the
1157
+ * row. A corrupt blob reads back as EMPTY rather than crashing boot.
1158
+ */
1159
+ /**
1160
+ * The addon-store key holding the exportId → ExportRecord map. Production
1161
+ * builds the durable handle via `BaseAddon.state(RECORDING_EXPORTS_KEY,
1162
+ * RecordingExportsBlobSchema, {})`; the pure functions below take that handle,
1163
+ * so this module never touches the @internal raw addon-store primitive.
1164
+ */
1165
+ var RECORDING_EXPORTS_KEY = "recordingExports";
1166
+ /** Persisted shape: export id → full ExportRecord. */
1167
+ var RecordingExportsBlobSchema = record(string(), ExportRecordSchema);
1168
+ /** Read every persisted export row, keyed by export id. */
1169
+ async function readExports(state) {
1170
+ const blob = await state.get();
1171
+ return new Map(Object.entries(blob));
1172
+ }
1173
+ /** Insert or replace one export row. Validates at the boundary. Returns it. */
1174
+ async function upsertExport(state, record) {
1175
+ const validated = ExportRecordSchema.parse(record);
1176
+ const map = await readExports(state);
1177
+ map.set(validated.id, validated);
1178
+ await state.set(Object.fromEntries(map));
1179
+ return validated;
1180
+ }
1181
+ /**
1182
+ * Shallow-merge a patch into one export row and persist. Returns the merged
1183
+ * record, or null when the id is unknown.
1184
+ */
1185
+ async function patchExport(state, id, patch) {
1186
+ const map = await readExports(state);
1187
+ const existing = map.get(id);
1188
+ if (!existing) return null;
1189
+ const merged = ExportRecordSchema.parse({
1190
+ ...existing,
1191
+ ...patch
1192
+ });
1193
+ map.set(id, merged);
1194
+ await state.set(Object.fromEntries(map));
1195
+ return merged;
1196
+ }
1197
+ /** Read one export row by id, or null. */
1198
+ async function getExportRecord(state, id) {
1199
+ return (await readExports(state)).get(id) ?? null;
1200
+ }
1201
+ /**
1202
+ * List export rows, newest-first, optionally scoped to one device.
1203
+ */
1204
+ async function listExportsFor(state, deviceId) {
1205
+ return [...(await readExports(state)).values()].filter((r) => deviceId === void 0 || r.deviceId === deviceId).toSorted((a, b) => b.createdAt - a.createdAt);
1206
+ }
1207
+ //#endregion
1208
+ //#region src/recorder/addon/export-ffmpeg-args.ts
1209
+ /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
1210
+ * band audio is dropped rather than chained (kept simple + predictable). */
1211
+ var ATEMPO_MIN = .5;
1212
+ var ATEMPO_MAX = 2;
1213
+ var X264 = [
1214
+ "-c:v",
1215
+ "libx264",
1216
+ "-preset",
1217
+ "veryfast",
1218
+ "-pix_fmt",
1219
+ "yuv420p"
1220
+ ];
1221
+ var FASTSTART = ["-movflags", "+faststart"];
1222
+ /** Build the ffmpeg argv for one export render. */
1223
+ function buildExportArgs(input) {
1224
+ const { inputPlaylist, outPath, options } = input;
1225
+ const inArgs = ["-i", inputPlaylist];
1226
+ if (options.timelapse) {
1227
+ const everySec = options.timelapse.everyMs / 1e3;
1228
+ const fps = options.timelapse.outputFps ?? 30;
1229
+ return [
1230
+ ...inArgs,
1231
+ "-vf",
1232
+ `select='not(mod(t,${everySec}))',setpts=N/FRAME_RATE/TB,fps=${fps}`,
1233
+ "-an",
1234
+ ...X264,
1235
+ ...FASTSTART,
1236
+ outPath
1237
+ ];
1238
+ }
1239
+ const speed = options.speed ?? 1;
1240
+ if (speed === 1) return [
1241
+ ...inArgs,
1242
+ "-map",
1243
+ "0",
1244
+ "-c",
1245
+ "copy",
1246
+ ...options.includeAudio ? [] : ["-an"],
1247
+ ...FASTSTART,
1248
+ outPath
1249
+ ];
1250
+ const keepAudio = options.includeAudio && speed >= ATEMPO_MIN && speed <= ATEMPO_MAX;
1251
+ return [
1252
+ ...inArgs,
1253
+ "-filter:v",
1254
+ `setpts=PTS/${speed}`,
1255
+ ...keepAudio ? [
1256
+ "-filter:a",
1257
+ `atempo=${speed}`,
1258
+ "-c:a",
1259
+ "aac"
1260
+ ] : ["-an"],
1261
+ ...X264,
1262
+ ...FASTSTART,
1263
+ outPath
1264
+ ];
1265
+ }
1266
+ //#endregion
1267
+ //#region src/recorder/addon/export-engine.ts
1268
+ /**
1269
+ * Render engine for recording exports.
1270
+ *
1271
+ * A FIFO queue with ONE concurrent ffmpeg render (exports are heavy; serialising
1272
+ * protects the recording node). Each job: resolve the range's local HLS master
1273
+ * playlist → build argv (`buildExportArgs`) → spawn ffmpeg → parse `time=` from
1274
+ * stderr for progress → on exit 0 stat the file and mark `ready`; on non-zero
1275
+ * mark `failed` and remove the partial. `cancel` SIGKILLs an in-flight render.
1276
+ * State transitions patch the DurableState store and emit telemetry events.
1277
+ *
1278
+ * Unlike `SegmentWriter` there is NO auto-restart — a render runs once; boot
1279
+ * re-queue of interrupted jobs is the provider's concern.
1280
+ */
1281
+ var STDERR_TAIL_LINES$1 = 12;
1282
+ var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
1283
+ /** Expected OUTPUT duration (seconds) for progress scaling. */
1284
+ function expectedOutSeconds(rec) {
1285
+ const rangeSec = Math.max(.001, (rec.toMs - rec.fromMs) / 1e3);
1286
+ if (rec.options.timelapse) {
1287
+ const everySec = rec.options.timelapse.everyMs / 1e3;
1288
+ const fps = rec.options.timelapse.outputFps ?? 30;
1289
+ return Math.max(.001, rangeSec / everySec / fps);
1290
+ }
1291
+ return rangeSec / (rec.options.speed ?? 1);
1292
+ }
1293
+ var ExportEngine = class {
1294
+ deps;
1295
+ queue = [];
1296
+ activeId = null;
1297
+ activeProc = null;
1298
+ cancelled = /* @__PURE__ */ new Set();
1299
+ constructor(deps) {
1300
+ this.deps = deps;
1301
+ }
1302
+ /** Queue a persisted `queued` record for rendering. */
1303
+ enqueue(id) {
1304
+ if (this.queue.includes(id) || this.activeId === id) return;
1305
+ this.queue.push(id);
1306
+ this.pump();
1307
+ }
1308
+ /** Abort a queued or in-flight render. */
1309
+ async cancel(id) {
1310
+ this.cancelled.add(id);
1311
+ const idx = this.queue.indexOf(id);
1312
+ if (idx >= 0) this.queue.splice(idx, 1);
1313
+ if (this.activeId === id && this.activeProc) try {
1314
+ this.activeProc.kill("SIGKILL");
1315
+ } catch {}
1316
+ else if (idx >= 0) await this.fail(id, "cancelled");
1317
+ }
1318
+ async pump() {
1319
+ if (this.activeId) return;
1320
+ const id = this.queue.shift();
1321
+ if (!id) return;
1322
+ if (this.cancelled.has(id)) {
1323
+ this.cancelled.delete(id);
1324
+ await this.fail(id, "cancelled");
1325
+ this.pump();
1326
+ return;
1327
+ }
1328
+ this.activeId = id;
1329
+ try {
1330
+ await this.render(id);
1331
+ } catch (err) {
1332
+ await this.fail(id, err instanceof Error ? err.message : String(err));
1333
+ } finally {
1334
+ this.activeId = null;
1335
+ this.activeProc = null;
1336
+ this.pump();
1337
+ }
1338
+ }
1339
+ async render(id) {
1340
+ const rec = await patchExport(this.deps.state, id, {
1341
+ state: "rendering",
1342
+ progressPct: 0
1343
+ });
1344
+ if (!rec) return;
1345
+ const playlist = await this.deps.resolvePlaylist(rec);
1346
+ if (!playlist) {
1347
+ await this.fail(id, "no footage for range");
1348
+ return;
1349
+ }
1350
+ const outPath = this.deps.outPath(rec);
1351
+ const args = buildExportArgs({
1352
+ inputPlaylist: playlist,
1353
+ outPath,
1354
+ options: rec.options
1355
+ });
1356
+ const totalSec = expectedOutSeconds(rec);
1357
+ await new Promise((resolve) => {
1358
+ const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1359
+ this.activeProc = proc;
1360
+ const stderrTail = [];
1361
+ proc.stderr?.on("data", (chunk) => {
1362
+ const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
1363
+ for (const line of text.split("\n")) {
1364
+ const trimmed = line.trim();
1365
+ if (trimmed) {
1366
+ stderrTail.push(trimmed);
1367
+ if (stderrTail.length > STDERR_TAIL_LINES$1) stderrTail.shift();
1368
+ }
1369
+ const m = TIME_RE.exec(line);
1370
+ if (m) {
1371
+ const sec = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
1372
+ const pct = Math.min(99, Math.round(sec / totalSec * 100));
1373
+ patchExport(this.deps.state, id, { progressPct: pct });
1374
+ this.deps.emit(EventCategory.RecordingExportProgress, {
1375
+ exportId: id,
1376
+ progressPct: pct
1377
+ });
1378
+ }
1379
+ }
1380
+ });
1381
+ proc.on("exit", (code) => {
1382
+ this.activeProc = null;
1383
+ this.finish(id, typeof code === "number" ? code : null, outPath, stderrTail).then(resolve);
1384
+ });
1385
+ });
1386
+ }
1387
+ async finish(id, code, outPath, stderrTail) {
1388
+ if (this.cancelled.has(id)) {
1389
+ this.cancelled.delete(id);
1390
+ await this.deps.removeFile(outPath);
1391
+ await this.fail(id, "cancelled");
1392
+ return;
1393
+ }
1394
+ if (code !== 0) {
1395
+ await this.deps.removeFile(outPath);
1396
+ await this.fail(id, `ffmpeg exited ${code}: ${stderrTail.join(" | ")}`);
1397
+ return;
1398
+ }
1399
+ let bytes = 0;
1400
+ try {
1401
+ bytes = await this.deps.statBytes(outPath);
1402
+ } catch (err) {
1403
+ await this.fail(id, err instanceof Error ? err.message : String(err));
1404
+ return;
1405
+ }
1406
+ await patchExport(this.deps.state, id, {
1407
+ state: "ready",
1408
+ progressPct: 100,
1409
+ fileBytes: bytes,
1410
+ error: null
1411
+ });
1412
+ this.deps.emit(EventCategory.RecordingExportCompleted, {
1413
+ exportId: id,
1414
+ fileBytes: bytes
1415
+ });
1416
+ }
1417
+ async fail(id, error) {
1418
+ await patchExport(this.deps.state, id, {
1419
+ state: "failed",
1420
+ progressPct: null,
1421
+ error
1422
+ });
1423
+ this.deps.emit(EventCategory.RecordingExportFailed, {
1424
+ exportId: id,
1425
+ error
1426
+ });
1427
+ }
1428
+ };
1429
+ //#endregion
1430
+ //#region src/recorder/addon/recording-export-provider.ts
1431
+ /**
1432
+ * True when at least one indexed segment overlaps `[fromMs, toMs)`. Mirrors the
1433
+ * `buildPlaybackManifest` overlap idiom: a segment `[startMs, startMs+durMs)`
1434
+ * overlaps the window iff it starts before the window ends AND ends after the
1435
+ * window starts.
1436
+ */
1437
+ function hasFootage(index, deviceId, profile, fromMs, toMs) {
1438
+ return index.segments(deviceId, profile).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
1439
+ }
1440
+ /** Load a record or throw a clear NOT_FOUND-style error. */
1441
+ async function requireRecord(state, exportId) {
1442
+ const rec = await getExportRecord(state, exportId);
1443
+ if (!rec) throw new Error(`recordingExport: unknown export "${exportId}"`);
1444
+ return rec;
1445
+ }
1446
+ /** Build the `recordingExport` provider over the injected deps. */
1447
+ function buildRecordingExportProvider(deps) {
1448
+ return {
1449
+ createExport: async ({ deviceId, profile, fromMs, toMs, options }) => {
1450
+ if (!(fromMs < toMs)) throw new Error(`recordingExport: invalid range ${fromMs}..${toMs}`);
1451
+ if (!hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recordingExport: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
1452
+ const now = deps.now();
1453
+ const record = {
1454
+ id: deps.newId(),
1455
+ deviceId,
1456
+ profile,
1457
+ fromMs,
1458
+ toMs,
1459
+ options,
1460
+ state: "queued",
1461
+ progressPct: null,
1462
+ fileBytes: null,
1463
+ expiresAt: now + options.maxLifeMs,
1464
+ deleteAfterDownload: options.deleteAfterDownload,
1465
+ downloadedAt: null,
1466
+ createdAt: now,
1467
+ createdBy: deps.createdBy(),
1468
+ error: null
1469
+ };
1470
+ const saved = await upsertExport(deps.state, record);
1471
+ deps.engine.enqueue(saved.id);
1472
+ return saved;
1473
+ },
1474
+ listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
1475
+ getExport: ({ exportId }) => requireRecord(deps.state, exportId),
1476
+ cancelExport: async ({ exportId }) => {
1477
+ const existing = await requireRecord(deps.state, exportId);
1478
+ await deps.engine.cancel(exportId);
1479
+ return await getExportRecord(deps.state, exportId) ?? existing;
1480
+ },
1481
+ deleteExport: async ({ exportId }) => {
1482
+ const existing = await requireRecord(deps.state, exportId);
1483
+ await deps.removeFile(deps.outPath(existing));
1484
+ return await patchExport(deps.state, exportId, {
1485
+ state: "deleted",
1486
+ fileBytes: null
1487
+ }) ?? existing;
1488
+ },
1489
+ getDownloadUrl: async ({ exportId }) => {
1490
+ await requireRecord(deps.state, exportId);
1491
+ const url = deps.downloadUrlFor(exportId);
1492
+ return {
1493
+ url,
1494
+ endpoints: [url]
1495
+ };
1496
+ }
1497
+ };
1498
+ }
1499
+ //#endregion
1500
+ //#region src/recorder/addon/export-janitor.ts
1501
+ /** States whose file still exists on disk and can be reclaimed. */
1502
+ var LIVE_STATES = new Set(["ready"]);
1503
+ /**
1504
+ * Delete the files of every export past `expiresAt` and flip them to `expired`
1505
+ * (row kept). Only `ready` exports hold a file; `queued`/`rendering` are left
1506
+ * for the engine, terminal rows are skipped. Returns the expired ids.
1507
+ */
1508
+ async function sweepExpiredExports(now, deps) {
1509
+ const rows = await listExportsFor(deps.state);
1510
+ const expired = [];
1511
+ for (const rec of rows) {
1512
+ if (!LIVE_STATES.has(rec.state)) continue;
1513
+ if (rec.expiresAt > now) continue;
1514
+ try {
1515
+ await deps.removeFile(deps.outPath(rec));
1516
+ } catch {}
1517
+ await patchExport(deps.state, rec.id, {
1518
+ state: "expired",
1519
+ fileBytes: null
1520
+ });
1521
+ expired.push(rec.id);
1522
+ }
1523
+ return { expired };
1524
+ }
1525
+ /**
1526
+ * Record a completed download. Stamps `downloadedAt`; when the export is
1527
+ * `deleteAfterDownload`, removes the file after `graceMs` (protects immediate
1528
+ * client retries) and flips to `deleted`. `scheduleDelete` is injected so the
1529
+ * caller controls timer semantics (real setTimeout in prod, immediate in test).
1530
+ */
1531
+ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
1532
+ const rec = await patchExport(deps.state, id, { downloadedAt: now });
1533
+ if (!rec || !rec.deleteAfterDownload) return;
1534
+ scheduleDelete(() => {
1535
+ (async () => {
1536
+ const current = (await listExportsFor(deps.state)).find((r) => r.id === id);
1537
+ if (!current || current.state !== "ready") return;
1538
+ try {
1539
+ await deps.removeFile(deps.outPath(current));
1540
+ } catch {}
1541
+ await patchExport(deps.state, id, {
1542
+ state: "deleted",
1543
+ fileBytes: null
1544
+ });
1545
+ })();
1546
+ }, graceMs);
1547
+ }
1548
+ //#endregion
1138
1549
  //#region src/recorder/addon/roots.ts
1139
1550
  /**
1140
1551
  * Storage-root resolution + boot hydrate for the recorder addon.
@@ -2072,8 +2483,8 @@ var resolveRecordingHubHostname = resolveHubHostname;
2072
2483
  */
2073
2484
  function buildThumbnailIndex(input) {
2074
2485
  if (input.samples.length === 0) throw new Error("buildThumbnailIndex: no samples");
2075
- const tileWidth = input.tileWidth ?? 320;
2076
- const tileHeight = input.tileHeight ?? 180;
2486
+ const tileWidth = input.tileWidth ?? 480;
2487
+ const tileHeight = input.tileHeight ?? 270;
2077
2488
  const maxCols = input.maxCols ?? 10;
2078
2489
  const ordered = [...input.samples].sort((a, b) => a.t - b.t);
2079
2490
  const geometry = computeSheetGeometry(ordered.length, {
@@ -2226,6 +2637,7 @@ var ThumbnailSink = class {
2226
2637
  }
2227
2638
  async doFlush(deviceId, win) {
2228
2639
  if (win.samples.length === 0) return;
2640
+ const preset = await this.deps.resolvePreset(deviceId);
2229
2641
  const ordered = [...win.samples].sort((a, b) => a.capturedAt - b.capturedAt);
2230
2642
  const built = buildThumbnailIndex({
2231
2643
  deviceId,
@@ -2233,8 +2645,8 @@ var ThumbnailSink = class {
2233
2645
  windowMs: this.deps.windowMs,
2234
2646
  intervalMs: this.deps.intervalMs,
2235
2647
  source: this.deps.source,
2236
- tileWidth: this.deps.tileWidth,
2237
- tileHeight: this.deps.tileHeight,
2648
+ tileWidth: preset.tileWidth,
2649
+ tileHeight: preset.tileHeight,
2238
2650
  maxCols: this.deps.maxCols,
2239
2651
  samples: ordered.map((s) => ({ t: s.capturedAt }))
2240
2652
  });
@@ -2242,7 +2654,8 @@ var ThumbnailSink = class {
2242
2654
  const sheetBytes = await this.deps.compose({
2243
2655
  tiles: ordered.map((s) => s.jpeg),
2244
2656
  placements,
2245
- geometry: built.geometry
2657
+ geometry: built.geometry,
2658
+ quality: preset.quality
2246
2659
  });
2247
2660
  await this.deps.persist({
2248
2661
  deviceId,
@@ -2269,7 +2682,7 @@ async function composeSheet(sharp, input) {
2269
2682
  const entries = (await Promise.all(tiles.map((t) => sharp(t).resize(geometry.tileWidth, geometry.tileHeight, {
2270
2683
  fit: "cover",
2271
2684
  position: "centre"
2272
- }).jpeg({ quality: input.quality ?? 60 }).toBuffer()))).map((buf, i) => {
2685
+ }).jpeg({ quality: input.quality ?? 72 }).toBuffer()))).map((buf, i) => {
2273
2686
  const p = placements[i];
2274
2687
  return {
2275
2688
  input: buf,
@@ -2286,7 +2699,7 @@ async function composeSheet(sharp, input) {
2286
2699
  g: 0,
2287
2700
  b: 0
2288
2701
  }
2289
- } }).composite(entries).jpeg({ quality: input.quality ?? 60 }).toBuffer();
2702
+ } }).composite(entries).jpeg({ quality: input.quality ?? 72 }).toBuffer();
2290
2703
  }
2291
2704
  //#endregion
2292
2705
  //#region src/thumbnails/thumbnail-persister.ts
@@ -2480,6 +2893,22 @@ function planBackfill(input) {
2480
2893
  //#endregion
2481
2894
  //#region src/thumbnails/backfill-ffmpeg.ts
2482
2895
  /**
2896
+ * Map a JPEG quality (higher = better; the scrub presets span ~55..85) onto
2897
+ * ffmpeg's INVERSE `-q:v` scale (2 = best .. 31 = worst). Inverse-linear over
2898
+ * the full JPEG 1..100 domain, monotonic-decreasing (a higher JPEG quality
2899
+ * yields a lower/better ffmpeg q), clamped to [2, 31] for any input.
2900
+ *
2901
+ * These extracted keyframes are INTERMEDIATE — `composeSheet` re-encodes them
2902
+ * into the sheet at the preset's final JPEG quality — so this only governs the
2903
+ * extract's crispness. Anchoring the extract to the same preset keeps a
2904
+ * high-preset camera's backfill from being the bottleneck while a low-preset
2905
+ * camera spends fewer bytes on throwaway intermediates.
2906
+ */
2907
+ function jpegQualityToFfmpegQ(jpegQuality) {
2908
+ const q = Math.round(31 - (Math.min(100, Math.max(1, jpegQuality)) - 1) / 99 * 29);
2909
+ return Math.min(31, Math.max(2, q));
2910
+ }
2911
+ /**
2483
2912
  * ffmpeg args to extract keyframes from one segment as scaled JPEGs. `-skip_frame
2484
2913
  * nokey` + `-vsync 0` (a.k.a. passthrough) keeps only I-frames without
2485
2914
  * duplicating; `-an` drops audio. The caller resolves timestamps from the
@@ -2576,16 +3005,16 @@ var ThumbnailService = class {
2576
3005
  this.sink = new ThumbnailSink({
2577
3006
  windowMs: deps.config.windowMs,
2578
3007
  intervalMs: deps.config.intervalMs,
2579
- tileWidth: 320,
2580
- tileHeight: 180,
2581
3008
  maxCols: 10,
3009
+ resolvePreset: (deviceId) => deps.resolvePreset(deviceId),
2582
3010
  maxTilesPerWindow: MAX_TILES_PER_WINDOW,
2583
3011
  idleGraceMs: IDLE_GRACE_MS,
2584
3012
  source: "sampled",
2585
3013
  compose: (req) => composeSheet(SHARP, {
2586
3014
  tiles: [...req.tiles],
2587
3015
  placements: req.placements,
2588
- geometry: req.geometry
3016
+ geometry: req.geometry,
3017
+ quality: req.quality
2589
3018
  }),
2590
3019
  persist: (req) => this.persist(req),
2591
3020
  logger: deps.logger.child("thumbnail-sink")
@@ -2851,13 +3280,14 @@ var ThumbnailService = class {
2851
3280
  /** Fill ONE window: extract keyframes from its covering low segments, compose
2852
3281
  * a sheet, persist with `source:'backfill'`. */
2853
3282
  async backfillWindow(deviceId, windowStartMs, segments, root) {
3283
+ const preset = await this.deps.resolvePreset(deviceId);
2854
3284
  const workDir = await promises.mkdtemp(path.join(tmpdir(), `csthumb-${deviceId}-`));
2855
3285
  try {
2856
3286
  const samples = [];
2857
3287
  for (const seg of segments) {
2858
3288
  const absSeg = this.resolveSegmentAbs(deviceId, seg.path);
2859
3289
  if (!absSeg) continue;
2860
- const jpegs = await this.extractKeyframes(absSeg, workDir);
3290
+ const jpegs = await this.extractKeyframes(absSeg, workDir, preset);
2861
3291
  const n = jpegs.length;
2862
3292
  for (let i = 0; i < n; i += 1) {
2863
3293
  const t = seg.startMs + Math.round(seg.durMs * i / Math.max(1, n));
@@ -2876,21 +3306,22 @@ var ThumbnailService = class {
2876
3306
  windowMs: this.deps.config.windowMs,
2877
3307
  intervalMs: this.deps.config.intervalMs,
2878
3308
  source: "backfill",
2879
- tileWidth: 320,
2880
- tileHeight: 180,
3309
+ tileWidth: preset.tileWidth,
3310
+ tileHeight: preset.tileHeight,
2881
3311
  maxCols: 10,
2882
3312
  samples: ordered.map((s) => ({ t: s.t }))
2883
3313
  });
2884
3314
  const geometry = computeSheetGeometry(ordered.length, {
2885
- tileWidth: 320,
2886
- tileHeight: 180,
3315
+ tileWidth: preset.tileWidth,
3316
+ tileHeight: preset.tileHeight,
2887
3317
  maxCols: 10
2888
3318
  });
2889
3319
  const placements = allTilePlacements(ordered.length, geometry);
2890
3320
  const sheetBytes = await composeSheet(SHARP, {
2891
3321
  tiles: ordered.map((s) => s.jpeg),
2892
3322
  placements,
2893
- geometry
3323
+ geometry,
3324
+ quality: preset.quality
2894
3325
  });
2895
3326
  await persistThumbnailWindow(root, {
2896
3327
  deviceId,
@@ -2918,11 +3349,12 @@ var ThumbnailService = class {
2918
3349
  }
2919
3350
  /** Run ONE keyframe-only ffmpeg pass over a segment; return the extracted
2920
3351
  * JPEG buffers in order. */
2921
- async extractKeyframes(absSeg, workDir) {
3352
+ async extractKeyframes(absSeg, workDir, preset) {
2922
3353
  const args = buildKeyframeExtractArgs({
2923
3354
  inputPath: absSeg,
2924
3355
  outputPattern: path.join(workDir, "kf-%04d.jpg"),
2925
- tileWidth: 320
3356
+ tileWidth: preset.tileWidth,
3357
+ quality: jpegQualityToFfmpegQ(preset.quality)
2926
3358
  });
2927
3359
  await this.spawnFfmpeg(args);
2928
3360
  const files = (await promises.readdir(workDir)).filter((f) => /^kf-\d+\.jpg$/.test(f)).sort();
@@ -3031,6 +3463,14 @@ var MARKER_PAD_POST_MS = EVENT_PAD_MS.postMs;
3031
3463
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
3032
3464
  var RECORDER_ADDON_ID = "recorder";
3033
3465
  var PLAYBACK_PREFIX = "playback";
3466
+ /** Data-plane prefix for export MP4 downloads → `/addon/recorder/exports/<id>.mp4`. */
3467
+ var EXPORTS_PREFIX = "exports";
3468
+ /** How often the export janitor sweeps expired exports. */
3469
+ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
3470
+ /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
3471
+ var EXPORT_DELETE_GRACE_MS = 6e4;
3472
+ /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
3473
+ var EXPORT_FILENAME_RE = /^([A-Za-z0-9][A-Za-z0-9_-]*)\.mp4$/;
3034
3474
  /** Narrows the `unknown` extras `SegmentStore`'s logger contract accepts down
3035
3475
  * to the structured `{ tags, error }` shape the device-scoped call sites
3036
3476
  * (stat/relocate failures) pass — as opposed to the raw caught error most
@@ -3058,6 +3498,10 @@ var RecorderV2Addon = class extends BaseAddon {
3058
3498
  playbackBaseUrl = null;
3059
3499
  /** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
3060
3500
  thumbnailService = null;
3501
+ /** Render engine for recording exports. Null on non-recording nodes. */
3502
+ exportEngine = null;
3503
+ /** Periodic export-expiry sweep timer. Cleared on shutdown. */
3504
+ exportSweepTimer = null;
3061
3505
  constructor() {
3062
3506
  super({ ...DEFAULT_CONFIG });
3063
3507
  }
@@ -3135,7 +3579,7 @@ var RecorderV2Addon = class extends BaseAddon {
3135
3579
  }),
3136
3580
  brokerCall: makeBrokerCall(brokerHandle)
3137
3581
  });
3138
- const recordingProvider = buildRecordingProvider({
3582
+ const recordingDeps = {
3139
3583
  index: this.index,
3140
3584
  segmentStore: this.segmentStore,
3141
3585
  nodeId: this.nodeId,
@@ -3156,11 +3600,42 @@ var RecorderV2Addon = class extends BaseAddon {
3156
3600
  return true;
3157
3601
  }
3158
3602
  }
3159
- });
3603
+ };
3604
+ const recordingProvider = buildRecordingProvider(recordingDeps);
3160
3605
  const evictableProvider = new StorageEvictableProvider({
3161
3606
  index: this.index,
3162
3607
  store: this.segmentStore
3163
3608
  });
3609
+ const exportsState = this.exportsStore();
3610
+ const exportCodecCache = /* @__PURE__ */ new Map();
3611
+ const exportOutPath = (rec) => path.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`);
3612
+ const removeExportFile = async (p) => {
3613
+ await promises.rm(p, { force: true });
3614
+ };
3615
+ this.exportEngine = new ExportEngine({
3616
+ spawn,
3617
+ logger: this.ctx.logger.child("export"),
3618
+ state: exportsState,
3619
+ resolvePlaylist: async (rec) => {
3620
+ if ((await buildPlaybackManifest(recordingDeps, exportCodecCache, rec.deviceId, rec.fromMs, rec.toMs)).localMasterPath === null) return null;
3621
+ return playbackVariantPlaylistPath(this.ctx.dataDir, rec.deviceId, rec.profile);
3622
+ },
3623
+ outPath: exportOutPath,
3624
+ statBytes: async (p) => (await promises.stat(p)).size,
3625
+ removeFile: removeExportFile,
3626
+ emit: (category, data) => this.emitExportEvent(category, data)
3627
+ });
3628
+ const exportProvider = buildRecordingExportProvider({
3629
+ state: exportsState,
3630
+ engine: this.exportEngine,
3631
+ index: this.index,
3632
+ now: () => Date.now(),
3633
+ newId: () => randomUUID(),
3634
+ createdBy: () => "operator",
3635
+ outPath: exportOutPath,
3636
+ removeFile: removeExportFile,
3637
+ downloadUrlFor: (id) => `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}/${id}.mp4`
3638
+ });
3164
3639
  try {
3165
3640
  const handler = createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
3166
3641
  const served = await this.ctx.dataPlane?.serve({
@@ -3176,6 +3651,19 @@ var RecorderV2Addon = class extends BaseAddon {
3176
3651
  } catch (err) {
3177
3652
  this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: errMsg(err) } });
3178
3653
  }
3654
+ try {
3655
+ const served = await this.ctx.dataPlane?.serve({
3656
+ prefix: EXPORTS_PREFIX,
3657
+ access: "authenticated",
3658
+ handler: (req, res) => this.serveExportDownload(exportsState, exportOutPath, req, res)
3659
+ });
3660
+ this.ctx.logger.info("recorder exports data-plane served", { meta: {
3661
+ clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}` : "(no dataPlane facility)",
3662
+ internal: served?.baseUrl
3663
+ } });
3664
+ } catch (err) {
3665
+ this.ctx.logger.warn("recorder exports data-plane failed to serve", { meta: { error: errMsg(err) } });
3666
+ }
3179
3667
  await this.ensureThumbnailService().serveEarly();
3180
3668
  this.ctx.logger.info("recorder addon started (continuous write path active)", {
3181
3669
  tags: { nodeId: this.nodeId },
@@ -3184,13 +3672,20 @@ var RecorderV2Addon = class extends BaseAddon {
3184
3672
  watchIntervalMs: this.config.watchIntervalMs
3185
3673
  }
3186
3674
  });
3187
- return { providers: [{
3188
- capability: recordingCapability,
3189
- provider: recordingProvider
3190
- }, {
3191
- capability: storageEvictableCapability,
3192
- provider: evictableProvider
3193
- }] };
3675
+ return { providers: [
3676
+ {
3677
+ capability: recordingCapability,
3678
+ provider: recordingProvider
3679
+ },
3680
+ {
3681
+ capability: storageEvictableCapability,
3682
+ provider: evictableProvider
3683
+ },
3684
+ {
3685
+ capability: recordingExportCapability,
3686
+ provider: exportProvider
3687
+ }
3688
+ ] };
3194
3689
  }
3195
3690
  async onShutdown() {
3196
3691
  this.eventUnsub?.();
@@ -3200,6 +3695,11 @@ var RecorderV2Addon = class extends BaseAddon {
3200
3695
  this.segmentStore = null;
3201
3696
  await this.thumbnailService?.stop();
3202
3697
  this.thumbnailService = null;
3698
+ if (this.exportSweepTimer !== null) {
3699
+ clearInterval(this.exportSweepTimer);
3700
+ this.exportSweepTimer = null;
3701
+ }
3702
+ this.exportEngine = null;
3203
3703
  }
3204
3704
  /**
3205
3705
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
@@ -3257,6 +3757,39 @@ var RecorderV2Addon = class extends BaseAddon {
3257
3757
  } catch (err) {
3258
3758
  this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: errMsg(err) } });
3259
3759
  }
3760
+ await this.bootstrapExports();
3761
+ }
3762
+ /**
3763
+ * Resume interrupted exports and arm the expiry sweep. A row left `rendering`
3764
+ * by a crash/restart has no live ffmpeg, so demote it to `queued` and re-enqueue
3765
+ * it alongside any still-`queued` rows. The sweep runs immediately then on an
3766
+ * interval; both are best-effort (a failure never blocks boot).
3767
+ */
3768
+ async bootstrapExports() {
3769
+ const engine = this.exportEngine;
3770
+ if (!engine) return;
3771
+ const state = this.exportsStore();
3772
+ try {
3773
+ for (const rec of await listExportsFor(state)) {
3774
+ if (rec.state === "rendering") await patchExport(state, rec.id, {
3775
+ state: "queued",
3776
+ progressPct: null
3777
+ });
3778
+ if (rec.state === "queued" || rec.state === "rendering") engine.enqueue(rec.id);
3779
+ }
3780
+ } catch (err) {
3781
+ this.ctx.logger.warn("recorder: export boot re-queue failed", { meta: { error: errMsg(err) } });
3782
+ }
3783
+ if (this.exportSweepTimer === null) {
3784
+ const sweep = () => {
3785
+ sweepExpiredExports(Date.now(), this.exportJanitorDeps()).catch((err) => {
3786
+ this.ctx.logger.warn("recorder: export expiry sweep failed", { meta: { error: errMsg(err) } });
3787
+ });
3788
+ };
3789
+ sweep();
3790
+ this.exportSweepTimer = setInterval(sweep, EXPORT_SWEEP_INTERVAL_MS);
3791
+ this.exportSweepTimer.unref?.();
3792
+ }
3260
3793
  }
3261
3794
  /** Construct the thumbnail service once — every dep is a lazy closure, so
3262
3795
  * construction is safe in `onInitialize` (before locations resolve). */
@@ -3268,6 +3801,13 @@ var RecorderV2Addon = class extends BaseAddon {
3268
3801
  locations: () => this.resolvedLocations,
3269
3802
  lowSegments: (deviceId) => this.index.segments(deviceId, "low"),
3270
3803
  recordingDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
3804
+ resolvePreset: async (deviceId) => {
3805
+ try {
3806
+ return resolveScrubThumbnailGeometry((await loadDeviceConfig(this.configStore(), deviceId)).scrubThumbnails);
3807
+ } catch {
3808
+ return resolveScrubThumbnailGeometry(void 0);
3809
+ }
3810
+ },
3271
3811
  config: {
3272
3812
  enabled: this.config.thumbnailsEnabled,
3273
3813
  backfillEnabled: this.config.thumbnailBackfillEnabled,
@@ -3378,6 +3918,121 @@ var RecorderV2Addon = class extends BaseAddon {
3378
3918
  configStore() {
3379
3919
  return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
3380
3920
  }
3921
+ /** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
3922
+ * wrapper as the config store). */
3923
+ exportsStore() {
3924
+ return this.state(RECORDING_EXPORTS_KEY, RecordingExportsBlobSchema, {});
3925
+ }
3926
+ /** Retention deps shared by the sweep and the download-complete hook. */
3927
+ exportJanitorDeps() {
3928
+ return {
3929
+ state: this.exportsStore(),
3930
+ outPath: (rec) => path.join(this.ctx.dataDir, EXPORTS_PREFIX, String(rec.deviceId), `${rec.id}.mp4`),
3931
+ removeFile: async (p) => {
3932
+ await promises.rm(p, { force: true });
3933
+ }
3934
+ };
3935
+ }
3936
+ /** Emit an export telemetry event through the addon event bus (untyped
3937
+ * category — the export categories are telemetry, not in the typed catalog). */
3938
+ emitExportEvent(category, data) {
3939
+ const source = {
3940
+ type: "addon",
3941
+ id: RECORDER_ADDON_ID,
3942
+ nodeId: this.nodeId
3943
+ };
3944
+ this.ctx.eventBus.emit({
3945
+ id: randomUUID(),
3946
+ timestamp: /* @__PURE__ */ new Date(),
3947
+ source,
3948
+ category,
3949
+ data
3950
+ });
3951
+ }
3952
+ /**
3953
+ * Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
3954
+ * row's file (traversal-guarded by the id regex + the exact stored path),
3955
+ * streams it with HTTP `Range`, and — when the FINAL byte of a full-file GET is
3956
+ * flushed — records the download so `deleteAfterDownload` can reclaim the file.
3957
+ */
3958
+ async serveExportDownload(state, outPath, req, res) {
3959
+ if (req.method !== "GET" && req.method !== "HEAD") {
3960
+ res.writeHead(405).end();
3961
+ return;
3962
+ }
3963
+ const urlPath = (req.url ?? "/").split("?")[0] ?? "/";
3964
+ const match = EXPORT_FILENAME_RE.exec(urlPath.replace(/^\/+/, ""));
3965
+ if (!match) {
3966
+ res.writeHead(404).end();
3967
+ return;
3968
+ }
3969
+ const exportId = match[1];
3970
+ const rec = await getExportRecord(state, exportId);
3971
+ if (!rec || rec.state !== "ready") {
3972
+ res.writeHead(404).end();
3973
+ return;
3974
+ }
3975
+ const absPath = outPath(rec);
3976
+ let size;
3977
+ try {
3978
+ const st = await promises.stat(absPath);
3979
+ if (!st.isFile()) {
3980
+ res.writeHead(404).end();
3981
+ return;
3982
+ }
3983
+ size = st.size;
3984
+ } catch {
3985
+ res.writeHead(404).end();
3986
+ return;
3987
+ }
3988
+ const baseHeaders = {
3989
+ "content-type": contentTypeFor(absPath),
3990
+ "accept-ranges": "bytes",
3991
+ "cache-control": "no-cache"
3992
+ };
3993
+ const range = parseRangeHeader(req.headers.range, size);
3994
+ const servesFinalByte = range ? range.end === size - 1 : true;
3995
+ const recordDownloadOnFinish = () => {
3996
+ if (req.method !== "GET" || !servesFinalByte) return;
3997
+ res.once("finish", () => {
3998
+ markDownloaded(exportId, Date.now(), this.exportJanitorDeps(), (fn, ms) => {
3999
+ setTimeout(fn, ms).unref?.();
4000
+ }, EXPORT_DELETE_GRACE_MS).catch((err) => {
4001
+ this.ctx.logger.warn("recorder: markDownloaded failed", { meta: {
4002
+ exportId,
4003
+ error: errMsg(err)
4004
+ } });
4005
+ });
4006
+ });
4007
+ };
4008
+ if (range) {
4009
+ res.writeHead(206, {
4010
+ ...baseHeaders,
4011
+ "content-range": `bytes ${range.start}-${range.end}/${size}`,
4012
+ "content-length": String(range.end - range.start + 1)
4013
+ });
4014
+ if (req.method === "HEAD") {
4015
+ res.end();
4016
+ return;
4017
+ }
4018
+ recordDownloadOnFinish();
4019
+ createReadStream(absPath, {
4020
+ start: range.start,
4021
+ end: range.end
4022
+ }).pipe(res);
4023
+ return;
4024
+ }
4025
+ res.writeHead(200, {
4026
+ ...baseHeaders,
4027
+ "content-length": String(size)
4028
+ });
4029
+ if (req.method === "HEAD") {
4030
+ res.end();
4031
+ return;
4032
+ }
4033
+ recordDownloadOnFinish();
4034
+ createReadStream(absPath).pipe(res);
4035
+ }
3381
4036
  };
3382
4037
  //#endregion
3383
4038
  export { RecorderV2Addon as default };