@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.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +42 -4
- package/dist/detection-pipeline/index.mjs +42 -4
- package/dist/{dist-DKeNH_5z.mjs → dist-D16_dBsr.mjs} +281 -7
- package/dist/{dist-B1u-QN05.js → dist-p_JqOxtk.js} +298 -6
- package/dist/{model-download-service-Cp9f4dk6-0wh7OK3s.mjs → model-download-service-Cp9f4dk6-CwUb5v3Y.mjs} +1 -1
- package/dist/{model-download-service-Cp9f4dk6-Cp_GTKTw.js → model-download-service-Cp9f4dk6-Dv-oFwjN.js} +12 -0
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +4 -4
- package/dist/recorder/index.js +685 -30
- package/dist/recorder/index.mjs +686 -31
- package/dist/{sheet-geometry-BN80zpCa.js → sheet-geometry-BR-ip0CP.js} +2 -2
- package/dist/{sheet-geometry-Bk3fR4s7.mjs → sheet-geometry-DcpuGTCm.mjs} +2 -2
- package/dist/{step-definitions-CPch37vh.js → step-definitions-CoxF5G31.js} +18 -2
- package/dist/{step-definitions-DUB27V8Y.mjs → step-definitions-DxAcpUV-.mjs} +18 -2
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/{hostInit-BLn2Pv8Q.mjs → hostInit-D_dSciqF.mjs} +3 -3
- package/dist/stream-broker/index.js +29 -9
- package/dist/stream-broker/index.mjs +29 -9
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B0_nx15B.js → MaskShapeCanvas-DI4BY7W2-BldL9_Ee.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-B8uM-VeO.js → MotionZonesSettings-NcxxQN8r-BgWqMe4f.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js → PrivacyMaskSettings-APgPLF7p-YBgaXffP.js} +1 -1
- package/embed-dist/assets/index-e0bQ8yep.js +81 -0
- package/embed-dist/index.html +1 -1
- package/package.json +5 -1
- 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
- package/embed-dist/assets/index-W9-H5LTj.js +0 -81
package/dist/recorder/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const require_chunk = require("../chunk-D6vf50IK.js");
|
|
2
|
-
const require_dist = require("../dist-
|
|
3
|
-
const require_sheet_geometry = require("../sheet-geometry-
|
|
4
|
-
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-
|
|
2
|
+
const require_dist = require("../dist-p_JqOxtk.js");
|
|
3
|
+
const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.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.
|
|
@@ -2075,8 +2486,8 @@ var resolveRecordingHubHostname = require_sheet_geometry.resolveHubHostname;
|
|
|
2075
2486
|
*/
|
|
2076
2487
|
function buildThumbnailIndex(input) {
|
|
2077
2488
|
if (input.samples.length === 0) throw new Error("buildThumbnailIndex: no samples");
|
|
2078
|
-
const tileWidth = input.tileWidth ??
|
|
2079
|
-
const tileHeight = input.tileHeight ??
|
|
2489
|
+
const tileWidth = input.tileWidth ?? 480;
|
|
2490
|
+
const tileHeight = input.tileHeight ?? 270;
|
|
2080
2491
|
const maxCols = input.maxCols ?? 10;
|
|
2081
2492
|
const ordered = [...input.samples].sort((a, b) => a.t - b.t);
|
|
2082
2493
|
const geometry = require_sheet_geometry.computeSheetGeometry(ordered.length, {
|
|
@@ -2229,6 +2640,7 @@ var ThumbnailSink = class {
|
|
|
2229
2640
|
}
|
|
2230
2641
|
async doFlush(deviceId, win) {
|
|
2231
2642
|
if (win.samples.length === 0) return;
|
|
2643
|
+
const preset = await this.deps.resolvePreset(deviceId);
|
|
2232
2644
|
const ordered = [...win.samples].sort((a, b) => a.capturedAt - b.capturedAt);
|
|
2233
2645
|
const built = buildThumbnailIndex({
|
|
2234
2646
|
deviceId,
|
|
@@ -2236,8 +2648,8 @@ var ThumbnailSink = class {
|
|
|
2236
2648
|
windowMs: this.deps.windowMs,
|
|
2237
2649
|
intervalMs: this.deps.intervalMs,
|
|
2238
2650
|
source: this.deps.source,
|
|
2239
|
-
tileWidth:
|
|
2240
|
-
tileHeight:
|
|
2651
|
+
tileWidth: preset.tileWidth,
|
|
2652
|
+
tileHeight: preset.tileHeight,
|
|
2241
2653
|
maxCols: this.deps.maxCols,
|
|
2242
2654
|
samples: ordered.map((s) => ({ t: s.capturedAt }))
|
|
2243
2655
|
});
|
|
@@ -2245,7 +2657,8 @@ var ThumbnailSink = class {
|
|
|
2245
2657
|
const sheetBytes = await this.deps.compose({
|
|
2246
2658
|
tiles: ordered.map((s) => s.jpeg),
|
|
2247
2659
|
placements,
|
|
2248
|
-
geometry: built.geometry
|
|
2660
|
+
geometry: built.geometry,
|
|
2661
|
+
quality: preset.quality
|
|
2249
2662
|
});
|
|
2250
2663
|
await this.deps.persist({
|
|
2251
2664
|
deviceId,
|
|
@@ -2272,7 +2685,7 @@ async function composeSheet(sharp, input) {
|
|
|
2272
2685
|
const entries = (await Promise.all(tiles.map((t) => sharp(t).resize(geometry.tileWidth, geometry.tileHeight, {
|
|
2273
2686
|
fit: "cover",
|
|
2274
2687
|
position: "centre"
|
|
2275
|
-
}).jpeg({ quality: input.quality ??
|
|
2688
|
+
}).jpeg({ quality: input.quality ?? 72 }).toBuffer()))).map((buf, i) => {
|
|
2276
2689
|
const p = placements[i];
|
|
2277
2690
|
return {
|
|
2278
2691
|
input: buf,
|
|
@@ -2289,7 +2702,7 @@ async function composeSheet(sharp, input) {
|
|
|
2289
2702
|
g: 0,
|
|
2290
2703
|
b: 0
|
|
2291
2704
|
}
|
|
2292
|
-
} }).composite(entries).jpeg({ quality: input.quality ??
|
|
2705
|
+
} }).composite(entries).jpeg({ quality: input.quality ?? 72 }).toBuffer();
|
|
2293
2706
|
}
|
|
2294
2707
|
//#endregion
|
|
2295
2708
|
//#region src/thumbnails/thumbnail-persister.ts
|
|
@@ -2483,6 +2896,22 @@ function planBackfill(input) {
|
|
|
2483
2896
|
//#endregion
|
|
2484
2897
|
//#region src/thumbnails/backfill-ffmpeg.ts
|
|
2485
2898
|
/**
|
|
2899
|
+
* Map a JPEG quality (higher = better; the scrub presets span ~55..85) onto
|
|
2900
|
+
* ffmpeg's INVERSE `-q:v` scale (2 = best .. 31 = worst). Inverse-linear over
|
|
2901
|
+
* the full JPEG 1..100 domain, monotonic-decreasing (a higher JPEG quality
|
|
2902
|
+
* yields a lower/better ffmpeg q), clamped to [2, 31] for any input.
|
|
2903
|
+
*
|
|
2904
|
+
* These extracted keyframes are INTERMEDIATE — `composeSheet` re-encodes them
|
|
2905
|
+
* into the sheet at the preset's final JPEG quality — so this only governs the
|
|
2906
|
+
* extract's crispness. Anchoring the extract to the same preset keeps a
|
|
2907
|
+
* high-preset camera's backfill from being the bottleneck while a low-preset
|
|
2908
|
+
* camera spends fewer bytes on throwaway intermediates.
|
|
2909
|
+
*/
|
|
2910
|
+
function jpegQualityToFfmpegQ(jpegQuality) {
|
|
2911
|
+
const q = Math.round(31 - (Math.min(100, Math.max(1, jpegQuality)) - 1) / 99 * 29);
|
|
2912
|
+
return Math.min(31, Math.max(2, q));
|
|
2913
|
+
}
|
|
2914
|
+
/**
|
|
2486
2915
|
* ffmpeg args to extract keyframes from one segment as scaled JPEGs. `-skip_frame
|
|
2487
2916
|
* nokey` + `-vsync 0` (a.k.a. passthrough) keeps only I-frames without
|
|
2488
2917
|
* duplicating; `-an` drops audio. The caller resolves timestamps from the
|
|
@@ -2579,16 +3008,16 @@ var ThumbnailService = class {
|
|
|
2579
3008
|
this.sink = new ThumbnailSink({
|
|
2580
3009
|
windowMs: deps.config.windowMs,
|
|
2581
3010
|
intervalMs: deps.config.intervalMs,
|
|
2582
|
-
tileWidth: 320,
|
|
2583
|
-
tileHeight: 180,
|
|
2584
3011
|
maxCols: 10,
|
|
3012
|
+
resolvePreset: (deviceId) => deps.resolvePreset(deviceId),
|
|
2585
3013
|
maxTilesPerWindow: MAX_TILES_PER_WINDOW,
|
|
2586
3014
|
idleGraceMs: IDLE_GRACE_MS,
|
|
2587
3015
|
source: "sampled",
|
|
2588
3016
|
compose: (req) => composeSheet(SHARP, {
|
|
2589
3017
|
tiles: [...req.tiles],
|
|
2590
3018
|
placements: req.placements,
|
|
2591
|
-
geometry: req.geometry
|
|
3019
|
+
geometry: req.geometry,
|
|
3020
|
+
quality: req.quality
|
|
2592
3021
|
}),
|
|
2593
3022
|
persist: (req) => this.persist(req),
|
|
2594
3023
|
logger: deps.logger.child("thumbnail-sink")
|
|
@@ -2854,13 +3283,14 @@ var ThumbnailService = class {
|
|
|
2854
3283
|
/** Fill ONE window: extract keyframes from its covering low segments, compose
|
|
2855
3284
|
* a sheet, persist with `source:'backfill'`. */
|
|
2856
3285
|
async backfillWindow(deviceId, windowStartMs, segments, root) {
|
|
3286
|
+
const preset = await this.deps.resolvePreset(deviceId);
|
|
2857
3287
|
const workDir = await node_fs.promises.mkdtemp(node_path.default.join((0, node_os.tmpdir)(), `csthumb-${deviceId}-`));
|
|
2858
3288
|
try {
|
|
2859
3289
|
const samples = [];
|
|
2860
3290
|
for (const seg of segments) {
|
|
2861
3291
|
const absSeg = this.resolveSegmentAbs(deviceId, seg.path);
|
|
2862
3292
|
if (!absSeg) continue;
|
|
2863
|
-
const jpegs = await this.extractKeyframes(absSeg, workDir);
|
|
3293
|
+
const jpegs = await this.extractKeyframes(absSeg, workDir, preset);
|
|
2864
3294
|
const n = jpegs.length;
|
|
2865
3295
|
for (let i = 0; i < n; i += 1) {
|
|
2866
3296
|
const t = seg.startMs + Math.round(seg.durMs * i / Math.max(1, n));
|
|
@@ -2879,21 +3309,22 @@ var ThumbnailService = class {
|
|
|
2879
3309
|
windowMs: this.deps.config.windowMs,
|
|
2880
3310
|
intervalMs: this.deps.config.intervalMs,
|
|
2881
3311
|
source: "backfill",
|
|
2882
|
-
tileWidth:
|
|
2883
|
-
tileHeight:
|
|
3312
|
+
tileWidth: preset.tileWidth,
|
|
3313
|
+
tileHeight: preset.tileHeight,
|
|
2884
3314
|
maxCols: 10,
|
|
2885
3315
|
samples: ordered.map((s) => ({ t: s.t }))
|
|
2886
3316
|
});
|
|
2887
3317
|
const geometry = require_sheet_geometry.computeSheetGeometry(ordered.length, {
|
|
2888
|
-
tileWidth:
|
|
2889
|
-
tileHeight:
|
|
3318
|
+
tileWidth: preset.tileWidth,
|
|
3319
|
+
tileHeight: preset.tileHeight,
|
|
2890
3320
|
maxCols: 10
|
|
2891
3321
|
});
|
|
2892
3322
|
const placements = require_sheet_geometry.allTilePlacements(ordered.length, geometry);
|
|
2893
3323
|
const sheetBytes = await composeSheet(SHARP, {
|
|
2894
3324
|
tiles: ordered.map((s) => s.jpeg),
|
|
2895
3325
|
placements,
|
|
2896
|
-
geometry
|
|
3326
|
+
geometry,
|
|
3327
|
+
quality: preset.quality
|
|
2897
3328
|
});
|
|
2898
3329
|
await persistThumbnailWindow(root, {
|
|
2899
3330
|
deviceId,
|
|
@@ -2921,11 +3352,12 @@ var ThumbnailService = class {
|
|
|
2921
3352
|
}
|
|
2922
3353
|
/** Run ONE keyframe-only ffmpeg pass over a segment; return the extracted
|
|
2923
3354
|
* JPEG buffers in order. */
|
|
2924
|
-
async extractKeyframes(absSeg, workDir) {
|
|
3355
|
+
async extractKeyframes(absSeg, workDir, preset) {
|
|
2925
3356
|
const args = buildKeyframeExtractArgs({
|
|
2926
3357
|
inputPath: absSeg,
|
|
2927
3358
|
outputPattern: node_path.default.join(workDir, "kf-%04d.jpg"),
|
|
2928
|
-
tileWidth:
|
|
3359
|
+
tileWidth: preset.tileWidth,
|
|
3360
|
+
quality: jpegQualityToFfmpegQ(preset.quality)
|
|
2929
3361
|
});
|
|
2930
3362
|
await this.spawnFfmpeg(args);
|
|
2931
3363
|
const files = (await node_fs.promises.readdir(workDir)).filter((f) => /^kf-\d+\.jpg$/.test(f)).sort();
|
|
@@ -3034,6 +3466,14 @@ var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
|
|
|
3034
3466
|
* form the hub reverse-proxy client path `/addon/recorder/playback`. */
|
|
3035
3467
|
var RECORDER_ADDON_ID = "recorder";
|
|
3036
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$/;
|
|
3037
3477
|
/** Narrows the `unknown` extras `SegmentStore`'s logger contract accepts down
|
|
3038
3478
|
* to the structured `{ tags, error }` shape the device-scoped call sites
|
|
3039
3479
|
* (stat/relocate failures) pass — as opposed to the raw caught error most
|
|
@@ -3061,6 +3501,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3061
3501
|
playbackBaseUrl = null;
|
|
3062
3502
|
/** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
|
|
3063
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;
|
|
3064
3508
|
constructor() {
|
|
3065
3509
|
super({ ...DEFAULT_CONFIG });
|
|
3066
3510
|
}
|
|
@@ -3138,7 +3582,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3138
3582
|
}),
|
|
3139
3583
|
brokerCall: makeBrokerCall(brokerHandle)
|
|
3140
3584
|
});
|
|
3141
|
-
const
|
|
3585
|
+
const recordingDeps = {
|
|
3142
3586
|
index: this.index,
|
|
3143
3587
|
segmentStore: this.segmentStore,
|
|
3144
3588
|
nodeId: this.nodeId,
|
|
@@ -3159,11 +3603,42 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3159
3603
|
return true;
|
|
3160
3604
|
}
|
|
3161
3605
|
}
|
|
3162
|
-
}
|
|
3606
|
+
};
|
|
3607
|
+
const recordingProvider = buildRecordingProvider(recordingDeps);
|
|
3163
3608
|
const evictableProvider = new StorageEvictableProvider({
|
|
3164
3609
|
index: this.index,
|
|
3165
3610
|
store: this.segmentStore
|
|
3166
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
|
+
});
|
|
3167
3642
|
try {
|
|
3168
3643
|
const handler = require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
|
|
3169
3644
|
const served = await this.ctx.dataPlane?.serve({
|
|
@@ -3179,6 +3654,19 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3179
3654
|
} catch (err) {
|
|
3180
3655
|
this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
3181
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
|
+
}
|
|
3182
3670
|
await this.ensureThumbnailService().serveEarly();
|
|
3183
3671
|
this.ctx.logger.info("recorder addon started (continuous write path active)", {
|
|
3184
3672
|
tags: { nodeId: this.nodeId },
|
|
@@ -3187,13 +3675,20 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3187
3675
|
watchIntervalMs: this.config.watchIntervalMs
|
|
3188
3676
|
}
|
|
3189
3677
|
});
|
|
3190
|
-
return { providers: [
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
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
|
+
] };
|
|
3197
3692
|
}
|
|
3198
3693
|
async onShutdown() {
|
|
3199
3694
|
this.eventUnsub?.();
|
|
@@ -3203,6 +3698,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3203
3698
|
this.segmentStore = null;
|
|
3204
3699
|
await this.thumbnailService?.stop();
|
|
3205
3700
|
this.thumbnailService = null;
|
|
3701
|
+
if (this.exportSweepTimer !== null) {
|
|
3702
|
+
clearInterval(this.exportSweepTimer);
|
|
3703
|
+
this.exportSweepTimer = null;
|
|
3704
|
+
}
|
|
3705
|
+
this.exportEngine = null;
|
|
3206
3706
|
}
|
|
3207
3707
|
/**
|
|
3208
3708
|
* Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
|
|
@@ -3260,6 +3760,39 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3260
3760
|
} catch (err) {
|
|
3261
3761
|
this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: require_dist.errMsg(err) } });
|
|
3262
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
|
+
}
|
|
3263
3796
|
}
|
|
3264
3797
|
/** Construct the thumbnail service once — every dep is a lazy closure, so
|
|
3265
3798
|
* construction is safe in `onInitialize` (before locations resolve). */
|
|
@@ -3271,6 +3804,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3271
3804
|
locations: () => this.resolvedLocations,
|
|
3272
3805
|
lowSegments: (deviceId) => this.index.segments(deviceId, "low"),
|
|
3273
3806
|
recordingDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
|
|
3807
|
+
resolvePreset: async (deviceId) => {
|
|
3808
|
+
try {
|
|
3809
|
+
return require_dist.resolveScrubThumbnailGeometry((await loadDeviceConfig(this.configStore(), deviceId)).scrubThumbnails);
|
|
3810
|
+
} catch {
|
|
3811
|
+
return require_dist.resolveScrubThumbnailGeometry(void 0);
|
|
3812
|
+
}
|
|
3813
|
+
},
|
|
3274
3814
|
config: {
|
|
3275
3815
|
enabled: this.config.thumbnailsEnabled,
|
|
3276
3816
|
backfillEnabled: this.config.thumbnailBackfillEnabled,
|
|
@@ -3381,6 +3921,121 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3381
3921
|
configStore() {
|
|
3382
3922
|
return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
|
|
3383
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
|
+
}
|
|
3384
4039
|
};
|
|
3385
4040
|
//#endregion
|
|
3386
4041
|
module.exports = RecorderV2Addon;
|