@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.
Files changed (32) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-CAWhHQo2.js → default-detection-model-6lCvep7n.js} +1 -1
  4. package/dist/{default-detection-model-BXwE-Dvo.mjs → default-detection-model-dQSwHHfx.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-g5MfpiBh.mjs → dist-C1pCVJk7.mjs} +440 -32
  8. package/dist/{dist-k6Vu-AhZ.js → dist-NYjn7hkE.js} +469 -31
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-BXsHQH5h.mjs → node--UY0iepi.mjs} +1 -1
  12. package/dist/{node--u_jUObS.js → node-CVYY8a4y.js} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-Gl3EowL-.mjs → process-memory-BQOfL3Ig.mjs} +1 -1
  16. package/dist/{process-memory-DN7opum2.js → process-memory-DDvuSEMJ.js} +1 -1
  17. package/dist/recorder/index.js +572 -40
  18. package/dist/recorder/index.mjs +572 -40
  19. package/dist/{segment-demux-js-sYP_jndp.mjs → segment-demux-js-B0_aAzN0.mjs} +1 -1
  20. package/dist/{segment-demux-js-DHX8o8pI.js → segment-demux-js-DVupBBc4.js} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. 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
  23. 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
  24. 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
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-UKNN4qes.mjs → hostInit-C8TnCvWj.mjs} +2 -2
  28. package/dist/stream-broker/index.js +526 -69
  29. package/dist/stream-broker/index.mjs +526 -69
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. 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
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-k6Vu-AhZ.js");
7
- const require_node = require("../node--u_jUObS.js");
6
+ const require_dist = require("../dist-NYjn7hkE.js");
7
+ const require_node = require("../node-CVYY8a4y.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
9
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
@@ -6129,6 +6129,21 @@ function errnoCode(raw) {
6129
6129
  }
6130
6130
  //#endregion
6131
6131
  //#region src/recorder/addon/export-engine.ts
6132
+ /**
6133
+ * How long an operator's authorised wake stays spendable.
6134
+ *
6135
+ * One authorised yes is ONE wake (D558 § 5.2 rule 3), and the two ways a job
6136
+ * outlives its yes without anybody meaning it are both real here: it can sit
6137
+ * behind {@link EXPORT_MAX_CONCURRENT} long renders, and it can be re-queued
6138
+ * after a restart. Five minutes is generous against a job measured at 4–6 s
6139
+ * end to end and short enough that a camera is never woken for a tap the
6140
+ * operator has forgotten making.
6141
+ *
6142
+ * Past it the export FAILS with `wake-expired` — a named row the operator can
6143
+ * retry with one tap, which re-asks for the authorisation — rather than
6144
+ * silently waking the camera on a yes from an hour ago.
6145
+ */
6146
+ var CLIP_WAKE_TTL_MS = 5 * 6e4;
6132
6147
  /** Stderr lines retained for the failure LOG. ffmpeg prints one line per failed
6133
6148
  * segment before the decisive "Error opening input" summary, so a short tail can
6134
6149
  * push the actual cause out of the window (it very nearly did — the line that
@@ -6137,6 +6152,42 @@ function errnoCode(raw) {
6137
6152
  var STDERR_TAIL_LINES$1 = 40;
6138
6153
  var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
6139
6154
  /**
6155
+ * How far a delivered clip's duration may sit from its catalog span before it
6156
+ * is worth a line. A remux rounds, a GOP boundary moves a fraction of a
6157
+ * second; two seconds is past both and well under D568's own "materially
6158
+ * shorter" retry threshold, so this reports a disagreement the PRODUCER
6159
+ * already decided to serve rather than second-guessing its completion rule.
6160
+ */
6161
+ var CLIP_DURATION_TOLERANCE_MS = 2e3;
6162
+ /**
6163
+ * The source id of the recorder's OWN `videoclips` provider — a visit over
6164
+ * footage we wrote, not a file a camera holds (D560).
6165
+ *
6166
+ * Duplicating the literal rather than importing `videoclips-provider.ts`
6167
+ * keeps the engine free of the provider (and of the analytics event reads it
6168
+ * carries); the two are pinned together by
6169
+ * `export-engine-clip-fetch.spec.ts`.
6170
+ */
6171
+ var ANALYTICS_CLIP_SOURCE = "analytics";
6172
+ /**
6173
+ * Read a producer's refusal as one of the named clip-export failures.
6174
+ *
6175
+ * A producer that speaks the vocabulary is believed — `clipExportFailureOf`
6176
+ * recovers its token, so a `sleeping` stays `sleeping` all the way to the
6177
+ * modal. Anything else is `fetch-failed` **with the producer's words kept
6178
+ * verbatim**: inventing a more specific token from prose is how a surface's
6179
+ * table came to render every refusal on this fleet as `Refused: <prose>`
6180
+ * (ClipStillRefusalCode's own docblock). The one exception is the size bound,
6181
+ * which the producer states in numbers rather than in a token, and which the
6182
+ * operator must be able to tell from a network failure.
6183
+ */
6184
+ function classifyClipFetchFailure(detail) {
6185
+ const spoken = require_dist.clipExportFailureOf(detail);
6186
+ if (spoken !== null) return spoken;
6187
+ if (/too large/i.test(detail)) return "too-large-to-transfer";
6188
+ return "fetch-failed";
6189
+ }
6190
+ /**
6140
6191
  * The floor under which an MP4 cannot hold a single decodable frame.
6141
6192
  *
6142
6193
  * **Exit 0 is not the same question as "the file holds a video".** A timelapse
@@ -6218,6 +6269,13 @@ var ExportEngine = class {
6218
6269
  }
6219
6270
  }
6220
6271
  async render(id) {
6272
+ const existing = await getExportRecord(this.deps.state, id);
6273
+ if (!existing) return;
6274
+ const subject = require_dist.exportSubjectOf(existing);
6275
+ if (subject.kind === "clip" && subject.source !== ANALYTICS_CLIP_SOURCE) {
6276
+ await this.fetchClip(id, existing, subject);
6277
+ return;
6278
+ }
6221
6279
  const rec = await patchExport(this.deps.state, id, {
6222
6280
  state: "rendering",
6223
6281
  progressPct: 0
@@ -6246,6 +6304,187 @@ var ExportEngine = class {
6246
6304
  }
6247
6305
  }
6248
6306
  /**
6307
+ * The CLIP branch: one by-handle fetch, under a bound, written straight to
6308
+ * the export path. No playlist, no ffmpeg, no second completion rule.
6309
+ *
6310
+ * The recorder cannot produce these bytes itself — talking to the camera is
6311
+ * the owning addon's monopoly (one session per host, the sleep gate before
6312
+ * `getApi()`, a cmd-5 transfer whose completion is an idle window proved
6313
+ * against the catalog span, D549 17 / D568) — and there is no addon→addon
6314
+ * byte transport, so the producer's own cap read is the seam (D558 § 3).
6315
+ *
6316
+ * `progressPct` stays `null` for the whole fetch. The job is measured at
6317
+ * 4–6 s end to end and only two of its phases have a real denominator; a
6318
+ * ramp would be a number nothing measured (D558 § 5.1).
6319
+ *
6320
+ * Every exit is named. A branch that accepts work and produces nothing is
6321
+ * the branch this repo keeps losing work in (D391), and a clip export has
6322
+ * five such branches a footage render does not have.
6323
+ */
6324
+ async fetchClip(id, rec, subject) {
6325
+ const tags = { deviceId: rec.deviceId };
6326
+ const now = (this.deps.now ?? Date.now)();
6327
+ if (subject.wake !== void 0 && now - rec.createdAt > 3e5) {
6328
+ const waitedMs = now - rec.createdAt;
6329
+ this.deps.logger.warn("clip export refused: it outlived the wake it was granted", {
6330
+ tags,
6331
+ meta: {
6332
+ exportId: id,
6333
+ clipId: subject.clipId,
6334
+ waitedMs,
6335
+ ttlMs: CLIP_WAKE_TTL_MS
6336
+ }
6337
+ });
6338
+ await this.fail(id, require_dist.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`));
6339
+ return;
6340
+ }
6341
+ const fetchClipBytes = this.deps.fetchClipBytes;
6342
+ const writeExportFile = this.deps.writeExportFile;
6343
+ if (fetchClipBytes === void 0 || writeExportFile === void 0) {
6344
+ this.deps.logger.error("clip export refused: this recorder has no clip byte reader wired", {
6345
+ tags,
6346
+ meta: {
6347
+ exportId: id,
6348
+ clipId: subject.clipId
6349
+ }
6350
+ });
6351
+ await this.fail(id, require_dist.clipExportFailure("fetch-failed", "this recorder has no clip byte reader wired"));
6352
+ return;
6353
+ }
6354
+ await patchExport(this.deps.state, id, {
6355
+ state: "rendering",
6356
+ progressPct: null
6357
+ });
6358
+ this.deps.logger.info("clip export fetch starting", {
6359
+ tags,
6360
+ meta: {
6361
+ exportId: id,
6362
+ clipId: subject.clipId,
6363
+ source: subject.source,
6364
+ provider: subject.provider,
6365
+ profile: subject.profile,
6366
+ wake: subject.wake ?? "none"
6367
+ }
6368
+ });
6369
+ let answer;
6370
+ try {
6371
+ answer = await fetchClipBytes({
6372
+ deviceId: subject.deviceId,
6373
+ clipId: subject.clipId,
6374
+ profile: subject.profile,
6375
+ maxBytes: require_dist.RECORDING_EXPORT_MAX_READ_BYTES,
6376
+ ...subject.wake !== void 0 ? { wake: subject.wake } : {}
6377
+ });
6378
+ } catch (err) {
6379
+ const detail = err instanceof Error ? err.message : String(err);
6380
+ const code = classifyClipFetchFailure(detail);
6381
+ this.deps.logger.warn("clip export fetch failed", {
6382
+ tags,
6383
+ meta: {
6384
+ exportId: id,
6385
+ clipId: subject.clipId,
6386
+ reason: code,
6387
+ error: detail
6388
+ }
6389
+ });
6390
+ await this.fail(id, require_dist.clipExportFailure(code, detail));
6391
+ return;
6392
+ }
6393
+ if (this.cancelled.has(id)) {
6394
+ this.cancelled.delete(id);
6395
+ await this.fail(id, "cancelled");
6396
+ return;
6397
+ }
6398
+ const bytes = Buffer.from(answer.base64, "base64");
6399
+ if (bytes.byteLength > 52428800) {
6400
+ this.deps.logger.warn("clip export refused: the answer exceeded the transfer bound", {
6401
+ tags,
6402
+ meta: {
6403
+ exportId: id,
6404
+ bytes: bytes.byteLength,
6405
+ maxBytes: require_dist.RECORDING_EXPORT_MAX_READ_BYTES
6406
+ }
6407
+ });
6408
+ await this.fail(id, require_dist.clipExportFailure("too-large-to-transfer", `${bytes.byteLength} > ${require_dist.RECORDING_EXPORT_MAX_READ_BYTES} bytes`));
6409
+ return;
6410
+ }
6411
+ if (bytes.byteLength < 1024) {
6412
+ this.deps.logger.error("clip export produced no video", {
6413
+ tags,
6414
+ meta: {
6415
+ exportId: id,
6416
+ clipId: subject.clipId,
6417
+ bytes: bytes.byteLength,
6418
+ minBytes: EXPORT_MIN_PLAUSIBLE_BYTES
6419
+ }
6420
+ });
6421
+ await this.fail(id, require_dist.clipExportFailure("fetch-failed", `the source answered ${bytes.byteLength} bytes, under the ${EXPORT_MIN_PLAUSIBLE_BYTES} a playable MP4 needs`));
6422
+ return;
6423
+ }
6424
+ const outPath = this.deps.outPath(rec);
6425
+ try {
6426
+ await writeExportFile(outPath, bytes);
6427
+ } catch (err) {
6428
+ const detail = err instanceof Error ? err.message : String(err);
6429
+ this.deps.logger.error("clip export could not be written", {
6430
+ tags,
6431
+ meta: {
6432
+ exportId: id,
6433
+ outPath,
6434
+ error: detail
6435
+ }
6436
+ });
6437
+ await this.deps.removeFile(outPath);
6438
+ await this.fail(id, require_dist.clipExportFailure("fetch-failed", `could not write the export: ${detail}`));
6439
+ return;
6440
+ }
6441
+ const servedPatch = answer.served !== rec.profile ? { profile: answer.served } : {};
6442
+ if (answer.served !== rec.profile) this.deps.logger.warn("clip export served a different twin than the catalog projected", {
6443
+ tags,
6444
+ meta: {
6445
+ exportId: id,
6446
+ clipId: subject.clipId,
6447
+ asked: subject.profile,
6448
+ projected: rec.profile,
6449
+ served: answer.served
6450
+ }
6451
+ });
6452
+ const claimedMs = rec.toMs - rec.fromMs;
6453
+ 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", {
6454
+ tags,
6455
+ meta: {
6456
+ exportId: id,
6457
+ clipId: subject.clipId,
6458
+ catalogSpanMs: claimedMs,
6459
+ deliveredMs: answer.durationMs
6460
+ }
6461
+ });
6462
+ await patchExport(this.deps.state, id, {
6463
+ state: "ready",
6464
+ progressPct: 100,
6465
+ fileBytes: bytes.byteLength,
6466
+ error: null,
6467
+ ...servedPatch
6468
+ });
6469
+ this.deps.logger.info("clip export ready", {
6470
+ tags,
6471
+ meta: {
6472
+ exportId: id,
6473
+ clipId: subject.clipId,
6474
+ outPath,
6475
+ bytes: bytes.byteLength,
6476
+ served: answer.served
6477
+ }
6478
+ });
6479
+ this.deps.emit(require_dist.EventCategory.RecordingExportCompleted, {
6480
+ exportId: id,
6481
+ fileBytes: bytes.byteLength,
6482
+ deviceId: rec.deviceId,
6483
+ ...rec.options.title !== void 0 ? { title: rec.options.title } : {},
6484
+ ...rec.options.notifyTargetIds !== void 0 && rec.options.notifyTargetIds.length > 0 ? { notifyTargetIds: rec.options.notifyTargetIds } : {}
6485
+ });
6486
+ }
6487
+ /**
6249
6488
  * The record's options with any dense overlay moved onto ffmpeg's clock.
6250
6489
  *
6251
6490
  * The translation is reported on EVERY render that carries an overlay, not
@@ -6681,6 +6920,22 @@ function isInterrupted(record) {
6681
6920
  return record.state === "queued" || record.state === "rendering";
6682
6921
  }
6683
6922
  /**
6923
+ * Did this row carry an operator's authorised WAKE?
6924
+ *
6925
+ * A re-queue is the third of the three ways D558 § 5.2 rule 3 names for
6926
+ * spending one yes twice, and it is the quietest: the row reaches the fetch
6927
+ * hours after the tap, wakes the camera, and nothing in the log says the
6928
+ * operator did not ask for it. So a clip export holding a wake is FAILED by
6929
+ * name instead — a row he can retry with one tap, which re-asks for the
6930
+ * authorisation. A clip export WITHOUT a wake is re-queued like any render:
6931
+ * there is nothing to spend, and a sleeping camera refuses at its own gate
6932
+ * exactly as it did the first time.
6933
+ */
6934
+ function carriesAnAuthorisedWake(record) {
6935
+ const subject = require_dist.exportSubjectOf(record);
6936
+ return subject.kind === "clip" && subject.wake !== void 0;
6937
+ }
6938
+ /**
6684
6939
  * Hand every interrupted row back to the engine, once, at boot.
6685
6940
  *
6686
6941
  * A row past its `expiresAt` is FAILED rather than re-rendered: hours of
@@ -6708,6 +6963,19 @@ async function reconcileInterruptedExports(deps) {
6708
6963
  toMs: row.toMs,
6709
6964
  createdAt: row.createdAt
6710
6965
  };
6966
+ if (carriesAnAuthorisedWake(row)) {
6967
+ expired += 1;
6968
+ await patchExport(deps.state, row.id, {
6969
+ state: "failed",
6970
+ progressPct: null,
6971
+ error: require_dist.clipExportFailure("wake-expired", "interrupted by a restart — one authorised wake is one wake, so ask again to wake the camera")
6972
+ });
6973
+ deps.logger.warn("clip export abandoned: it was interrupted while holding a wake", {
6974
+ tags,
6975
+ meta
6976
+ });
6977
+ continue;
6978
+ }
6711
6979
  if (row.expiresAt <= now) {
6712
6980
  expired += 1;
6713
6981
  await patchExport(deps.state, row.id, {
@@ -13530,6 +13798,73 @@ function clusterFootageVisits(ranges, gapMs = require_dist.VISIT_MERGE_GAP_MS) {
13530
13798
  visits.push(finishVisit(pieces, endMs));
13531
13799
  return visits;
13532
13800
  }
13801
+ /**
13802
+ * A visit per BURST OF EVENTS, clamped to the footage that exists behind it.
13803
+ *
13804
+ * ## Why this replaced clustering the availability
13805
+ *
13806
+ * {@link VISIT_MERGE_GAP_MS}'s own docblock states the claim: 30 s "glues
13807
+ * those intra-visit holes without joining visits hours apart". It was applied
13808
+ * to the wrong input. {@link clusterFootageVisits} clusters AVAILABILITY, and
13809
+ * availability only has gaps on an EVENTS-MODE camera, where the writer opens
13810
+ * and closes around activity. A camera that records continuously has none —
13811
+ * so the visit became the recording session.
13812
+ *
13813
+ * Measured on 592, 2026-09-21, 24 h: seven rows covering the whole day, one of
13814
+ * them 7 h 58 m. Clustering the same day's EVENTS at the same 30 s gives 46
13815
+ * visits overnight and 36 in the day, median 17 s, longest 99 s. The constant
13816
+ * was right; the input was not. No duration cap: it would split by the clock
13817
+ * rather than by what happened, and nothing here needs splitting that way.
13818
+ *
13819
+ * ## What the footage is still for
13820
+ *
13821
+ * It CLAMPS, it does not define. A visit keeps only the pieces it actually
13822
+ * covers, because {@link stillInstantMs} takes the midpoint of the longest
13823
+ * piece — handed the whole session's range it would put the still hours from
13824
+ * the event. A burst with no footage behind it is dropped: an event the
13825
+ * recorder never kept is not a clip, and listing it is a row that opens onto
13826
+ * nothing.
13827
+ */
13828
+ function clusterEventVisits(events, ranges, gapMs, pad) {
13829
+ if (events.length === 0 || ranges.length === 0) return [];
13830
+ const times = [...events].map((e) => e.timestamp).toSorted((a, b) => a - b);
13831
+ const sortedRanges = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
13832
+ const bursts = [];
13833
+ let first = times[0];
13834
+ let last = times[0];
13835
+ for (let i = 1; i < times.length; i++) {
13836
+ const t = times[i];
13837
+ if (t - last <= gapMs) {
13838
+ last = t;
13839
+ continue;
13840
+ }
13841
+ bursts.push({
13842
+ startMs: first - pad.preMs,
13843
+ endMs: last + pad.postMs
13844
+ });
13845
+ first = t;
13846
+ last = t;
13847
+ }
13848
+ bursts.push({
13849
+ startMs: first - pad.preMs,
13850
+ endMs: last + pad.postMs
13851
+ });
13852
+ const visits = [];
13853
+ for (const burst of bursts) {
13854
+ const pieces = [];
13855
+ for (const range of sortedRanges) {
13856
+ const startMs = Math.max(range.startMs, burst.startMs);
13857
+ const endMs = Math.min(range.endMs, burst.endMs);
13858
+ if (endMs > startMs) pieces.push({
13859
+ startMs,
13860
+ endMs
13861
+ });
13862
+ }
13863
+ if (pieces.length === 0) continue;
13864
+ visits.push(finishVisit(pieces, pieces[pieces.length - 1].endMs));
13865
+ }
13866
+ return visits;
13867
+ }
13533
13868
  function finishVisit(pieces, endMs) {
13534
13869
  const holes = [];
13535
13870
  for (let i = 1; i < pieces.length; i++) {
@@ -13704,13 +14039,17 @@ function eventsToClips(events, pad, vouched) {
13704
14039
  ...events.audio.map((e) => make(e, "audio"))
13705
14040
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
13706
14041
  }
13707
- /** Every id in the three buckets, in the order the composers tag them. */
13708
- function allEventIds(events) {
14042
+ /** Every event in the three buckets, in the order the composers tag them. */
14043
+ function allEvents(events) {
13709
14044
  return [
13710
14045
  ...events.motion,
13711
14046
  ...events.object,
13712
14047
  ...events.audio
13713
- ].map((e) => e.id);
14048
+ ];
14049
+ }
14050
+ /** Every id in the three buckets, in the order the composers tag them. */
14051
+ function allEventIds(events) {
14052
+ return allEvents(events).map((e) => e.id);
13714
14053
  }
13715
14054
  /**
13716
14055
  * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
@@ -13827,11 +14166,12 @@ function createVideoclipsProvider(deps) {
13827
14166
  }
13828
14167
  let clips = eventsToClips(events, deps.defaultPad, vouched);
13829
14168
  if (deps.fetchAvailability) try {
13830
- clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
14169
+ const ranges = await deps.fetchAvailability({
13831
14170
  deviceId,
13832
14171
  since: readSince,
13833
14172
  until
13834
- }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
14173
+ });
14174
+ clips = visitsToClips(clusterEventVisits(allEvents(events), ranges, require_dist.VISIT_MERGE_GAP_MS, deps.defaultPad), events, deps.defaultPad, vouched);
13835
14175
  } catch (err) {
13836
14176
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
13837
14177
  deviceId,
@@ -13877,6 +14217,20 @@ function createVideoclipsProvider(deps) {
13877
14217
  }),
13878
14218
  format: "hls"
13879
14219
  };
14220
+ },
14221
+ /**
14222
+ * This source has no bytes to hand over, and says so rather than
14223
+ * inventing a path to them.
14224
+ *
14225
+ * An `analytics` clip is a time WINDOW over footage the recorder already
14226
+ * owns — there is no file behind it, and a caller that wants it as one
14227
+ * asks for it as an EXPORT, where the source playlist and
14228
+ * `export-dense-map` handle the holes a visit spans by construction
14229
+ * (D558 § 5.4.3). Reading it here would be this addon fetching from
14230
+ * itself, over a cap channel, in base64.
14231
+ */
14232
+ readClipBytes: async ({ clipId }) => {
14233
+ 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.`);
13880
14234
  }
13881
14235
  };
13882
14236
  }
@@ -17282,6 +17636,81 @@ function makeBrokerCall(handle) {
17282
17636
  return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
17283
17637
  }
17284
17638
  //#endregion
17639
+ //#region src/recorder/addon/export-clip-catalog.ts
17640
+ /**
17641
+ * Catalog page size for the confirm.
17642
+ *
17643
+ * One local day is the window (`ExportClipSubject.catalogWindow`) and a busy
17644
+ * day on 592 measured 546 rows, so 1 000 holds a day with room and still
17645
+ * bounds a pathological camera. A truncated page would read as "this clip is
17646
+ * gone", which is the one answer this gate must never give wrongly.
17647
+ */
17648
+ var EXPORT_CLIP_CATALOG_LIMIT = 1e3;
17649
+ /**
17650
+ * Which twin this row can actually serve for the asked profile.
17651
+ *
17652
+ * The mapping is the one D549 15 already fixed — `low | mid` → the sub file,
17653
+ * `high` → the main twin — and both ids are resolved at LISTING time, so this
17654
+ * never re-searches the camera. A row that holds only one twin answers with
17655
+ * the one it has and the caller SAYS so: the ask stays on `subject.profile`
17656
+ * and this lands on the record's top-level `profile`, so a week later the row
17657
+ * still tells the operator he was handed the sub stream.
17658
+ *
17659
+ * A row with no `streams` block at all is not a fallback and not a guess: the
17660
+ * ask is carried through and the producer's own `served` is what corrects it.
17661
+ */
17662
+ function servedTwinFor(clip, asked) {
17663
+ const streams = clip.streams;
17664
+ if (streams === void 0) return asked;
17665
+ const hasMain = streams.main !== void 0;
17666
+ const hasSub = streams.sub !== void 0;
17667
+ if (asked === "high") return hasMain ? "high" : hasSub ? "low" : asked;
17668
+ return hasSub ? asked : hasMain ? "high" : asked;
17669
+ }
17670
+ /**
17671
+ * Judge one catalog answer for one clip subject. Pure — the I/O is the
17672
+ * caller's, so every refusal below is unit-testable without a camera.
17673
+ */
17674
+ function judgeClipRow(subject, rows) {
17675
+ const clip = rows.find((row) => row.id === subject.clipId);
17676
+ if (clip === void 0) return {
17677
+ ok: false,
17678
+ refusal: {
17679
+ code: "catalog-miss",
17680
+ detail: `"${subject.sourceLabel}" does not list ${subject.clipId} between ${subject.catalogWindow.sinceMs} and ${subject.catalogWindow.untilMs}`
17681
+ }
17682
+ };
17683
+ if (clip.playable === false) return {
17684
+ ok: false,
17685
+ refusal: {
17686
+ code: "no-file-for-window",
17687
+ detail: clip.unplayableReason ?? "no bytes"
17688
+ }
17689
+ };
17690
+ if (clip.inProgress === true) return {
17691
+ ok: false,
17692
+ refusal: {
17693
+ code: "clip-in-progress",
17694
+ detail: "the camera is still writing this recording, so it has no end to export yet"
17695
+ }
17696
+ };
17697
+ const { startMs, endMs } = clip.timeRange;
17698
+ if (!(startMs < endMs)) return {
17699
+ ok: false,
17700
+ refusal: {
17701
+ code: "catalog-miss",
17702
+ detail: `the row carries no usable span (${startMs}..${endMs})`
17703
+ }
17704
+ };
17705
+ return {
17706
+ ok: true,
17707
+ accepted: {
17708
+ clip,
17709
+ served: servedTwinFor(clip, subject.profile)
17710
+ }
17711
+ };
17712
+ }
17713
+ //#endregion
17285
17714
  //#region src/recorder/addon/recording-export-provider.ts
17286
17715
  /** Content type of every rendered export — the engine only ever writes MP4. */
17287
17716
  var EXPORT_CONTENT_TYPE = "video/mp4";
@@ -17309,12 +17738,6 @@ function exportFileName(rec) {
17309
17738
  async function hasFootage(index, deviceId, profile, fromMs, toMs) {
17310
17739
  return (await index.segmentsInWindow(deviceId, profile, fromMs, toMs)).some((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs);
17311
17740
  }
17312
- /** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
17313
- function resolveExportProfiles(input) {
17314
- if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
17315
- if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
17316
- return [];
17317
- }
17318
17741
  /** Load a record or throw a clear NOT_FOUND-style error. */
17319
17742
  async function requireRecord(state, exportId) {
17320
17743
  const rec = await getExportRecord(state, exportId);
@@ -17323,37 +17746,133 @@ async function requireRecord(state, exportId) {
17323
17746
  }
17324
17747
  /** Build the `recordingExport` provider over the injected deps. */
17325
17748
  function buildRecordingExportProvider(deps) {
17749
+ /**
17750
+ * Persist one `queued` row and hand it to the engine. The ONE place a record
17751
+ * is born, for both kinds — so the fields nothing about the subject decides
17752
+ * (the lifetime, the audit stamp, the null progress) cannot drift apart.
17753
+ */
17754
+ const queueOne = async (fields, options, now) => {
17755
+ const saved = await upsertExport(deps.state, {
17756
+ ...fields,
17757
+ id: deps.newId(),
17758
+ options,
17759
+ state: "queued",
17760
+ progressPct: null,
17761
+ fileBytes: null,
17762
+ expiresAt: now + options.maxLifeMs,
17763
+ deleteAfterDownload: options.deleteAfterDownload,
17764
+ downloadedAt: null,
17765
+ createdAt: now,
17766
+ createdBy: deps.createdBy(),
17767
+ error: null
17768
+ });
17769
+ deps.engine.enqueue(saved.id);
17770
+ return saved;
17771
+ };
17772
+ /**
17773
+ * The clip branch's fail-fast gate — the catalog, never the recording index.
17774
+ *
17775
+ * Refuses BEFORE a row exists, so an operator gets the reason in the answer
17776
+ * to his own tap instead of discovering it on a `failed` row a minute later,
17777
+ * and so a refusal never spends a wake.
17778
+ */
17779
+ const createClipExport = async (subject, options) => {
17780
+ const tags = { deviceId: subject.deviceId };
17781
+ if (options.timelapse !== void 0 || options.speed !== void 0 && options.speed !== 1) {
17782
+ const detail = "a clip is remuxed, never re-timed — drop speed/timelapse";
17783
+ deps.logger.warn("clip export refused: a render option a remux cannot honour", {
17784
+ tags,
17785
+ meta: {
17786
+ clipId: subject.clipId,
17787
+ speed: options.speed,
17788
+ timelapse: options.timelapse
17789
+ }
17790
+ });
17791
+ throw new Error(require_dist.clipExportFailure("unsupported-option", detail));
17792
+ }
17793
+ let rows;
17794
+ try {
17795
+ rows = await deps.clipCatalog.listClips({
17796
+ deviceId: subject.deviceId,
17797
+ since: subject.catalogWindow.sinceMs,
17798
+ until: subject.catalogWindow.untilMs,
17799
+ provider: subject.provider,
17800
+ limit: EXPORT_CLIP_CATALOG_LIMIT
17801
+ });
17802
+ } catch (err) {
17803
+ const detail = err instanceof Error ? err.message : String(err);
17804
+ deps.logger.warn("clip export refused: the clip catalog could not be read", {
17805
+ tags,
17806
+ meta: {
17807
+ clipId: subject.clipId,
17808
+ provider: subject.provider,
17809
+ error: detail
17810
+ }
17811
+ });
17812
+ throw new Error(require_dist.clipExportFailure("catalog-unreachable", detail), { cause: err });
17813
+ }
17814
+ const outcome = judgeClipRow(subject, rows);
17815
+ if (!outcome.ok) {
17816
+ deps.logger.warn("clip export refused by the catalog gate", {
17817
+ tags,
17818
+ meta: {
17819
+ clipId: subject.clipId,
17820
+ provider: subject.provider,
17821
+ reason: outcome.refusal.code,
17822
+ detail: outcome.refusal.detail
17823
+ }
17824
+ });
17825
+ throw new Error(require_dist.clipExportFailure(outcome.refusal.code, outcome.refusal.detail));
17826
+ }
17827
+ const { clip, served } = outcome.accepted;
17828
+ const record = await queueOne({
17829
+ deviceId: subject.deviceId,
17830
+ profile: served,
17831
+ fromMs: clip.timeRange.startMs,
17832
+ toMs: clip.timeRange.endMs,
17833
+ subject
17834
+ }, options, deps.now());
17835
+ deps.logger.info("clip export queued", {
17836
+ tags,
17837
+ meta: {
17838
+ exportId: record.id,
17839
+ clipId: subject.clipId,
17840
+ source: subject.source,
17841
+ provider: subject.provider,
17842
+ askedProfile: subject.profile,
17843
+ servedProfile: served,
17844
+ wake: subject.wake ?? "none",
17845
+ fromMs: record.fromMs,
17846
+ toMs: record.toMs
17847
+ }
17848
+ });
17849
+ return record;
17850
+ };
17326
17851
  return {
17327
17852
  createExport: async (input) => {
17328
- const { deviceId, fromMs, toMs, options } = input;
17329
- const profiles = resolveExportProfiles(input);
17330
- if (profiles.length < 1) throw new Error("recording-export: profiles must contain at least one profile");
17853
+ const subject = require_dist.resolveCreateExportSubject(input);
17854
+ if (subject.kind === "clip") return await createClipExport(subject, input.options);
17855
+ const { deviceId, profiles, fromMs, toMs } = subject;
17856
+ const { options } = input;
17331
17857
  if (!(fromMs < toMs)) throw new Error(`recording-export: invalid range ${fromMs}..${toMs}`);
17332
17858
  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}`);
17333
17859
  const now = deps.now();
17334
- let first = null;
17335
- for (const profile of profiles) {
17336
- const record = {
17337
- id: deps.newId(),
17338
- deviceId,
17339
- profile,
17340
- fromMs,
17341
- toMs,
17342
- options,
17343
- state: "queued",
17344
- progressPct: null,
17345
- fileBytes: null,
17346
- expiresAt: now + options.maxLifeMs,
17347
- deleteAfterDownload: options.deleteAfterDownload,
17348
- downloadedAt: null,
17349
- createdAt: now,
17350
- createdBy: deps.createdBy(),
17351
- error: null
17352
- };
17353
- const saved = await upsertExport(deps.state, record);
17354
- deps.engine.enqueue(saved.id);
17355
- first ??= saved;
17356
- }
17860
+ const [head, ...rest] = profiles;
17861
+ if (head === void 0) throw new Error("recording-export: profiles must contain at least one profile");
17862
+ const first = await queueOne({
17863
+ deviceId,
17864
+ profile: head,
17865
+ fromMs,
17866
+ toMs,
17867
+ subject
17868
+ }, options, now);
17869
+ for (const profile of rest) await queueOne({
17870
+ deviceId,
17871
+ profile,
17872
+ fromMs,
17873
+ toMs,
17874
+ subject
17875
+ }, options, now);
17357
17876
  return first;
17358
17877
  },
17359
17878
  listExports: ({ deviceId }) => listExportsFor(deps.state, deviceId),
@@ -19128,7 +19647,19 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
19128
19647
  outPath: exportOutPath,
19129
19648
  statBytes: async (p) => (await node_fs.promises.stat(p)).size,
19130
19649
  removeFile: removeExportFile,
19131
- emit: (category, data) => this.emitExportEvent(category, data)
19650
+ emit: (category, data) => this.emitExportEvent(category, data),
19651
+ now: () => Date.now(),
19652
+ fetchClipBytes: async (req) => await this.ctx.api.videoclips.readClipBytes.query({
19653
+ deviceId: req.deviceId,
19654
+ clipId: req.clipId,
19655
+ profile: req.profile,
19656
+ maxBytes: req.maxBytes,
19657
+ ...req.wake !== void 0 ? { wake: req.wake } : {}
19658
+ }),
19659
+ writeExportFile: async (p, bytes) => {
19660
+ await node_fs.promises.mkdir(node_path.default.dirname(p), { recursive: true });
19661
+ await node_fs.promises.writeFile(p, bytes);
19662
+ }
19132
19663
  });
19133
19664
  const exportProvider = buildRecordingExportProvider({
19134
19665
  state: exportsState,
@@ -19137,6 +19668,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
19137
19668
  if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
19138
19669
  return this.index.segments(deviceId, profile);
19139
19670
  } },
19671
+ clipCatalog: { listClips: async (input) => await this.ctx.api.videoclips.listClips.query(input) },
19140
19672
  now: () => Date.now(),
19141
19673
  newId: () => (0, node_crypto.randomUUID)(),
19142
19674
  createdBy: () => "operator",