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