@camstack/addon-pipeline 1.2.274 → 1.2.276
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/{default-detection-model-CAWhHQo2.js → default-detection-model-6lCvep7n.js} +1 -1
- package/dist/{default-detection-model-BXwE-Dvo.mjs → default-detection-model-dQSwHHfx.mjs} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-g5MfpiBh.mjs → dist-C1pCVJk7.mjs} +440 -32
- package/dist/{dist-k6Vu-AhZ.js → dist-NYjn7hkE.js} +469 -31
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-BXsHQH5h.mjs → node--UY0iepi.mjs} +1 -1
- package/dist/{node--u_jUObS.js → node-CVYY8a4y.js} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-Gl3EowL-.mjs → process-memory-BQOfL3Ig.mjs} +1 -1
- package/dist/{process-memory-DN7opum2.js → process-memory-DDvuSEMJ.js} +1 -1
- package/dist/recorder/index.js +572 -40
- package/dist/recorder/index.mjs +572 -40
- package/dist/{segment-demux-js-sYP_jndp.mjs → segment-demux-js-B0_aAzN0.mjs} +1 -1
- package/dist/{segment-demux-js-DHX8o8pI.js → segment-demux-js-DVupBBc4.js} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CEy8IryH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D0A4Ra4h.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BV2tQ1w3.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-MU3_q1MX.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BnLVuoDg.mjs} +1 -1
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-UKNN4qes.mjs → hostInit-C8TnCvWj.mjs} +2 -2
- package/dist/stream-broker/index.js +526 -69
- package/dist/stream-broker/index.mjs +526 -69
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BlXyfeXU.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as RecordingConfigSchema, At as
|
|
2
|
-
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node
|
|
1
|
+
import { A as RecordingConfigSchema, At as recordingSignalCapability, Cn as number, Dn as string, En as record, Gt as errMsg, H as clipExportFailureOf, It as resolveRecordingProfiles, Mt as resolveCreateExportSubject, N as VISIT_MERGE_GAP_MS, Ot as recordingCapability, Pt as resolveLocationMode, Qt as BaseAddon, Rt as storageEvictableCapability, T as OpsLogEntrySchema, Tn as preprocess, Ut as videoclipsCapability, V as clipExportFailure, Vt as stripRetiredBandPreBufferSec, Y as deriveRecordingMode, _n as _enum, _t as modeMayWrite, an as customAction, cn as hydrateSchema, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, et as evaluateSensorEdge, fn as nodePin, g as EVENT_PAD_MS, gn as sleep$1, gt as migrateLegacyDeviceSignalConfig, h as EVENT_OWNER_TYPES, hn as selectAssignedProfileSlots, ht as mayWriteToLocation, i as BatteryStatusSchema, it as exportSubjectOf, j as RecordingSignalStatusSchema, k as RECORDING_EXPORT_MAX_READ_BYTES, kn as EventCategory, kt as recordingExportCapability, lt as isFirstLevelMacroClass, mt as mayReadLocation, nn as DeviceType, nt as evictionPolicyForMode, on as defineCustomActions, ot as gbToBytes, q as deriveBatteryPresence, rt as evictionPolicyOfLocation, tn as DeviceFeature, ut as loadContributionCapability, v as ExportRecordSchema, vn as array, wn as object, yn as boolean, z as batteryCapability, zt as storageOccupancyCapability } from "../dist-C1pCVJk7.mjs";
|
|
2
|
+
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node--UY0iepi.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
4
|
import { d as PREBUFFER_MAX_MS, l as withRecordingIntent, r as RECORDING_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
|
|
5
5
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
@@ -6122,6 +6122,21 @@ function errnoCode(raw) {
|
|
|
6122
6122
|
}
|
|
6123
6123
|
//#endregion
|
|
6124
6124
|
//#region src/recorder/addon/export-engine.ts
|
|
6125
|
+
/**
|
|
6126
|
+
* How long an operator's authorised wake stays spendable.
|
|
6127
|
+
*
|
|
6128
|
+
* One authorised yes is ONE wake (D558 § 5.2 rule 3), and the two ways a job
|
|
6129
|
+
* outlives its yes without anybody meaning it are both real here: it can sit
|
|
6130
|
+
* behind {@link EXPORT_MAX_CONCURRENT} long renders, and it can be re-queued
|
|
6131
|
+
* after a restart. Five minutes is generous against a job measured at 4–6 s
|
|
6132
|
+
* end to end and short enough that a camera is never woken for a tap the
|
|
6133
|
+
* operator has forgotten making.
|
|
6134
|
+
*
|
|
6135
|
+
* Past it the export FAILS with `wake-expired` — a named row the operator can
|
|
6136
|
+
* retry with one tap, which re-asks for the authorisation — rather than
|
|
6137
|
+
* silently waking the camera on a yes from an hour ago.
|
|
6138
|
+
*/
|
|
6139
|
+
var CLIP_WAKE_TTL_MS = 5 * 6e4;
|
|
6125
6140
|
/** Stderr lines retained for the failure LOG. ffmpeg prints one line per failed
|
|
6126
6141
|
* segment before the decisive "Error opening input" summary, so a short tail can
|
|
6127
6142
|
* push the actual cause out of the window (it very nearly did — the line that
|
|
@@ -6130,6 +6145,42 @@ function errnoCode(raw) {
|
|
|
6130
6145
|
var STDERR_TAIL_LINES$1 = 40;
|
|
6131
6146
|
var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
|
|
6132
6147
|
/**
|
|
6148
|
+
* How far a delivered clip's duration may sit from its catalog span before it
|
|
6149
|
+
* is worth a line. A remux rounds, a GOP boundary moves a fraction of a
|
|
6150
|
+
* second; two seconds is past both and well under D568's own "materially
|
|
6151
|
+
* shorter" retry threshold, so this reports a disagreement the PRODUCER
|
|
6152
|
+
* already decided to serve rather than second-guessing its completion rule.
|
|
6153
|
+
*/
|
|
6154
|
+
var CLIP_DURATION_TOLERANCE_MS = 2e3;
|
|
6155
|
+
/**
|
|
6156
|
+
* The source id of the recorder's OWN `videoclips` provider — a visit over
|
|
6157
|
+
* footage we wrote, not a file a camera holds (D560).
|
|
6158
|
+
*
|
|
6159
|
+
* Duplicating the literal rather than importing `videoclips-provider.ts`
|
|
6160
|
+
* keeps the engine free of the provider (and of the analytics event reads it
|
|
6161
|
+
* carries); the two are pinned together by
|
|
6162
|
+
* `export-engine-clip-fetch.spec.ts`.
|
|
6163
|
+
*/
|
|
6164
|
+
var ANALYTICS_CLIP_SOURCE = "analytics";
|
|
6165
|
+
/**
|
|
6166
|
+
* Read a producer's refusal as one of the named clip-export failures.
|
|
6167
|
+
*
|
|
6168
|
+
* A producer that speaks the vocabulary is believed — `clipExportFailureOf`
|
|
6169
|
+
* recovers its token, so a `sleeping` stays `sleeping` all the way to the
|
|
6170
|
+
* modal. Anything else is `fetch-failed` **with the producer's words kept
|
|
6171
|
+
* verbatim**: inventing a more specific token from prose is how a surface's
|
|
6172
|
+
* table came to render every refusal on this fleet as `Refused: <prose>`
|
|
6173
|
+
* (ClipStillRefusalCode's own docblock). The one exception is the size bound,
|
|
6174
|
+
* which the producer states in numbers rather than in a token, and which the
|
|
6175
|
+
* operator must be able to tell from a network failure.
|
|
6176
|
+
*/
|
|
6177
|
+
function classifyClipFetchFailure(detail) {
|
|
6178
|
+
const spoken = clipExportFailureOf(detail);
|
|
6179
|
+
if (spoken !== null) return spoken;
|
|
6180
|
+
if (/too large/i.test(detail)) return "too-large-to-transfer";
|
|
6181
|
+
return "fetch-failed";
|
|
6182
|
+
}
|
|
6183
|
+
/**
|
|
6133
6184
|
* The floor under which an MP4 cannot hold a single decodable frame.
|
|
6134
6185
|
*
|
|
6135
6186
|
* **Exit 0 is not the same question as "the file holds a video".** A timelapse
|
|
@@ -6211,6 +6262,13 @@ var ExportEngine = class {
|
|
|
6211
6262
|
}
|
|
6212
6263
|
}
|
|
6213
6264
|
async render(id) {
|
|
6265
|
+
const existing = await getExportRecord(this.deps.state, id);
|
|
6266
|
+
if (!existing) return;
|
|
6267
|
+
const subject = exportSubjectOf(existing);
|
|
6268
|
+
if (subject.kind === "clip" && subject.source !== ANALYTICS_CLIP_SOURCE) {
|
|
6269
|
+
await this.fetchClip(id, existing, subject);
|
|
6270
|
+
return;
|
|
6271
|
+
}
|
|
6214
6272
|
const rec = await patchExport(this.deps.state, id, {
|
|
6215
6273
|
state: "rendering",
|
|
6216
6274
|
progressPct: 0
|
|
@@ -6239,6 +6297,187 @@ var ExportEngine = class {
|
|
|
6239
6297
|
}
|
|
6240
6298
|
}
|
|
6241
6299
|
/**
|
|
6300
|
+
* The CLIP branch: one by-handle fetch, under a bound, written straight to
|
|
6301
|
+
* the export path. No playlist, no ffmpeg, no second completion rule.
|
|
6302
|
+
*
|
|
6303
|
+
* The recorder cannot produce these bytes itself — talking to the camera is
|
|
6304
|
+
* the owning addon's monopoly (one session per host, the sleep gate before
|
|
6305
|
+
* `getApi()`, a cmd-5 transfer whose completion is an idle window proved
|
|
6306
|
+
* against the catalog span, D549 17 / D568) — and there is no addon→addon
|
|
6307
|
+
* byte transport, so the producer's own cap read is the seam (D558 § 3).
|
|
6308
|
+
*
|
|
6309
|
+
* `progressPct` stays `null` for the whole fetch. The job is measured at
|
|
6310
|
+
* 4–6 s end to end and only two of its phases have a real denominator; a
|
|
6311
|
+
* ramp would be a number nothing measured (D558 § 5.1).
|
|
6312
|
+
*
|
|
6313
|
+
* Every exit is named. A branch that accepts work and produces nothing is
|
|
6314
|
+
* the branch this repo keeps losing work in (D391), and a clip export has
|
|
6315
|
+
* five such branches a footage render does not have.
|
|
6316
|
+
*/
|
|
6317
|
+
async fetchClip(id, rec, subject) {
|
|
6318
|
+
const tags = { deviceId: rec.deviceId };
|
|
6319
|
+
const now = (this.deps.now ?? Date.now)();
|
|
6320
|
+
if (subject.wake !== void 0 && now - rec.createdAt > 3e5) {
|
|
6321
|
+
const waitedMs = now - rec.createdAt;
|
|
6322
|
+
this.deps.logger.warn("clip export refused: it outlived the wake it was granted", {
|
|
6323
|
+
tags,
|
|
6324
|
+
meta: {
|
|
6325
|
+
exportId: id,
|
|
6326
|
+
clipId: subject.clipId,
|
|
6327
|
+
waitedMs,
|
|
6328
|
+
ttlMs: CLIP_WAKE_TTL_MS
|
|
6329
|
+
}
|
|
6330
|
+
});
|
|
6331
|
+
await this.fail(id, clipExportFailure("wake-expired", `queued ${Math.round(waitedMs / 1e3)}s ago, past the ${Math.round(CLIP_WAKE_TTL_MS / 1e3)}s an authorised wake stays spendable — ask again to wake the camera`));
|
|
6332
|
+
return;
|
|
6333
|
+
}
|
|
6334
|
+
const fetchClipBytes = this.deps.fetchClipBytes;
|
|
6335
|
+
const writeExportFile = this.deps.writeExportFile;
|
|
6336
|
+
if (fetchClipBytes === void 0 || writeExportFile === void 0) {
|
|
6337
|
+
this.deps.logger.error("clip export refused: this recorder has no clip byte reader wired", {
|
|
6338
|
+
tags,
|
|
6339
|
+
meta: {
|
|
6340
|
+
exportId: id,
|
|
6341
|
+
clipId: subject.clipId
|
|
6342
|
+
}
|
|
6343
|
+
});
|
|
6344
|
+
await this.fail(id, clipExportFailure("fetch-failed", "this recorder has no clip byte reader wired"));
|
|
6345
|
+
return;
|
|
6346
|
+
}
|
|
6347
|
+
await patchExport(this.deps.state, id, {
|
|
6348
|
+
state: "rendering",
|
|
6349
|
+
progressPct: null
|
|
6350
|
+
});
|
|
6351
|
+
this.deps.logger.info("clip export fetch starting", {
|
|
6352
|
+
tags,
|
|
6353
|
+
meta: {
|
|
6354
|
+
exportId: id,
|
|
6355
|
+
clipId: subject.clipId,
|
|
6356
|
+
source: subject.source,
|
|
6357
|
+
provider: subject.provider,
|
|
6358
|
+
profile: subject.profile,
|
|
6359
|
+
wake: subject.wake ?? "none"
|
|
6360
|
+
}
|
|
6361
|
+
});
|
|
6362
|
+
let answer;
|
|
6363
|
+
try {
|
|
6364
|
+
answer = await fetchClipBytes({
|
|
6365
|
+
deviceId: subject.deviceId,
|
|
6366
|
+
clipId: subject.clipId,
|
|
6367
|
+
profile: subject.profile,
|
|
6368
|
+
maxBytes: RECORDING_EXPORT_MAX_READ_BYTES,
|
|
6369
|
+
...subject.wake !== void 0 ? { wake: subject.wake } : {}
|
|
6370
|
+
});
|
|
6371
|
+
} catch (err) {
|
|
6372
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
6373
|
+
const code = classifyClipFetchFailure(detail);
|
|
6374
|
+
this.deps.logger.warn("clip export fetch failed", {
|
|
6375
|
+
tags,
|
|
6376
|
+
meta: {
|
|
6377
|
+
exportId: id,
|
|
6378
|
+
clipId: subject.clipId,
|
|
6379
|
+
reason: code,
|
|
6380
|
+
error: detail
|
|
6381
|
+
}
|
|
6382
|
+
});
|
|
6383
|
+
await this.fail(id, clipExportFailure(code, detail));
|
|
6384
|
+
return;
|
|
6385
|
+
}
|
|
6386
|
+
if (this.cancelled.has(id)) {
|
|
6387
|
+
this.cancelled.delete(id);
|
|
6388
|
+
await this.fail(id, "cancelled");
|
|
6389
|
+
return;
|
|
6390
|
+
}
|
|
6391
|
+
const bytes = Buffer.from(answer.base64, "base64");
|
|
6392
|
+
if (bytes.byteLength > 52428800) {
|
|
6393
|
+
this.deps.logger.warn("clip export refused: the answer exceeded the transfer bound", {
|
|
6394
|
+
tags,
|
|
6395
|
+
meta: {
|
|
6396
|
+
exportId: id,
|
|
6397
|
+
bytes: bytes.byteLength,
|
|
6398
|
+
maxBytes: RECORDING_EXPORT_MAX_READ_BYTES
|
|
6399
|
+
}
|
|
6400
|
+
});
|
|
6401
|
+
await this.fail(id, clipExportFailure("too-large-to-transfer", `${bytes.byteLength} > ${RECORDING_EXPORT_MAX_READ_BYTES} bytes`));
|
|
6402
|
+
return;
|
|
6403
|
+
}
|
|
6404
|
+
if (bytes.byteLength < 1024) {
|
|
6405
|
+
this.deps.logger.error("clip export produced no video", {
|
|
6406
|
+
tags,
|
|
6407
|
+
meta: {
|
|
6408
|
+
exportId: id,
|
|
6409
|
+
clipId: subject.clipId,
|
|
6410
|
+
bytes: bytes.byteLength,
|
|
6411
|
+
minBytes: EXPORT_MIN_PLAUSIBLE_BYTES
|
|
6412
|
+
}
|
|
6413
|
+
});
|
|
6414
|
+
await this.fail(id, clipExportFailure("fetch-failed", `the source answered ${bytes.byteLength} bytes, under the ${EXPORT_MIN_PLAUSIBLE_BYTES} a playable MP4 needs`));
|
|
6415
|
+
return;
|
|
6416
|
+
}
|
|
6417
|
+
const outPath = this.deps.outPath(rec);
|
|
6418
|
+
try {
|
|
6419
|
+
await writeExportFile(outPath, bytes);
|
|
6420
|
+
} catch (err) {
|
|
6421
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
6422
|
+
this.deps.logger.error("clip export could not be written", {
|
|
6423
|
+
tags,
|
|
6424
|
+
meta: {
|
|
6425
|
+
exportId: id,
|
|
6426
|
+
outPath,
|
|
6427
|
+
error: detail
|
|
6428
|
+
}
|
|
6429
|
+
});
|
|
6430
|
+
await this.deps.removeFile(outPath);
|
|
6431
|
+
await this.fail(id, clipExportFailure("fetch-failed", `could not write the export: ${detail}`));
|
|
6432
|
+
return;
|
|
6433
|
+
}
|
|
6434
|
+
const servedPatch = answer.served !== rec.profile ? { profile: answer.served } : {};
|
|
6435
|
+
if (answer.served !== rec.profile) this.deps.logger.warn("clip export served a different twin than the catalog projected", {
|
|
6436
|
+
tags,
|
|
6437
|
+
meta: {
|
|
6438
|
+
exportId: id,
|
|
6439
|
+
clipId: subject.clipId,
|
|
6440
|
+
asked: subject.profile,
|
|
6441
|
+
projected: rec.profile,
|
|
6442
|
+
served: answer.served
|
|
6443
|
+
}
|
|
6444
|
+
});
|
|
6445
|
+
const claimedMs = rec.toMs - rec.fromMs;
|
|
6446
|
+
if (answer.durationMs !== void 0 && Math.abs(answer.durationMs - claimedMs) > CLIP_DURATION_TOLERANCE_MS) this.deps.logger.warn("clip export duration disagrees with the catalog row", {
|
|
6447
|
+
tags,
|
|
6448
|
+
meta: {
|
|
6449
|
+
exportId: id,
|
|
6450
|
+
clipId: subject.clipId,
|
|
6451
|
+
catalogSpanMs: claimedMs,
|
|
6452
|
+
deliveredMs: answer.durationMs
|
|
6453
|
+
}
|
|
6454
|
+
});
|
|
6455
|
+
await patchExport(this.deps.state, id, {
|
|
6456
|
+
state: "ready",
|
|
6457
|
+
progressPct: 100,
|
|
6458
|
+
fileBytes: bytes.byteLength,
|
|
6459
|
+
error: null,
|
|
6460
|
+
...servedPatch
|
|
6461
|
+
});
|
|
6462
|
+
this.deps.logger.info("clip export ready", {
|
|
6463
|
+
tags,
|
|
6464
|
+
meta: {
|
|
6465
|
+
exportId: id,
|
|
6466
|
+
clipId: subject.clipId,
|
|
6467
|
+
outPath,
|
|
6468
|
+
bytes: bytes.byteLength,
|
|
6469
|
+
served: answer.served
|
|
6470
|
+
}
|
|
6471
|
+
});
|
|
6472
|
+
this.deps.emit(EventCategory.RecordingExportCompleted, {
|
|
6473
|
+
exportId: id,
|
|
6474
|
+
fileBytes: bytes.byteLength,
|
|
6475
|
+
deviceId: rec.deviceId,
|
|
6476
|
+
...rec.options.title !== void 0 ? { title: rec.options.title } : {},
|
|
6477
|
+
...rec.options.notifyTargetIds !== void 0 && rec.options.notifyTargetIds.length > 0 ? { notifyTargetIds: rec.options.notifyTargetIds } : {}
|
|
6478
|
+
});
|
|
6479
|
+
}
|
|
6480
|
+
/**
|
|
6242
6481
|
* The record's options with any dense overlay moved onto ffmpeg's clock.
|
|
6243
6482
|
*
|
|
6244
6483
|
* The translation is reported on EVERY render that carries an overlay, not
|
|
@@ -6674,6 +6913,22 @@ function isInterrupted(record) {
|
|
|
6674
6913
|
return record.state === "queued" || record.state === "rendering";
|
|
6675
6914
|
}
|
|
6676
6915
|
/**
|
|
6916
|
+
* Did this row carry an operator's authorised WAKE?
|
|
6917
|
+
*
|
|
6918
|
+
* A re-queue is the third of the three ways D558 § 5.2 rule 3 names for
|
|
6919
|
+
* spending one yes twice, and it is the quietest: the row reaches the fetch
|
|
6920
|
+
* hours after the tap, wakes the camera, and nothing in the log says the
|
|
6921
|
+
* operator did not ask for it. So a clip export holding a wake is FAILED by
|
|
6922
|
+
* name instead — a row he can retry with one tap, which re-asks for the
|
|
6923
|
+
* authorisation. A clip export WITHOUT a wake is re-queued like any render:
|
|
6924
|
+
* there is nothing to spend, and a sleeping camera refuses at its own gate
|
|
6925
|
+
* exactly as it did the first time.
|
|
6926
|
+
*/
|
|
6927
|
+
function carriesAnAuthorisedWake(record) {
|
|
6928
|
+
const subject = exportSubjectOf(record);
|
|
6929
|
+
return subject.kind === "clip" && subject.wake !== void 0;
|
|
6930
|
+
}
|
|
6931
|
+
/**
|
|
6677
6932
|
* Hand every interrupted row back to the engine, once, at boot.
|
|
6678
6933
|
*
|
|
6679
6934
|
* A row past its `expiresAt` is FAILED rather than re-rendered: hours of
|
|
@@ -6701,6 +6956,19 @@ async function reconcileInterruptedExports(deps) {
|
|
|
6701
6956
|
toMs: row.toMs,
|
|
6702
6957
|
createdAt: row.createdAt
|
|
6703
6958
|
};
|
|
6959
|
+
if (carriesAnAuthorisedWake(row)) {
|
|
6960
|
+
expired += 1;
|
|
6961
|
+
await patchExport(deps.state, row.id, {
|
|
6962
|
+
state: "failed",
|
|
6963
|
+
progressPct: null,
|
|
6964
|
+
error: clipExportFailure("wake-expired", "interrupted by a restart — one authorised wake is one wake, so ask again to wake the camera")
|
|
6965
|
+
});
|
|
6966
|
+
deps.logger.warn("clip export abandoned: it was interrupted while holding a wake", {
|
|
6967
|
+
tags,
|
|
6968
|
+
meta
|
|
6969
|
+
});
|
|
6970
|
+
continue;
|
|
6971
|
+
}
|
|
6704
6972
|
if (row.expiresAt <= now) {
|
|
6705
6973
|
expired += 1;
|
|
6706
6974
|
await patchExport(deps.state, row.id, {
|
|
@@ -13523,6 +13791,73 @@ function clusterFootageVisits(ranges, gapMs = VISIT_MERGE_GAP_MS) {
|
|
|
13523
13791
|
visits.push(finishVisit(pieces, endMs));
|
|
13524
13792
|
return visits;
|
|
13525
13793
|
}
|
|
13794
|
+
/**
|
|
13795
|
+
* A visit per BURST OF EVENTS, clamped to the footage that exists behind it.
|
|
13796
|
+
*
|
|
13797
|
+
* ## Why this replaced clustering the availability
|
|
13798
|
+
*
|
|
13799
|
+
* {@link VISIT_MERGE_GAP_MS}'s own docblock states the claim: 30 s "glues
|
|
13800
|
+
* those intra-visit holes without joining visits hours apart". It was applied
|
|
13801
|
+
* to the wrong input. {@link clusterFootageVisits} clusters AVAILABILITY, and
|
|
13802
|
+
* availability only has gaps on an EVENTS-MODE camera, where the writer opens
|
|
13803
|
+
* and closes around activity. A camera that records continuously has none —
|
|
13804
|
+
* so the visit became the recording session.
|
|
13805
|
+
*
|
|
13806
|
+
* Measured on 592, 2026-09-21, 24 h: seven rows covering the whole day, one of
|
|
13807
|
+
* them 7 h 58 m. Clustering the same day's EVENTS at the same 30 s gives 46
|
|
13808
|
+
* visits overnight and 36 in the day, median 17 s, longest 99 s. The constant
|
|
13809
|
+
* was right; the input was not. No duration cap: it would split by the clock
|
|
13810
|
+
* rather than by what happened, and nothing here needs splitting that way.
|
|
13811
|
+
*
|
|
13812
|
+
* ## What the footage is still for
|
|
13813
|
+
*
|
|
13814
|
+
* It CLAMPS, it does not define. A visit keeps only the pieces it actually
|
|
13815
|
+
* covers, because {@link stillInstantMs} takes the midpoint of the longest
|
|
13816
|
+
* piece — handed the whole session's range it would put the still hours from
|
|
13817
|
+
* the event. A burst with no footage behind it is dropped: an event the
|
|
13818
|
+
* recorder never kept is not a clip, and listing it is a row that opens onto
|
|
13819
|
+
* nothing.
|
|
13820
|
+
*/
|
|
13821
|
+
function clusterEventVisits(events, ranges, gapMs, pad) {
|
|
13822
|
+
if (events.length === 0 || ranges.length === 0) return [];
|
|
13823
|
+
const times = [...events].map((e) => e.timestamp).toSorted((a, b) => a - b);
|
|
13824
|
+
const sortedRanges = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
|
|
13825
|
+
const bursts = [];
|
|
13826
|
+
let first = times[0];
|
|
13827
|
+
let last = times[0];
|
|
13828
|
+
for (let i = 1; i < times.length; i++) {
|
|
13829
|
+
const t = times[i];
|
|
13830
|
+
if (t - last <= gapMs) {
|
|
13831
|
+
last = t;
|
|
13832
|
+
continue;
|
|
13833
|
+
}
|
|
13834
|
+
bursts.push({
|
|
13835
|
+
startMs: first - pad.preMs,
|
|
13836
|
+
endMs: last + pad.postMs
|
|
13837
|
+
});
|
|
13838
|
+
first = t;
|
|
13839
|
+
last = t;
|
|
13840
|
+
}
|
|
13841
|
+
bursts.push({
|
|
13842
|
+
startMs: first - pad.preMs,
|
|
13843
|
+
endMs: last + pad.postMs
|
|
13844
|
+
});
|
|
13845
|
+
const visits = [];
|
|
13846
|
+
for (const burst of bursts) {
|
|
13847
|
+
const pieces = [];
|
|
13848
|
+
for (const range of sortedRanges) {
|
|
13849
|
+
const startMs = Math.max(range.startMs, burst.startMs);
|
|
13850
|
+
const endMs = Math.min(range.endMs, burst.endMs);
|
|
13851
|
+
if (endMs > startMs) pieces.push({
|
|
13852
|
+
startMs,
|
|
13853
|
+
endMs
|
|
13854
|
+
});
|
|
13855
|
+
}
|
|
13856
|
+
if (pieces.length === 0) continue;
|
|
13857
|
+
visits.push(finishVisit(pieces, pieces[pieces.length - 1].endMs));
|
|
13858
|
+
}
|
|
13859
|
+
return visits;
|
|
13860
|
+
}
|
|
13526
13861
|
function finishVisit(pieces, endMs) {
|
|
13527
13862
|
const holes = [];
|
|
13528
13863
|
for (let i = 1; i < pieces.length; i++) {
|
|
@@ -13697,13 +14032,17 @@ function eventsToClips(events, pad, vouched) {
|
|
|
13697
14032
|
...events.audio.map((e) => make(e, "audio"))
|
|
13698
14033
|
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
13699
14034
|
}
|
|
13700
|
-
/** Every
|
|
13701
|
-
function
|
|
14035
|
+
/** Every event in the three buckets, in the order the composers tag them. */
|
|
14036
|
+
function allEvents(events) {
|
|
13702
14037
|
return [
|
|
13703
14038
|
...events.motion,
|
|
13704
14039
|
...events.object,
|
|
13705
14040
|
...events.audio
|
|
13706
|
-
]
|
|
14041
|
+
];
|
|
14042
|
+
}
|
|
14043
|
+
/** Every id in the three buckets, in the order the composers tag them. */
|
|
14044
|
+
function allEventIds(events) {
|
|
14045
|
+
return allEvents(events).map((e) => e.id);
|
|
13707
14046
|
}
|
|
13708
14047
|
/**
|
|
13709
14048
|
* Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
|
|
@@ -13820,11 +14159,12 @@ function createVideoclipsProvider(deps) {
|
|
|
13820
14159
|
}
|
|
13821
14160
|
let clips = eventsToClips(events, deps.defaultPad, vouched);
|
|
13822
14161
|
if (deps.fetchAvailability) try {
|
|
13823
|
-
|
|
14162
|
+
const ranges = await deps.fetchAvailability({
|
|
13824
14163
|
deviceId,
|
|
13825
14164
|
since: readSince,
|
|
13826
14165
|
until
|
|
13827
|
-
})
|
|
14166
|
+
});
|
|
14167
|
+
clips = visitsToClips(clusterEventVisits(allEvents(events), ranges, VISIT_MERGE_GAP_MS, deps.defaultPad), events, deps.defaultPad, vouched);
|
|
13828
14168
|
} catch (err) {
|
|
13829
14169
|
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
13830
14170
|
deviceId,
|
|
@@ -13870,6 +14210,20 @@ function createVideoclipsProvider(deps) {
|
|
|
13870
14210
|
}),
|
|
13871
14211
|
format: "hls"
|
|
13872
14212
|
};
|
|
14213
|
+
},
|
|
14214
|
+
/**
|
|
14215
|
+
* This source has no bytes to hand over, and says so rather than
|
|
14216
|
+
* inventing a path to them.
|
|
14217
|
+
*
|
|
14218
|
+
* An `analytics` clip is a time WINDOW over footage the recorder already
|
|
14219
|
+
* owns — there is no file behind it, and a caller that wants it as one
|
|
14220
|
+
* asks for it as an EXPORT, where the source playlist and
|
|
14221
|
+
* `export-dense-map` handle the holes a visit spans by construction
|
|
14222
|
+
* (D558 § 5.4.3). Reading it here would be this addon fetching from
|
|
14223
|
+
* itself, over a cap channel, in base64.
|
|
14224
|
+
*/
|
|
14225
|
+
readClipBytes: async ({ clipId }) => {
|
|
14226
|
+
throw new Error(`videoclips: "${clipId}" is an analytics visit — a window over recorded footage with no file behind it. Export it through recordingExport with the same clip subject; the recorder renders it from its own segments.`);
|
|
13873
14227
|
}
|
|
13874
14228
|
};
|
|
13875
14229
|
}
|
|
@@ -17275,6 +17629,81 @@ function makeBrokerCall(handle) {
|
|
|
17275
17629
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
17276
17630
|
}
|
|
17277
17631
|
//#endregion
|
|
17632
|
+
//#region src/recorder/addon/export-clip-catalog.ts
|
|
17633
|
+
/**
|
|
17634
|
+
* Catalog page size for the confirm.
|
|
17635
|
+
*
|
|
17636
|
+
* One local day is the window (`ExportClipSubject.catalogWindow`) and a busy
|
|
17637
|
+
* day on 592 measured 546 rows, so 1 000 holds a day with room and still
|
|
17638
|
+
* bounds a pathological camera. A truncated page would read as "this clip is
|
|
17639
|
+
* gone", which is the one answer this gate must never give wrongly.
|
|
17640
|
+
*/
|
|
17641
|
+
var EXPORT_CLIP_CATALOG_LIMIT = 1e3;
|
|
17642
|
+
/**
|
|
17643
|
+
* Which twin this row can actually serve for the asked profile.
|
|
17644
|
+
*
|
|
17645
|
+
* The mapping is the one D549 15 already fixed — `low | mid` → the sub file,
|
|
17646
|
+
* `high` → the main twin — and both ids are resolved at LISTING time, so this
|
|
17647
|
+
* never re-searches the camera. A row that holds only one twin answers with
|
|
17648
|
+
* the one it has and the caller SAYS so: the ask stays on `subject.profile`
|
|
17649
|
+
* and this lands on the record's top-level `profile`, so a week later the row
|
|
17650
|
+
* still tells the operator he was handed the sub stream.
|
|
17651
|
+
*
|
|
17652
|
+
* A row with no `streams` block at all is not a fallback and not a guess: the
|
|
17653
|
+
* ask is carried through and the producer's own `served` is what corrects it.
|
|
17654
|
+
*/
|
|
17655
|
+
function servedTwinFor(clip, asked) {
|
|
17656
|
+
const streams = clip.streams;
|
|
17657
|
+
if (streams === void 0) return asked;
|
|
17658
|
+
const hasMain = streams.main !== void 0;
|
|
17659
|
+
const hasSub = streams.sub !== void 0;
|
|
17660
|
+
if (asked === "high") return hasMain ? "high" : hasSub ? "low" : asked;
|
|
17661
|
+
return hasSub ? asked : hasMain ? "high" : asked;
|
|
17662
|
+
}
|
|
17663
|
+
/**
|
|
17664
|
+
* Judge one catalog answer for one clip subject. Pure — the I/O is the
|
|
17665
|
+
* caller's, so every refusal below is unit-testable without a camera.
|
|
17666
|
+
*/
|
|
17667
|
+
function judgeClipRow(subject, rows) {
|
|
17668
|
+
const clip = rows.find((row) => row.id === subject.clipId);
|
|
17669
|
+
if (clip === void 0) return {
|
|
17670
|
+
ok: false,
|
|
17671
|
+
refusal: {
|
|
17672
|
+
code: "catalog-miss",
|
|
17673
|
+
detail: `"${subject.sourceLabel}" does not list ${subject.clipId} between ${subject.catalogWindow.sinceMs} and ${subject.catalogWindow.untilMs}`
|
|
17674
|
+
}
|
|
17675
|
+
};
|
|
17676
|
+
if (clip.playable === false) return {
|
|
17677
|
+
ok: false,
|
|
17678
|
+
refusal: {
|
|
17679
|
+
code: "no-file-for-window",
|
|
17680
|
+
detail: clip.unplayableReason ?? "no bytes"
|
|
17681
|
+
}
|
|
17682
|
+
};
|
|
17683
|
+
if (clip.inProgress === true) return {
|
|
17684
|
+
ok: false,
|
|
17685
|
+
refusal: {
|
|
17686
|
+
code: "clip-in-progress",
|
|
17687
|
+
detail: "the camera is still writing this recording, so it has no end to export yet"
|
|
17688
|
+
}
|
|
17689
|
+
};
|
|
17690
|
+
const { startMs, endMs } = clip.timeRange;
|
|
17691
|
+
if (!(startMs < endMs)) return {
|
|
17692
|
+
ok: false,
|
|
17693
|
+
refusal: {
|
|
17694
|
+
code: "catalog-miss",
|
|
17695
|
+
detail: `the row carries no usable span (${startMs}..${endMs})`
|
|
17696
|
+
}
|
|
17697
|
+
};
|
|
17698
|
+
return {
|
|
17699
|
+
ok: true,
|
|
17700
|
+
accepted: {
|
|
17701
|
+
clip,
|
|
17702
|
+
served: servedTwinFor(clip, subject.profile)
|
|
17703
|
+
}
|
|
17704
|
+
};
|
|
17705
|
+
}
|
|
17706
|
+
//#endregion
|
|
17278
17707
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
17279
17708
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
17280
17709
|
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
@@ -17302,12 +17731,6 @@ function exportFileName(rec) {
|
|
|
17302
17731
|
async function hasFootage(index, deviceId, profile, fromMs, toMs) {
|
|
17303
17732
|
return (await index.segmentsInWindow(deviceId, profile, fromMs, toMs)).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
|
|
17304
17733
|
}
|
|
17305
|
-
/** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
|
|
17306
|
-
function resolveExportProfiles(input) {
|
|
17307
|
-
if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
|
|
17308
|
-
if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
|
|
17309
|
-
return [];
|
|
17310
|
-
}
|
|
17311
17734
|
/** Load a record or throw a clear NOT_FOUND-style error. */
|
|
17312
17735
|
async function requireRecord(state, exportId) {
|
|
17313
17736
|
const rec = await getExportRecord(state, exportId);
|
|
@@ -17316,37 +17739,133 @@ async function requireRecord(state, exportId) {
|
|
|
17316
17739
|
}
|
|
17317
17740
|
/** Build the `recordingExport` provider over the injected deps. */
|
|
17318
17741
|
function buildRecordingExportProvider(deps) {
|
|
17742
|
+
/**
|
|
17743
|
+
* Persist one `queued` row and hand it to the engine. The ONE place a record
|
|
17744
|
+
* is born, for both kinds — so the fields nothing about the subject decides
|
|
17745
|
+
* (the lifetime, the audit stamp, the null progress) cannot drift apart.
|
|
17746
|
+
*/
|
|
17747
|
+
const queueOne = async (fields, options, now) => {
|
|
17748
|
+
const saved = await upsertExport(deps.state, {
|
|
17749
|
+
...fields,
|
|
17750
|
+
id: deps.newId(),
|
|
17751
|
+
options,
|
|
17752
|
+
state: "queued",
|
|
17753
|
+
progressPct: null,
|
|
17754
|
+
fileBytes: null,
|
|
17755
|
+
expiresAt: now + options.maxLifeMs,
|
|
17756
|
+
deleteAfterDownload: options.deleteAfterDownload,
|
|
17757
|
+
downloadedAt: null,
|
|
17758
|
+
createdAt: now,
|
|
17759
|
+
createdBy: deps.createdBy(),
|
|
17760
|
+
error: null
|
|
17761
|
+
});
|
|
17762
|
+
deps.engine.enqueue(saved.id);
|
|
17763
|
+
return saved;
|
|
17764
|
+
};
|
|
17765
|
+
/**
|
|
17766
|
+
* The clip branch's fail-fast gate — the catalog, never the recording index.
|
|
17767
|
+
*
|
|
17768
|
+
* Refuses BEFORE a row exists, so an operator gets the reason in the answer
|
|
17769
|
+
* to his own tap instead of discovering it on a `failed` row a minute later,
|
|
17770
|
+
* and so a refusal never spends a wake.
|
|
17771
|
+
*/
|
|
17772
|
+
const createClipExport = async (subject, options) => {
|
|
17773
|
+
const tags = { deviceId: subject.deviceId };
|
|
17774
|
+
if (options.timelapse !== void 0 || options.speed !== void 0 && options.speed !== 1) {
|
|
17775
|
+
const detail = "a clip is remuxed, never re-timed — drop speed/timelapse";
|
|
17776
|
+
deps.logger.warn("clip export refused: a render option a remux cannot honour", {
|
|
17777
|
+
tags,
|
|
17778
|
+
meta: {
|
|
17779
|
+
clipId: subject.clipId,
|
|
17780
|
+
speed: options.speed,
|
|
17781
|
+
timelapse: options.timelapse
|
|
17782
|
+
}
|
|
17783
|
+
});
|
|
17784
|
+
throw new Error(clipExportFailure("unsupported-option", detail));
|
|
17785
|
+
}
|
|
17786
|
+
let rows;
|
|
17787
|
+
try {
|
|
17788
|
+
rows = await deps.clipCatalog.listClips({
|
|
17789
|
+
deviceId: subject.deviceId,
|
|
17790
|
+
since: subject.catalogWindow.sinceMs,
|
|
17791
|
+
until: subject.catalogWindow.untilMs,
|
|
17792
|
+
provider: subject.provider,
|
|
17793
|
+
limit: EXPORT_CLIP_CATALOG_LIMIT
|
|
17794
|
+
});
|
|
17795
|
+
} catch (err) {
|
|
17796
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
17797
|
+
deps.logger.warn("clip export refused: the clip catalog could not be read", {
|
|
17798
|
+
tags,
|
|
17799
|
+
meta: {
|
|
17800
|
+
clipId: subject.clipId,
|
|
17801
|
+
provider: subject.provider,
|
|
17802
|
+
error: detail
|
|
17803
|
+
}
|
|
17804
|
+
});
|
|
17805
|
+
throw new Error(clipExportFailure("catalog-unreachable", detail), { cause: err });
|
|
17806
|
+
}
|
|
17807
|
+
const outcome = judgeClipRow(subject, rows);
|
|
17808
|
+
if (!outcome.ok) {
|
|
17809
|
+
deps.logger.warn("clip export refused by the catalog gate", {
|
|
17810
|
+
tags,
|
|
17811
|
+
meta: {
|
|
17812
|
+
clipId: subject.clipId,
|
|
17813
|
+
provider: subject.provider,
|
|
17814
|
+
reason: outcome.refusal.code,
|
|
17815
|
+
detail: outcome.refusal.detail
|
|
17816
|
+
}
|
|
17817
|
+
});
|
|
17818
|
+
throw new Error(clipExportFailure(outcome.refusal.code, outcome.refusal.detail));
|
|
17819
|
+
}
|
|
17820
|
+
const { clip, served } = outcome.accepted;
|
|
17821
|
+
const record = await queueOne({
|
|
17822
|
+
deviceId: subject.deviceId,
|
|
17823
|
+
profile: served,
|
|
17824
|
+
fromMs: clip.timeRange.startMs,
|
|
17825
|
+
toMs: clip.timeRange.endMs,
|
|
17826
|
+
subject
|
|
17827
|
+
}, options, deps.now());
|
|
17828
|
+
deps.logger.info("clip export queued", {
|
|
17829
|
+
tags,
|
|
17830
|
+
meta: {
|
|
17831
|
+
exportId: record.id,
|
|
17832
|
+
clipId: subject.clipId,
|
|
17833
|
+
source: subject.source,
|
|
17834
|
+
provider: subject.provider,
|
|
17835
|
+
askedProfile: subject.profile,
|
|
17836
|
+
servedProfile: served,
|
|
17837
|
+
wake: subject.wake ?? "none",
|
|
17838
|
+
fromMs: record.fromMs,
|
|
17839
|
+
toMs: record.toMs
|
|
17840
|
+
}
|
|
17841
|
+
});
|
|
17842
|
+
return record;
|
|
17843
|
+
};
|
|
17319
17844
|
return {
|
|
17320
17845
|
createExport: async (input) => {
|
|
17321
|
-
const
|
|
17322
|
-
|
|
17323
|
-
|
|
17846
|
+
const subject = resolveCreateExportSubject(input);
|
|
17847
|
+
if (subject.kind === "clip") return await createClipExport(subject, input.options);
|
|
17848
|
+
const { deviceId, profiles, fromMs, toMs } = subject;
|
|
17849
|
+
const { options } = input;
|
|
17324
17850
|
if (!(fromMs < toMs)) throw new Error(`recording-export: invalid range ${fromMs}..${toMs}`);
|
|
17325
17851
|
for (const profile of profiles) if (!await hasFootage(deps.index, deviceId, profile, fromMs, toMs)) throw new Error(`recording-export: no footage for ${deviceId}/${profile} in ${fromMs}..${toMs}`);
|
|
17326
17852
|
const now = deps.now();
|
|
17327
|
-
|
|
17328
|
-
|
|
17329
|
-
|
|
17330
|
-
|
|
17331
|
-
|
|
17332
|
-
|
|
17333
|
-
|
|
17334
|
-
|
|
17335
|
-
|
|
17336
|
-
|
|
17337
|
-
|
|
17338
|
-
|
|
17339
|
-
|
|
17340
|
-
|
|
17341
|
-
|
|
17342
|
-
|
|
17343
|
-
createdBy: deps.createdBy(),
|
|
17344
|
-
error: null
|
|
17345
|
-
};
|
|
17346
|
-
const saved = await upsertExport(deps.state, record);
|
|
17347
|
-
deps.engine.enqueue(saved.id);
|
|
17348
|
-
first ??= saved;
|
|
17349
|
-
}
|
|
17853
|
+
const [head, ...rest] = profiles;
|
|
17854
|
+
if (head === void 0) throw new Error("recording-export: profiles must contain at least one profile");
|
|
17855
|
+
const first = await queueOne({
|
|
17856
|
+
deviceId,
|
|
17857
|
+
profile: head,
|
|
17858
|
+
fromMs,
|
|
17859
|
+
toMs,
|
|
17860
|
+
subject
|
|
17861
|
+
}, options, now);
|
|
17862
|
+
for (const profile of rest) await queueOne({
|
|
17863
|
+
deviceId,
|
|
17864
|
+
profile,
|
|
17865
|
+
fromMs,
|
|
17866
|
+
toMs,
|
|
17867
|
+
subject
|
|
17868
|
+
}, options, now);
|
|
17350
17869
|
return first;
|
|
17351
17870
|
},
|
|
17352
17871
|
listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
|
|
@@ -19121,7 +19640,19 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
19121
19640
|
outPath: exportOutPath,
|
|
19122
19641
|
statBytes: async (p) => (await promises.stat(p)).size,
|
|
19123
19642
|
removeFile: removeExportFile,
|
|
19124
|
-
emit: (category, data) => this.emitExportEvent(category, data)
|
|
19643
|
+
emit: (category, data) => this.emitExportEvent(category, data),
|
|
19644
|
+
now: () => Date.now(),
|
|
19645
|
+
fetchClipBytes: async (req) => await this.ctx.api.videoclips.readClipBytes.query({
|
|
19646
|
+
deviceId: req.deviceId,
|
|
19647
|
+
clipId: req.clipId,
|
|
19648
|
+
profile: req.profile,
|
|
19649
|
+
maxBytes: req.maxBytes,
|
|
19650
|
+
...req.wake !== void 0 ? { wake: req.wake } : {}
|
|
19651
|
+
}),
|
|
19652
|
+
writeExportFile: async (p, bytes) => {
|
|
19653
|
+
await promises.mkdir(path.dirname(p), { recursive: true });
|
|
19654
|
+
await promises.writeFile(p, bytes);
|
|
19655
|
+
}
|
|
19125
19656
|
});
|
|
19126
19657
|
const exportProvider = buildRecordingExportProvider({
|
|
19127
19658
|
state: exportsState,
|
|
@@ -19130,6 +19661,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
19130
19661
|
if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
|
|
19131
19662
|
return this.index.segments(deviceId, profile);
|
|
19132
19663
|
} },
|
|
19664
|
+
clipCatalog: { listClips: async (input) => await this.ctx.api.videoclips.listClips.query(input) },
|
|
19133
19665
|
now: () => Date.now(),
|
|
19134
19666
|
newId: () => randomUUID(),
|
|
19135
19667
|
createdBy: () => "operator",
|