@camstack/addon-pipeline 1.2.273 → 1.2.275
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-9Solpn0K.js → default-detection-model-6lCvep7n.js} +1 -1
- package/dist/{default-detection-model-DCFaNnYd.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-BRT-qKlE.mjs → dist-C1pCVJk7.mjs} +621 -117
- package/dist/{dist-DRDzfOnl.js → dist-NYjn7hkE.js} +674 -116
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Dwu66ZR4.mjs → node--UY0iepi.mjs} +1 -1
- package/dist/{node-BmTPPuIg.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-Cx4oDFz9.mjs → process-memory-BQOfL3Ig.mjs} +1 -1
- package/dist/{process-memory-BTwHfysZ.js → process-memory-DDvuSEMJ.js} +1 -1
- package/dist/recorder/index.js +1063 -35
- package/dist/recorder/index.mjs +1063 -35
- package/dist/{segment-demux-js-BKvYCad_.mjs → segment-demux-js-B0_aAzN0.mjs} +1 -1
- package/dist/{segment-demux-js--ePyI476.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-shbmpL8E.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrNGbvkl.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-C8RTK6nJ.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-CtVYKCNe.mjs → hostInit-CSJt3rp0.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 +5 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C_SEu7AC.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
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, {
|
|
@@ -13489,6 +13757,581 @@ var PlacementService = class {
|
|
|
13489
13757
|
}
|
|
13490
13758
|
};
|
|
13491
13759
|
//#endregion
|
|
13760
|
+
//#region src/recorder/addon/videoclip-visits.ts
|
|
13761
|
+
/**
|
|
13762
|
+
* Videoclip visits — the grain a clip should have on an events-mode camera.
|
|
13763
|
+
*
|
|
13764
|
+
* Disk writes one fMP4 per ffmpeg roll (~8–12 s). The recorder calendar merges
|
|
13765
|
+
* only gaps ≤ 5 s, so a single pantry walk shows up as several availability
|
|
13766
|
+
* ranges with ~8 s GOP holes between them. Analytics then mints one 15 s clip
|
|
13767
|
+
* per motion heartbeat. A *visit* is the keep-window those fragments belong
|
|
13768
|
+
* to: cluster availability with {@link VISIT_MERGE_GAP_MS}, annotate with
|
|
13769
|
+
* overlapping events. No second copy of the media; the files stay the source
|
|
13770
|
+
* of truth.
|
|
13771
|
+
*/
|
|
13772
|
+
/** Synthetic event-id slot in `analytics:<start>:<end>:<id>` for a visit clip. */
|
|
13773
|
+
var VISIT_EVENT_ID = "visit";
|
|
13774
|
+
function clusterFootageVisits(ranges, gapMs = VISIT_MERGE_GAP_MS) {
|
|
13775
|
+
if (ranges.length === 0) return [];
|
|
13776
|
+
const sorted = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
|
|
13777
|
+
const visits = [];
|
|
13778
|
+
let pieces = [sorted[0]];
|
|
13779
|
+
let endMs = sorted[0].endMs;
|
|
13780
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
13781
|
+
const next = sorted[i];
|
|
13782
|
+
if (next.startMs <= endMs + gapMs) {
|
|
13783
|
+
pieces.push(next);
|
|
13784
|
+
endMs = Math.max(endMs, next.endMs);
|
|
13785
|
+
continue;
|
|
13786
|
+
}
|
|
13787
|
+
visits.push(finishVisit(pieces, endMs));
|
|
13788
|
+
pieces = [next];
|
|
13789
|
+
endMs = next.endMs;
|
|
13790
|
+
}
|
|
13791
|
+
visits.push(finishVisit(pieces, endMs));
|
|
13792
|
+
return visits;
|
|
13793
|
+
}
|
|
13794
|
+
function finishVisit(pieces, endMs) {
|
|
13795
|
+
const holes = [];
|
|
13796
|
+
for (let i = 1; i < pieces.length; i++) {
|
|
13797
|
+
const prevEnd = pieces[i - 1].endMs;
|
|
13798
|
+
const nextStart = pieces[i].startMs;
|
|
13799
|
+
if (nextStart > prevEnd) holes.push({
|
|
13800
|
+
startMs: prevEnd,
|
|
13801
|
+
endMs: nextStart
|
|
13802
|
+
});
|
|
13803
|
+
}
|
|
13804
|
+
return {
|
|
13805
|
+
startMs: pieces[0].startMs,
|
|
13806
|
+
endMs,
|
|
13807
|
+
ranges: pieces,
|
|
13808
|
+
holes
|
|
13809
|
+
};
|
|
13810
|
+
}
|
|
13811
|
+
var KIND_ORDER = [
|
|
13812
|
+
"object",
|
|
13813
|
+
"motion",
|
|
13814
|
+
"audio"
|
|
13815
|
+
];
|
|
13816
|
+
function eventOverlapsVisit(event, visit, pad) {
|
|
13817
|
+
if (event.timestamp >= visit.startMs && event.timestamp <= visit.endMs) return true;
|
|
13818
|
+
const windowStart = event.timestamp - pad.preMs;
|
|
13819
|
+
const windowEnd = event.timestamp + pad.postMs;
|
|
13820
|
+
return windowStart < visit.endMs && windowEnd > visit.startMs;
|
|
13821
|
+
}
|
|
13822
|
+
function dominantKind(attached) {
|
|
13823
|
+
for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
|
|
13824
|
+
return "motion";
|
|
13825
|
+
}
|
|
13826
|
+
/**
|
|
13827
|
+
* The instant a recorded still can actually be decoded inside a visit: the
|
|
13828
|
+
* midpoint of its LONGEST contiguous range.
|
|
13829
|
+
*
|
|
13830
|
+
* NOT the midpoint of the visit. A visit spans its holes by construction (it is
|
|
13831
|
+
* ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
|
|
13832
|
+
* lands in dead air roughly as often as not and the recorder's still route
|
|
13833
|
+
* answers 204 for a visit with plenty of footage.
|
|
13834
|
+
*/
|
|
13835
|
+
function stillInstantMs(ranges) {
|
|
13836
|
+
let best;
|
|
13837
|
+
for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
|
|
13838
|
+
if (best === void 0) return void 0;
|
|
13839
|
+
return Math.floor((best.startMs + best.endMs) / 2);
|
|
13840
|
+
}
|
|
13841
|
+
/**
|
|
13842
|
+
* The distinct object classes attached to a visit, most frequent first,
|
|
13843
|
+
* capped at {@link MAX_CLIP_LABELS}. Empty when nothing classified overlapped
|
|
13844
|
+
* — the caller then omits the field so a motion/audio-only visit keeps its
|
|
13845
|
+
* kind label instead of carrying an empty array every consumer length-checks.
|
|
13846
|
+
*/
|
|
13847
|
+
function dominantLabels(attached) {
|
|
13848
|
+
const counts = /* @__PURE__ */ new Map();
|
|
13849
|
+
for (const a of attached) {
|
|
13850
|
+
if (a.kind !== "object") continue;
|
|
13851
|
+
const name = a.event.className;
|
|
13852
|
+
if (typeof name !== "string" || name.length === 0) continue;
|
|
13853
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
13854
|
+
}
|
|
13855
|
+
return [...counts.entries()].toSorted((x, y) => y[1] - x[1]).slice(0, 3).map(([name]) => name);
|
|
13856
|
+
}
|
|
13857
|
+
function visitsToClips(visits, events, pad, vouched) {
|
|
13858
|
+
const tagged = [
|
|
13859
|
+
...events.object.map((event) => ({
|
|
13860
|
+
kind: "object",
|
|
13861
|
+
event
|
|
13862
|
+
})),
|
|
13863
|
+
...events.motion.map((event) => ({
|
|
13864
|
+
kind: "motion",
|
|
13865
|
+
event
|
|
13866
|
+
})),
|
|
13867
|
+
...events.audio.map((event) => ({
|
|
13868
|
+
kind: "audio",
|
|
13869
|
+
event
|
|
13870
|
+
}))
|
|
13871
|
+
];
|
|
13872
|
+
return visits.map((visit) => {
|
|
13873
|
+
const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
|
|
13874
|
+
const kind = dominantKind(attached);
|
|
13875
|
+
const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
|
|
13876
|
+
const stillAtMs = stillInstantMs(visit.ranges);
|
|
13877
|
+
const labels = dominantLabels(attached);
|
|
13878
|
+
return {
|
|
13879
|
+
id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
|
|
13880
|
+
source: "analytics",
|
|
13881
|
+
kind,
|
|
13882
|
+
timeRange: {
|
|
13883
|
+
startMs: visit.startMs,
|
|
13884
|
+
endMs: visit.endMs
|
|
13885
|
+
},
|
|
13886
|
+
...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
|
|
13887
|
+
...stillAtMs !== void 0 ? { stillAtMs } : {},
|
|
13888
|
+
eventIds: attached.slice(0, 24).map((a) => a.event.id),
|
|
13889
|
+
eventCount: attached.length,
|
|
13890
|
+
holes: [...visit.holes],
|
|
13891
|
+
...labels.length > 0 ? { labels } : {}
|
|
13892
|
+
};
|
|
13893
|
+
}).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
13894
|
+
}
|
|
13895
|
+
//#endregion
|
|
13896
|
+
//#region src/recorder/addon/videoclips-provider.ts
|
|
13897
|
+
/**
|
|
13898
|
+
* Default `videoclips` provider — composes recorded footage visits with the
|
|
13899
|
+
* analytics event log. A clip is a time-WINDOW over existing footage, never a
|
|
13900
|
+
* separate file: `getClipPlayback` resolves to
|
|
13901
|
+
* `recording.getPlaybackManifest(startMs, endMs)`.
|
|
13902
|
+
*
|
|
13903
|
+
* When availability is present the grain is a *visit* (events-mode keep-window
|
|
13904
|
+
* clustered at `VISIT_MERGE_GAP_MS`), not one clip per motion heartbeat. The
|
|
13905
|
+
* clip id still encodes the window (`analytics:<startMs>:<endMs>:visit`) so
|
|
13906
|
+
* playback is self-contained. Without availability (or when that read fails)
|
|
13907
|
+
* the provider falls back to per-event padded windows.
|
|
13908
|
+
*
|
|
13909
|
+
* Pure helpers (`eventsToClips` / `clusterFootageVisits` / `clipIdFor` /
|
|
13910
|
+
* `parseClipId`) are unit-tested in isolation; the provider factory wires
|
|
13911
|
+
* injected fetch/resolve deps so the compose logic is testable without I/O.
|
|
13912
|
+
*/
|
|
13913
|
+
var SOURCE = "analytics";
|
|
13914
|
+
/** The addon that registers this provider — stamped on its source row. */
|
|
13915
|
+
var CLIP_SOURCE_ADDON_ID = "recorder";
|
|
13916
|
+
/**
|
|
13917
|
+
* Backstop on the ANNOTATION read — never on the clip list.
|
|
13918
|
+
*
|
|
13919
|
+
* A visit is annotated by every event inside it, so the clip `limit` must not
|
|
13920
|
+
* reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
|
|
13921
|
+
* bound exists only so a pathological camera-day cannot pull an unbounded page:
|
|
13922
|
+
* the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
|
|
13923
|
+
* the EARLIEST visits silently lose their annotations — which is why hitting it
|
|
13924
|
+
* is logged, per camera, rather than absorbed.
|
|
13925
|
+
*/
|
|
13926
|
+
var VISIT_EVENT_FETCH_LIMIT = 5e3;
|
|
13927
|
+
function clipIdFor(eventId, startMs, endMs) {
|
|
13928
|
+
return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
|
|
13929
|
+
}
|
|
13930
|
+
/** Parse a default-provider clip id back into its window + event id. Returns
|
|
13931
|
+
* `null` for any id this provider did not mint (e.g. a native clip id). */
|
|
13932
|
+
function parseClipId(clipId) {
|
|
13933
|
+
const parts = clipId.split(":");
|
|
13934
|
+
if (parts.length < 4 || parts[0] !== SOURCE) return null;
|
|
13935
|
+
const startMs = Number(parts[1]);
|
|
13936
|
+
const endMs = Number(parts[2]);
|
|
13937
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
13938
|
+
const eventId = parts.slice(3).join(":");
|
|
13939
|
+
if (!eventId) return null;
|
|
13940
|
+
return {
|
|
13941
|
+
eventId,
|
|
13942
|
+
startMs,
|
|
13943
|
+
endMs
|
|
13944
|
+
};
|
|
13945
|
+
}
|
|
13946
|
+
/** Map per-kind events into time-ranged clips, newest-first. Pure. */
|
|
13947
|
+
function eventsToClips(events, pad, vouched) {
|
|
13948
|
+
const make = (e, kind) => {
|
|
13949
|
+
const startMs = e.timestamp - pad.preMs;
|
|
13950
|
+
const endMs = e.timestamp + pad.postMs;
|
|
13951
|
+
return {
|
|
13952
|
+
id: clipIdFor(e.id, startMs, endMs),
|
|
13953
|
+
source: SOURCE,
|
|
13954
|
+
kind,
|
|
13955
|
+
timeRange: {
|
|
13956
|
+
startMs,
|
|
13957
|
+
endMs
|
|
13958
|
+
},
|
|
13959
|
+
...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
|
|
13960
|
+
};
|
|
13961
|
+
};
|
|
13962
|
+
return [
|
|
13963
|
+
...events.motion.map((e) => make(e, "motion")),
|
|
13964
|
+
...events.object.map((e) => make(e, "object")),
|
|
13965
|
+
...events.audio.map((e) => make(e, "audio"))
|
|
13966
|
+
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
13967
|
+
}
|
|
13968
|
+
/** Every id in the three buckets, in the order the composers tag them. */
|
|
13969
|
+
function allEventIds(events) {
|
|
13970
|
+
return [
|
|
13971
|
+
...events.motion,
|
|
13972
|
+
...events.object,
|
|
13973
|
+
...events.audio
|
|
13974
|
+
].map((e) => e.id);
|
|
13975
|
+
}
|
|
13976
|
+
/**
|
|
13977
|
+
* Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
|
|
13978
|
+
* `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
|
|
13979
|
+
* owning TRACK's. Up to four batched index reads — one PER EVENT KIND that has
|
|
13980
|
+
* any ids (D476: `motion` / `object` / `audio` are separate owner kinds now,
|
|
13981
|
+
* so a single combined read can no longer ask "does any of these ids own
|
|
13982
|
+
* media" — a motion id and an object id can be the same number post-D474 and
|
|
13983
|
+
* are answered by different rows) — plus the track fallback, only over the
|
|
13984
|
+
* object events the first pass did not already answer for (motion and audio
|
|
13985
|
+
* are trackless roots, so they never need it).
|
|
13986
|
+
*
|
|
13987
|
+
* THROWS on a failed read. The caller fails closed on the CLAIM and open on the
|
|
13988
|
+
* LIST; swallowing here would collapse those two opposite directions into one.
|
|
13989
|
+
*/
|
|
13990
|
+
async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
|
|
13991
|
+
const ownMedia = /* @__PURE__ */ new Set();
|
|
13992
|
+
for (const ownerType of EVENT_OWNER_TYPES) {
|
|
13993
|
+
const ownerIds = events[ownerType].map((e) => e.id);
|
|
13994
|
+
if (ownerIds.length === 0) continue;
|
|
13995
|
+
const found = await fetchMediaOwners({
|
|
13996
|
+
deviceId,
|
|
13997
|
+
ownerKind: ownerType,
|
|
13998
|
+
ownerIds
|
|
13999
|
+
});
|
|
14000
|
+
for (const id of found) ownMedia.add(id);
|
|
14001
|
+
}
|
|
14002
|
+
const residualTracks = [];
|
|
14003
|
+
const seenTracks = /* @__PURE__ */ new Set();
|
|
14004
|
+
for (const event of events.object) {
|
|
14005
|
+
const trackId = event.trackId;
|
|
14006
|
+
if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
|
|
14007
|
+
seenTracks.add(trackId);
|
|
14008
|
+
residualTracks.push(trackId);
|
|
14009
|
+
}
|
|
14010
|
+
const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
|
|
14011
|
+
deviceId,
|
|
14012
|
+
ownerKind: "track",
|
|
14013
|
+
ownerIds: residualTracks
|
|
14014
|
+
});
|
|
14015
|
+
const vouched = new Set(ownMedia);
|
|
14016
|
+
for (const event of events.object) {
|
|
14017
|
+
const trackId = event.trackId;
|
|
14018
|
+
if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
|
|
14019
|
+
}
|
|
14020
|
+
return vouched;
|
|
14021
|
+
}
|
|
14022
|
+
/** Keep only clips that actually touch the caller's window. The reads are
|
|
14023
|
+
* widened by {@link leftOverhangMs}; the ANSWER is not. */
|
|
14024
|
+
function intersectsWindow(clip, since, until) {
|
|
14025
|
+
return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
|
|
14026
|
+
}
|
|
14027
|
+
/**
|
|
14028
|
+
* How far BEFORE `since` the event + availability reads must reach.
|
|
14029
|
+
*
|
|
14030
|
+
* A visit straddling `since` keeps its heartbeat rows and loses the transition
|
|
14031
|
+
* row — the only one that ever carries a snapshot — so the same visit shows a
|
|
14032
|
+
* picture or doesn't depending on where the operator scrolled. One wider range
|
|
14033
|
+
* scan on an indexed column buys a list that stops changing under the cursor.
|
|
14034
|
+
*/
|
|
14035
|
+
function leftOverhangMs(pad) {
|
|
14036
|
+
return VISIT_MERGE_GAP_MS + pad.preMs;
|
|
14037
|
+
}
|
|
14038
|
+
/** No events at all — what the degraded branch annotates its visits with. */
|
|
14039
|
+
var NO_EVENTS = {
|
|
14040
|
+
motion: [],
|
|
14041
|
+
object: [],
|
|
14042
|
+
audio: []
|
|
14043
|
+
};
|
|
14044
|
+
function createVideoclipsProvider(deps) {
|
|
14045
|
+
/**
|
|
14046
|
+
* Cameras whose LAST `listClips` could not read the analytics event log, with
|
|
14047
|
+
* the refusal verbatim.
|
|
14048
|
+
*
|
|
14049
|
+
* Not a cache of anybody else's fact (D224): it is this provider reporting
|
|
14050
|
+
* what IT just did, refreshed on the same call that produces it and cleared
|
|
14051
|
+
* by the next read that succeeds. `listSources` has no window argument and
|
|
14052
|
+
* must not go and ask again — an extra events read on the listing path would
|
|
14053
|
+
* be a second, disagreeing answer to the question `listClips` already
|
|
14054
|
+
* answered.
|
|
14055
|
+
*/
|
|
14056
|
+
const eventsUnreachable = /* @__PURE__ */ new Map();
|
|
14057
|
+
return {
|
|
14058
|
+
listClips: async ({ deviceId, since, until, limit }) => {
|
|
14059
|
+
const readSince = since - leftOverhangMs(deps.defaultPad);
|
|
14060
|
+
let events;
|
|
14061
|
+
try {
|
|
14062
|
+
events = await deps.fetchEvents({
|
|
14063
|
+
deviceId,
|
|
14064
|
+
since: readSince,
|
|
14065
|
+
until,
|
|
14066
|
+
limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
|
|
14067
|
+
});
|
|
14068
|
+
} catch (err) {
|
|
14069
|
+
return await listWithoutEvents(deps, eventsUnreachable, errMessage(err), {
|
|
14070
|
+
deviceId,
|
|
14071
|
+
since,
|
|
14072
|
+
until,
|
|
14073
|
+
readSince,
|
|
14074
|
+
limit
|
|
14075
|
+
});
|
|
14076
|
+
}
|
|
14077
|
+
eventsUnreachable.delete(deviceId);
|
|
14078
|
+
warnOnTruncatedFetch(deps, deviceId, events);
|
|
14079
|
+
let vouched = /* @__PURE__ */ new Set();
|
|
14080
|
+
try {
|
|
14081
|
+
vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
|
|
14082
|
+
} catch (err) {
|
|
14083
|
+
deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
|
|
14084
|
+
deviceId,
|
|
14085
|
+
events: allEventIds(events).length,
|
|
14086
|
+
error: err instanceof Error ? err.message : String(err)
|
|
14087
|
+
});
|
|
14088
|
+
}
|
|
14089
|
+
let clips = eventsToClips(events, deps.defaultPad, vouched);
|
|
14090
|
+
if (deps.fetchAvailability) try {
|
|
14091
|
+
clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
|
|
14092
|
+
deviceId,
|
|
14093
|
+
since: readSince,
|
|
14094
|
+
until
|
|
14095
|
+
}), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
|
|
14096
|
+
} catch (err) {
|
|
14097
|
+
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
14098
|
+
deviceId,
|
|
14099
|
+
error: err instanceof Error ? err.message : String(err)
|
|
14100
|
+
});
|
|
14101
|
+
}
|
|
14102
|
+
const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
|
|
14103
|
+
const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
|
|
14104
|
+
reportUnvouchedClips(deps, deviceId, answer);
|
|
14105
|
+
return answer;
|
|
14106
|
+
},
|
|
14107
|
+
/**
|
|
14108
|
+
* The one source this provider serves, on every camera: the hub's own
|
|
14109
|
+
* analytics visits over recorded footage.
|
|
14110
|
+
*
|
|
14111
|
+
* Always `ok`, and that is not optimism — this source IS the process
|
|
14112
|
+
* answering the call, so there is no reachability question to report. A
|
|
14113
|
+
* read that fails INSIDE `listClips` degrades that list (and logs it); a
|
|
14114
|
+
* source that cannot be reached at all is reported by the collection
|
|
14115
|
+
* dispatcher, which is the only layer that can tell the difference.
|
|
14116
|
+
* `label` is what the picker draws.
|
|
14117
|
+
*/
|
|
14118
|
+
listSources: async ({ deviceId }) => {
|
|
14119
|
+
const outage = eventsUnreachable.get(deviceId);
|
|
14120
|
+
return [{
|
|
14121
|
+
source: SOURCE,
|
|
14122
|
+
addonId: CLIP_SOURCE_ADDON_ID,
|
|
14123
|
+
label: "CamStack recordings",
|
|
14124
|
+
availability: outage === void 0 ? { state: "ok" } : {
|
|
14125
|
+
state: "ok",
|
|
14126
|
+
reason: `${EVENTS_UNREACHABLE_NOTICE} (${outage})`
|
|
14127
|
+
}
|
|
14128
|
+
}];
|
|
14129
|
+
},
|
|
14130
|
+
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
14131
|
+
const parsed = parseClipId(clipId);
|
|
14132
|
+
if (!parsed) throw new Error(`videoclips: unrecognized clipId "${clipId}"`);
|
|
14133
|
+
return {
|
|
14134
|
+
...await deps.resolvePlayback({
|
|
14135
|
+
deviceId,
|
|
14136
|
+
startMs: parsed.startMs,
|
|
14137
|
+
endMs: parsed.endMs
|
|
14138
|
+
}),
|
|
14139
|
+
format: "hls"
|
|
14140
|
+
};
|
|
14141
|
+
},
|
|
14142
|
+
/**
|
|
14143
|
+
* This source has no bytes to hand over, and says so rather than
|
|
14144
|
+
* inventing a path to them.
|
|
14145
|
+
*
|
|
14146
|
+
* An `analytics` clip is a time WINDOW over footage the recorder already
|
|
14147
|
+
* owns — there is no file behind it, and a caller that wants it as one
|
|
14148
|
+
* asks for it as an EXPORT, where the source playlist and
|
|
14149
|
+
* `export-dense-map` handle the holes a visit spans by construction
|
|
14150
|
+
* (D558 § 5.4.3). Reading it here would be this addon fetching from
|
|
14151
|
+
* itself, over a cap channel, in base64.
|
|
14152
|
+
*/
|
|
14153
|
+
readClipBytes: async ({ clipId }) => {
|
|
14154
|
+
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.`);
|
|
14155
|
+
}
|
|
14156
|
+
};
|
|
14157
|
+
}
|
|
14158
|
+
/** What the source row says while the event log is down. */
|
|
14159
|
+
var EVENTS_UNREACHABLE_NOTICE = "The analytics event log could not be read on the last listing — these clips are recorded footage only, with no labels, stills or event counts";
|
|
14160
|
+
function errMessage(err) {
|
|
14161
|
+
return err instanceof Error ? err.message : String(err);
|
|
14162
|
+
}
|
|
14163
|
+
/**
|
|
14164
|
+
* Footage present, EVENTS unreachable — the mirror of the two degradations
|
|
14165
|
+
* this provider already had, and the one that could not happen while a single
|
|
14166
|
+
* addon held both halves (D560).
|
|
14167
|
+
*
|
|
14168
|
+
* A visit is a window over recorded footage; the events only ANNOTATE it. So
|
|
14169
|
+
* when availability answers, the visits are still real and still playable —
|
|
14170
|
+
* their ids encode the window — and the honest answer is that list without its
|
|
14171
|
+
* annotations. An availability that answered `[]` yields an empty list that is
|
|
14172
|
+
* TRUE about footage: with availability present a clip only ever comes from a
|
|
14173
|
+
* recorded range, so it is the same answer a successful events read would have
|
|
14174
|
+
* given.
|
|
14175
|
+
*
|
|
14176
|
+
* Without availability (absent, or its read failed too) the clips ARE the
|
|
14177
|
+
* events, so there is nothing left that is true to say. That is a REFUSAL
|
|
14178
|
+
* naming both reads — never an empty list, which reads as "this camera has no
|
|
14179
|
+
* recordings".
|
|
14180
|
+
*/
|
|
14181
|
+
async function listWithoutEvents(deps, eventsUnreachable, reason, window) {
|
|
14182
|
+
const { deviceId, since, until, readSince, limit } = window;
|
|
14183
|
+
const fetchAvailability = deps.fetchAvailability;
|
|
14184
|
+
if (fetchAvailability === void 0) {
|
|
14185
|
+
eventsUnreachable.set(deviceId, reason);
|
|
14186
|
+
throw new Error(`videoclips: the analytics event log is unreachable for device ${deviceId} (${reason}) and this source lists no recorded footage — refusing rather than answering an empty list`);
|
|
14187
|
+
}
|
|
14188
|
+
let ranges;
|
|
14189
|
+
try {
|
|
14190
|
+
ranges = await fetchAvailability({
|
|
14191
|
+
deviceId,
|
|
14192
|
+
since: readSince,
|
|
14193
|
+
until
|
|
14194
|
+
});
|
|
14195
|
+
} catch (err) {
|
|
14196
|
+
eventsUnreachable.set(deviceId, reason);
|
|
14197
|
+
throw new Error(`videoclips: neither read answered for device ${deviceId} — events: ${reason}; footage: ${errMessage(err)}. Refusing rather than answering an empty list.`);
|
|
14198
|
+
}
|
|
14199
|
+
eventsUnreachable.set(deviceId, reason);
|
|
14200
|
+
deps.logWarn?.("videoclips: event log unreachable — listing the recorded footage visits without their events", {
|
|
14201
|
+
deviceId,
|
|
14202
|
+
error: reason,
|
|
14203
|
+
ranges: ranges.length
|
|
14204
|
+
});
|
|
14205
|
+
const clips = visitsToClips(clusterFootageVisits(ranges, VISIT_MERGE_GAP_MS), NO_EVENTS, deps.defaultPad, /* @__PURE__ */ new Set()).filter((clip) => intersectsWindow(clip, since, until));
|
|
14206
|
+
return limit != null ? clips.slice(0, limit) : clips;
|
|
14207
|
+
}
|
|
14208
|
+
/** The annotation backstop biting is a SILENT loss of the earliest visits'
|
|
14209
|
+
* events — say so, per camera, instead of shipping a thinner list. */
|
|
14210
|
+
function warnOnTruncatedFetch(deps, deviceId, events) {
|
|
14211
|
+
if (!deps.fetchAvailability) return;
|
|
14212
|
+
const truncated = [
|
|
14213
|
+
"motion",
|
|
14214
|
+
"object",
|
|
14215
|
+
"audio"
|
|
14216
|
+
].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
|
|
14217
|
+
if (truncated.length === 0) return;
|
|
14218
|
+
deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
|
|
14219
|
+
deviceId,
|
|
14220
|
+
kinds: truncated,
|
|
14221
|
+
limit: VISIT_EVENT_FETCH_LIMIT
|
|
14222
|
+
});
|
|
14223
|
+
}
|
|
14224
|
+
/** The operator's acceptance criterion as a number: a rising count on one
|
|
14225
|
+
* camera is its motion-snapshot capturer failing, not a quiet day. */
|
|
14226
|
+
function reportUnvouchedClips(deps, deviceId, clips) {
|
|
14227
|
+
const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
|
|
14228
|
+
if (clipsWithoutStill === 0) return;
|
|
14229
|
+
deps.logInfo?.("videoclips: visit has no vouched still", {
|
|
14230
|
+
deviceId,
|
|
14231
|
+
clipsWithoutStill,
|
|
14232
|
+
clips: clips.length
|
|
14233
|
+
});
|
|
14234
|
+
}
|
|
14235
|
+
//#endregion
|
|
14236
|
+
//#region src/recorder/addon/videoclips-shared.ts
|
|
14237
|
+
/** Stringify the numeric event ids of one bucket. Lossless. */
|
|
14238
|
+
function toEventLike(events) {
|
|
14239
|
+
return events.map((e) => ({
|
|
14240
|
+
...e,
|
|
14241
|
+
id: String(e.id)
|
|
14242
|
+
}));
|
|
14243
|
+
}
|
|
14244
|
+
//#endregion
|
|
14245
|
+
//#region src/recorder/addon/videoclips-wiring.ts
|
|
14246
|
+
/**
|
|
14247
|
+
* The `videoclips` source over THIS addon's archive — the deps half.
|
|
14248
|
+
*
|
|
14249
|
+
* The composer (`videoclips-provider.ts`, `videoclip-visits.ts`) is unchanged
|
|
14250
|
+
* and moved here verbatim from `addon-post-analysis`: a clip is still a time
|
|
14251
|
+
* WINDOW over recorded footage, visits are still clustered at
|
|
14252
|
+
* `VISIT_MERGE_GAP_MS`, and `getClipPlayback` still resolves through
|
|
14253
|
+
* `recording.getPlaybackManifest`. What moved is WHO registers it — the addon
|
|
14254
|
+
* that owns the footage, not one that borrowed it (D560).
|
|
14255
|
+
*
|
|
14256
|
+
* All four reads go over `ctx.api`, the recorder's own two included. That is
|
|
14257
|
+
* not laziness:
|
|
14258
|
+
*
|
|
14259
|
+
* - `recorder` is `placement: 'any-node'`, so several nodes register this
|
|
14260
|
+
* capability and the hub's collection dispatcher resolves the binding to
|
|
14261
|
+
* ONE of them (`addonIdentityOf` strips the `@<nodeId>`). That registration
|
|
14262
|
+
* is not necessarily the node holding this camera's segments.
|
|
14263
|
+
* - `ctx.api.recording.*` is device-scoped and routes to the node that IS
|
|
14264
|
+
* holding them. A short cut to the local `RecordingProvider` would answer a
|
|
14265
|
+
* camera recorded on little-unraid out of the hub's empty index — an empty
|
|
14266
|
+
* clip list on a camera with weeks of footage, which is the exact
|
|
14267
|
+
* misreading this whole line of work exists to end.
|
|
14268
|
+
*
|
|
14269
|
+
* The events arriving over a process boundary is the new failure mode, and the
|
|
14270
|
+
* provider answers it per branch — see `listWithoutEvents`.
|
|
14271
|
+
*/
|
|
14272
|
+
/**
|
|
14273
|
+
* Every log line about a camera carries `tags: { deviceId }`, always the
|
|
14274
|
+
* numeric id. A miss rate or a media gap is always asked per camera, and a line
|
|
14275
|
+
* that omits the tag cannot answer it.
|
|
14276
|
+
*/
|
|
14277
|
+
function stamp(logger, level) {
|
|
14278
|
+
return (message, meta) => {
|
|
14279
|
+
logger[level](message, {
|
|
14280
|
+
tags: { deviceId: Number(meta["deviceId"]) },
|
|
14281
|
+
meta
|
|
14282
|
+
});
|
|
14283
|
+
};
|
|
14284
|
+
}
|
|
14285
|
+
function createRecorderVideoclipsProvider(deps) {
|
|
14286
|
+
const api = deps.api;
|
|
14287
|
+
return createVideoclipsProvider({
|
|
14288
|
+
defaultPad: EVENT_PAD_MS,
|
|
14289
|
+
fetchEvents: async ({ deviceId, since, until, limit }) => {
|
|
14290
|
+
const query = {
|
|
14291
|
+
deviceId,
|
|
14292
|
+
since,
|
|
14293
|
+
until,
|
|
14294
|
+
limit,
|
|
14295
|
+
projection: "slim"
|
|
14296
|
+
};
|
|
14297
|
+
const [motion, object, audio] = await Promise.all([
|
|
14298
|
+
api.pipelineAnalytics.getMotionEvents.query(query),
|
|
14299
|
+
api.pipelineAnalytics.getObjectEvents.query(query),
|
|
14300
|
+
api.pipelineAnalytics.getAudioEvents.query(query)
|
|
14301
|
+
]);
|
|
14302
|
+
return {
|
|
14303
|
+
motion: toEventLike(motion),
|
|
14304
|
+
object: toEventLike(object),
|
|
14305
|
+
audio: toEventLike(audio)
|
|
14306
|
+
};
|
|
14307
|
+
},
|
|
14308
|
+
fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => new Set(await api.pipelineAnalytics.ownersWithMedia.query({
|
|
14309
|
+
deviceId,
|
|
14310
|
+
ownerKind,
|
|
14311
|
+
ownerIds
|
|
14312
|
+
})),
|
|
14313
|
+
fetchAvailability: async ({ deviceId, since, until }) => (await api.recording.getAvailability.query({
|
|
14314
|
+
deviceId,
|
|
14315
|
+
fromMs: since,
|
|
14316
|
+
toMs: until
|
|
14317
|
+
})).ranges,
|
|
14318
|
+
logWarn: stamp(deps.logger, "warn"),
|
|
14319
|
+
logInfo: stamp(deps.logger, "info"),
|
|
14320
|
+
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
14321
|
+
const manifest = await api.recording.getPlaybackManifest.query({
|
|
14322
|
+
deviceId,
|
|
14323
|
+
fromMs: startMs,
|
|
14324
|
+
toMs: endMs
|
|
14325
|
+
});
|
|
14326
|
+
if (manifest.playbackUrl == null) throw new Error(`videoclips: no footage for clip window [${String(startMs)},${String(endMs)}] on device ${String(deviceId)}`);
|
|
14327
|
+
return {
|
|
14328
|
+
playbackUrl: manifest.playbackUrl,
|
|
14329
|
+
...manifest.playbackEndpoints === void 0 ? {} : { playbackEndpoints: [...manifest.playbackEndpoints] }
|
|
14330
|
+
};
|
|
14331
|
+
}
|
|
14332
|
+
});
|
|
14333
|
+
}
|
|
14334
|
+
//#endregion
|
|
13492
14335
|
//#region src/recorder/band-engine.ts
|
|
13493
14336
|
function toMin(hhmm) {
|
|
13494
14337
|
const [h, m] = hhmm.split(":");
|
|
@@ -16714,6 +17557,81 @@ function makeBrokerCall(handle) {
|
|
|
16714
17557
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
16715
17558
|
}
|
|
16716
17559
|
//#endregion
|
|
17560
|
+
//#region src/recorder/addon/export-clip-catalog.ts
|
|
17561
|
+
/**
|
|
17562
|
+
* Catalog page size for the confirm.
|
|
17563
|
+
*
|
|
17564
|
+
* One local day is the window (`ExportClipSubject.catalogWindow`) and a busy
|
|
17565
|
+
* day on 592 measured 546 rows, so 1 000 holds a day with room and still
|
|
17566
|
+
* bounds a pathological camera. A truncated page would read as "this clip is
|
|
17567
|
+
* gone", which is the one answer this gate must never give wrongly.
|
|
17568
|
+
*/
|
|
17569
|
+
var EXPORT_CLIP_CATALOG_LIMIT = 1e3;
|
|
17570
|
+
/**
|
|
17571
|
+
* Which twin this row can actually serve for the asked profile.
|
|
17572
|
+
*
|
|
17573
|
+
* The mapping is the one D549 15 already fixed — `low | mid` → the sub file,
|
|
17574
|
+
* `high` → the main twin — and both ids are resolved at LISTING time, so this
|
|
17575
|
+
* never re-searches the camera. A row that holds only one twin answers with
|
|
17576
|
+
* the one it has and the caller SAYS so: the ask stays on `subject.profile`
|
|
17577
|
+
* and this lands on the record's top-level `profile`, so a week later the row
|
|
17578
|
+
* still tells the operator he was handed the sub stream.
|
|
17579
|
+
*
|
|
17580
|
+
* A row with no `streams` block at all is not a fallback and not a guess: the
|
|
17581
|
+
* ask is carried through and the producer's own `served` is what corrects it.
|
|
17582
|
+
*/
|
|
17583
|
+
function servedTwinFor(clip, asked) {
|
|
17584
|
+
const streams = clip.streams;
|
|
17585
|
+
if (streams === void 0) return asked;
|
|
17586
|
+
const hasMain = streams.main !== void 0;
|
|
17587
|
+
const hasSub = streams.sub !== void 0;
|
|
17588
|
+
if (asked === "high") return hasMain ? "high" : hasSub ? "low" : asked;
|
|
17589
|
+
return hasSub ? asked : hasMain ? "high" : asked;
|
|
17590
|
+
}
|
|
17591
|
+
/**
|
|
17592
|
+
* Judge one catalog answer for one clip subject. Pure — the I/O is the
|
|
17593
|
+
* caller's, so every refusal below is unit-testable without a camera.
|
|
17594
|
+
*/
|
|
17595
|
+
function judgeClipRow(subject, rows) {
|
|
17596
|
+
const clip = rows.find((row) => row.id === subject.clipId);
|
|
17597
|
+
if (clip === void 0) return {
|
|
17598
|
+
ok: false,
|
|
17599
|
+
refusal: {
|
|
17600
|
+
code: "catalog-miss",
|
|
17601
|
+
detail: `"${subject.sourceLabel}" does not list ${subject.clipId} between ${subject.catalogWindow.sinceMs} and ${subject.catalogWindow.untilMs}`
|
|
17602
|
+
}
|
|
17603
|
+
};
|
|
17604
|
+
if (clip.playable === false) return {
|
|
17605
|
+
ok: false,
|
|
17606
|
+
refusal: {
|
|
17607
|
+
code: "no-file-for-window",
|
|
17608
|
+
detail: clip.unplayableReason ?? "no bytes"
|
|
17609
|
+
}
|
|
17610
|
+
};
|
|
17611
|
+
if (clip.inProgress === true) return {
|
|
17612
|
+
ok: false,
|
|
17613
|
+
refusal: {
|
|
17614
|
+
code: "clip-in-progress",
|
|
17615
|
+
detail: "the camera is still writing this recording, so it has no end to export yet"
|
|
17616
|
+
}
|
|
17617
|
+
};
|
|
17618
|
+
const { startMs, endMs } = clip.timeRange;
|
|
17619
|
+
if (!(startMs < endMs)) return {
|
|
17620
|
+
ok: false,
|
|
17621
|
+
refusal: {
|
|
17622
|
+
code: "catalog-miss",
|
|
17623
|
+
detail: `the row carries no usable span (${startMs}..${endMs})`
|
|
17624
|
+
}
|
|
17625
|
+
};
|
|
17626
|
+
return {
|
|
17627
|
+
ok: true,
|
|
17628
|
+
accepted: {
|
|
17629
|
+
clip,
|
|
17630
|
+
served: servedTwinFor(clip, subject.profile)
|
|
17631
|
+
}
|
|
17632
|
+
};
|
|
17633
|
+
}
|
|
17634
|
+
//#endregion
|
|
16717
17635
|
//#region src/recorder/addon/recording-export-provider.ts
|
|
16718
17636
|
/** Content type of every rendered export — the engine only ever writes MP4. */
|
|
16719
17637
|
var EXPORT_CONTENT_TYPE = "video/mp4";
|
|
@@ -16741,12 +17659,6 @@ function exportFileName(rec) {
|
|
|
16741
17659
|
async function hasFootage(index, deviceId, profile, fromMs, toMs) {
|
|
16742
17660
|
return (await index.segmentsInWindow(deviceId, profile, fromMs, toMs)).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
|
|
16743
17661
|
}
|
|
16744
|
-
/** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
|
|
16745
|
-
function resolveExportProfiles(input) {
|
|
16746
|
-
if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
|
|
16747
|
-
if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
|
|
16748
|
-
return [];
|
|
16749
|
-
}
|
|
16750
17662
|
/** Load a record or throw a clear NOT_FOUND-style error. */
|
|
16751
17663
|
async function requireRecord(state, exportId) {
|
|
16752
17664
|
const rec = await getExportRecord(state, exportId);
|
|
@@ -16755,37 +17667,133 @@ async function requireRecord(state, exportId) {
|
|
|
16755
17667
|
}
|
|
16756
17668
|
/** Build the `recordingExport` provider over the injected deps. */
|
|
16757
17669
|
function buildRecordingExportProvider(deps) {
|
|
17670
|
+
/**
|
|
17671
|
+
* Persist one `queued` row and hand it to the engine. The ONE place a record
|
|
17672
|
+
* is born, for both kinds — so the fields nothing about the subject decides
|
|
17673
|
+
* (the lifetime, the audit stamp, the null progress) cannot drift apart.
|
|
17674
|
+
*/
|
|
17675
|
+
const queueOne = async (fields, options, now) => {
|
|
17676
|
+
const saved = await upsertExport(deps.state, {
|
|
17677
|
+
...fields,
|
|
17678
|
+
id: deps.newId(),
|
|
17679
|
+
options,
|
|
17680
|
+
state: "queued",
|
|
17681
|
+
progressPct: null,
|
|
17682
|
+
fileBytes: null,
|
|
17683
|
+
expiresAt: now + options.maxLifeMs,
|
|
17684
|
+
deleteAfterDownload: options.deleteAfterDownload,
|
|
17685
|
+
downloadedAt: null,
|
|
17686
|
+
createdAt: now,
|
|
17687
|
+
createdBy: deps.createdBy(),
|
|
17688
|
+
error: null
|
|
17689
|
+
});
|
|
17690
|
+
deps.engine.enqueue(saved.id);
|
|
17691
|
+
return saved;
|
|
17692
|
+
};
|
|
17693
|
+
/**
|
|
17694
|
+
* The clip branch's fail-fast gate — the catalog, never the recording index.
|
|
17695
|
+
*
|
|
17696
|
+
* Refuses BEFORE a row exists, so an operator gets the reason in the answer
|
|
17697
|
+
* to his own tap instead of discovering it on a `failed` row a minute later,
|
|
17698
|
+
* and so a refusal never spends a wake.
|
|
17699
|
+
*/
|
|
17700
|
+
const createClipExport = async (subject, options) => {
|
|
17701
|
+
const tags = { deviceId: subject.deviceId };
|
|
17702
|
+
if (options.timelapse !== void 0 || options.speed !== void 0 && options.speed !== 1) {
|
|
17703
|
+
const detail = "a clip is remuxed, never re-timed — drop speed/timelapse";
|
|
17704
|
+
deps.logger.warn("clip export refused: a render option a remux cannot honour", {
|
|
17705
|
+
tags,
|
|
17706
|
+
meta: {
|
|
17707
|
+
clipId: subject.clipId,
|
|
17708
|
+
speed: options.speed,
|
|
17709
|
+
timelapse: options.timelapse
|
|
17710
|
+
}
|
|
17711
|
+
});
|
|
17712
|
+
throw new Error(clipExportFailure("unsupported-option", detail));
|
|
17713
|
+
}
|
|
17714
|
+
let rows;
|
|
17715
|
+
try {
|
|
17716
|
+
rows = await deps.clipCatalog.listClips({
|
|
17717
|
+
deviceId: subject.deviceId,
|
|
17718
|
+
since: subject.catalogWindow.sinceMs,
|
|
17719
|
+
until: subject.catalogWindow.untilMs,
|
|
17720
|
+
provider: subject.provider,
|
|
17721
|
+
limit: EXPORT_CLIP_CATALOG_LIMIT
|
|
17722
|
+
});
|
|
17723
|
+
} catch (err) {
|
|
17724
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
17725
|
+
deps.logger.warn("clip export refused: the clip catalog could not be read", {
|
|
17726
|
+
tags,
|
|
17727
|
+
meta: {
|
|
17728
|
+
clipId: subject.clipId,
|
|
17729
|
+
provider: subject.provider,
|
|
17730
|
+
error: detail
|
|
17731
|
+
}
|
|
17732
|
+
});
|
|
17733
|
+
throw new Error(clipExportFailure("catalog-unreachable", detail), { cause: err });
|
|
17734
|
+
}
|
|
17735
|
+
const outcome = judgeClipRow(subject, rows);
|
|
17736
|
+
if (!outcome.ok) {
|
|
17737
|
+
deps.logger.warn("clip export refused by the catalog gate", {
|
|
17738
|
+
tags,
|
|
17739
|
+
meta: {
|
|
17740
|
+
clipId: subject.clipId,
|
|
17741
|
+
provider: subject.provider,
|
|
17742
|
+
reason: outcome.refusal.code,
|
|
17743
|
+
detail: outcome.refusal.detail
|
|
17744
|
+
}
|
|
17745
|
+
});
|
|
17746
|
+
throw new Error(clipExportFailure(outcome.refusal.code, outcome.refusal.detail));
|
|
17747
|
+
}
|
|
17748
|
+
const { clip, served } = outcome.accepted;
|
|
17749
|
+
const record = await queueOne({
|
|
17750
|
+
deviceId: subject.deviceId,
|
|
17751
|
+
profile: served,
|
|
17752
|
+
fromMs: clip.timeRange.startMs,
|
|
17753
|
+
toMs: clip.timeRange.endMs,
|
|
17754
|
+
subject
|
|
17755
|
+
}, options, deps.now());
|
|
17756
|
+
deps.logger.info("clip export queued", {
|
|
17757
|
+
tags,
|
|
17758
|
+
meta: {
|
|
17759
|
+
exportId: record.id,
|
|
17760
|
+
clipId: subject.clipId,
|
|
17761
|
+
source: subject.source,
|
|
17762
|
+
provider: subject.provider,
|
|
17763
|
+
askedProfile: subject.profile,
|
|
17764
|
+
servedProfile: served,
|
|
17765
|
+
wake: subject.wake ?? "none",
|
|
17766
|
+
fromMs: record.fromMs,
|
|
17767
|
+
toMs: record.toMs
|
|
17768
|
+
}
|
|
17769
|
+
});
|
|
17770
|
+
return record;
|
|
17771
|
+
};
|
|
16758
17772
|
return {
|
|
16759
17773
|
createExport: async (input) => {
|
|
16760
|
-
const
|
|
16761
|
-
|
|
16762
|
-
|
|
17774
|
+
const subject = resolveCreateExportSubject(input);
|
|
17775
|
+
if (subject.kind === "clip") return await createClipExport(subject, input.options);
|
|
17776
|
+
const { deviceId, profiles, fromMs, toMs } = subject;
|
|
17777
|
+
const { options } = input;
|
|
16763
17778
|
if (!(fromMs < toMs)) throw new Error(`recording-export: invalid range ${fromMs}..${toMs}`);
|
|
16764
17779
|
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}`);
|
|
16765
17780
|
const now = deps.now();
|
|
16766
|
-
|
|
16767
|
-
|
|
16768
|
-
|
|
16769
|
-
|
|
16770
|
-
|
|
16771
|
-
|
|
16772
|
-
|
|
16773
|
-
|
|
16774
|
-
|
|
16775
|
-
|
|
16776
|
-
|
|
16777
|
-
|
|
16778
|
-
|
|
16779
|
-
|
|
16780
|
-
|
|
16781
|
-
|
|
16782
|
-
createdBy: deps.createdBy(),
|
|
16783
|
-
error: null
|
|
16784
|
-
};
|
|
16785
|
-
const saved = await upsertExport(deps.state, record);
|
|
16786
|
-
deps.engine.enqueue(saved.id);
|
|
16787
|
-
first ??= saved;
|
|
16788
|
-
}
|
|
17781
|
+
const [head, ...rest] = profiles;
|
|
17782
|
+
if (head === void 0) throw new Error("recording-export: profiles must contain at least one profile");
|
|
17783
|
+
const first = await queueOne({
|
|
17784
|
+
deviceId,
|
|
17785
|
+
profile: head,
|
|
17786
|
+
fromMs,
|
|
17787
|
+
toMs,
|
|
17788
|
+
subject
|
|
17789
|
+
}, options, now);
|
|
17790
|
+
for (const profile of rest) await queueOne({
|
|
17791
|
+
deviceId,
|
|
17792
|
+
profile,
|
|
17793
|
+
fromMs,
|
|
17794
|
+
toMs,
|
|
17795
|
+
subject
|
|
17796
|
+
}, options, now);
|
|
16789
17797
|
return first;
|
|
16790
17798
|
},
|
|
16791
17799
|
listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
|
|
@@ -18560,7 +19568,19 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18560
19568
|
outPath: exportOutPath,
|
|
18561
19569
|
statBytes: async (p) => (await promises.stat(p)).size,
|
|
18562
19570
|
removeFile: removeExportFile,
|
|
18563
|
-
emit: (category, data) => this.emitExportEvent(category, data)
|
|
19571
|
+
emit: (category, data) => this.emitExportEvent(category, data),
|
|
19572
|
+
now: () => Date.now(),
|
|
19573
|
+
fetchClipBytes: async (req) => await this.ctx.api.videoclips.readClipBytes.query({
|
|
19574
|
+
deviceId: req.deviceId,
|
|
19575
|
+
clipId: req.clipId,
|
|
19576
|
+
profile: req.profile,
|
|
19577
|
+
maxBytes: req.maxBytes,
|
|
19578
|
+
...req.wake !== void 0 ? { wake: req.wake } : {}
|
|
19579
|
+
}),
|
|
19580
|
+
writeExportFile: async (p, bytes) => {
|
|
19581
|
+
await promises.mkdir(path.dirname(p), { recursive: true });
|
|
19582
|
+
await promises.writeFile(p, bytes);
|
|
19583
|
+
}
|
|
18564
19584
|
});
|
|
18565
19585
|
const exportProvider = buildRecordingExportProvider({
|
|
18566
19586
|
state: exportsState,
|
|
@@ -18569,6 +19589,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18569
19589
|
if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
|
|
18570
19590
|
return this.index.segments(deviceId, profile);
|
|
18571
19591
|
} },
|
|
19592
|
+
clipCatalog: { listClips: async (input) => await this.ctx.api.videoclips.listClips.query(input) },
|
|
18572
19593
|
now: () => Date.now(),
|
|
18573
19594
|
newId: () => randomUUID(),
|
|
18574
19595
|
createdBy: () => "operator",
|
|
@@ -18646,6 +19667,13 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18646
19667
|
capability: recordingCapability,
|
|
18647
19668
|
provider: recordingProvider
|
|
18648
19669
|
},
|
|
19670
|
+
{
|
|
19671
|
+
capability: videoclipsCapability,
|
|
19672
|
+
provider: createRecorderVideoclipsProvider({
|
|
19673
|
+
api: this.ctx.api,
|
|
19674
|
+
logger: this.ctx.logger
|
|
19675
|
+
})
|
|
19676
|
+
},
|
|
18649
19677
|
{
|
|
18650
19678
|
capability: storageEvictableCapability,
|
|
18651
19679
|
provider: evictableProvider
|