@camstack/addon-post-analysis 1.2.298 → 1.2.299

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-olxNKN7T.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-hTbsQ-z7.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -12,6 +12,8 @@ let node_crypto = require("node:crypto");
12
12
  let node_child_process = require("node:child_process");
13
13
  let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
14
14
  let node_stream_promises = require("node:stream/promises");
15
+ let node_fs_promises = require("node:fs/promises");
16
+ node_fs_promises = require_clip_model_registry.__toESM(node_fs_promises, 1);
15
17
  let sharp = require("sharp");
16
18
  sharp = require_clip_model_registry.__toESM(sharp);
17
19
  let node_os = require("node:os");
@@ -9980,14 +9982,25 @@ function occupancySubjectFromEdge(edge) {
9980
9982
  threshold: edge.threshold
9981
9983
  };
9982
9984
  }
9983
- /** Build the subject for a `package-event` evaluation (a persisted `package`
9984
- * object-event; `phase` derives from the event `state` at the call site). */
9985
+ /**
9986
+ * Build the subject for a `package-event` evaluation (a persisted `package`
9987
+ * object-event; `phase` derives from the event `state` at the call site).
9988
+ *
9989
+ * `observedAt` (D682, D684): a pick-up row's `timestamp` is when the departure
9990
+ * was CONCLUDED — five minutes of unseen observed time after the parcel went —
9991
+ * so the detector STORES the instant it happened on the row, and the footage is
9992
+ * cut around that (`media-anchor.ts`). Read from the row HERE, the one place
9993
+ * every path builds a package subject (live hook, boot reconcile, dry-run), so
9994
+ * none of them can anchor differently. `timestamp` keeps its meaning; a row
9995
+ * written before D684 has none, which the anchor reads as unknown, out loud.
9996
+ */
9985
9997
  function subjectFromPackageEvent(ev, phase) {
9986
9998
  return {
9987
9999
  kind: "package-event",
9988
10000
  recordId: String(ev.id),
9989
10001
  deviceId: ev.deviceId,
9990
10002
  timestamp: ev.timestamp,
10003
+ ...ev.observedAt !== void 0 ? { observedAt: ev.observedAt } : {},
9991
10004
  classNames: [ev.className],
9992
10005
  ...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
9993
10006
  ...recognitionPatch(ev.subLabelMeta),
@@ -10918,7 +10931,11 @@ function incomingFromAudioEvent(event, stillOwnerId) {
10918
10931
  })
10919
10932
  };
10920
10933
  }
10921
- /** Package object-event persist (delivered / picked-up). */
10934
+ /**
10935
+ * Package object-event persist (delivered / picked-up) — the live hook and the
10936
+ * boot reconcile alike. A pick-up's instant is read from the row
10937
+ * (`event.observedAt`, D684) inside `subjectFromPackageEvent`.
10938
+ */
10922
10939
  function incomingFromPackageEvent(event, phase) {
10923
10940
  return {
10924
10941
  subject: subjectFromPackageEvent(event, phase),
@@ -10928,7 +10945,8 @@ function incomingFromPackageEvent(event, phase) {
10928
10945
  tags: { deviceId: event.deviceId },
10929
10946
  meta: {
10930
10947
  eventId: event.id,
10931
- phase
10948
+ phase,
10949
+ ...event.observedAt !== void 0 ? { observedAt: event.observedAt } : {}
10932
10950
  }
10933
10951
  })
10934
10952
  };
@@ -19336,9 +19354,14 @@ function mediaAnchor(subject) {
19336
19354
  lagMs: subject.timestamp - observedAt
19337
19355
  };
19338
19356
  }
19357
+ function selfDatingKindOf(subject) {
19358
+ if (subject.occupancy !== void 0) return "occupancy";
19359
+ if (subject.packagePhase === "picked-up") return "package pick-up";
19360
+ return null;
19361
+ }
19339
19362
  /**
19340
19363
  * True when this anchor is the conclusion instant AND the subject was one that
19341
- * should have been able to date itself — today, an occupancy row.
19364
+ * should have been able to date itself — see {@link selfDatingKindOf}.
19342
19365
  *
19343
19366
  * Split from {@link mediaAnchor} because the anchor must stay pure of any
19344
19367
  * notion of subject KIND, while the log line that matters is exactly the one
@@ -20442,7 +20465,8 @@ var NcDispatcher = class {
20442
20465
  };
20443
20466
  const preRollSec = entry.payload.mediaClipPreRollSec ?? 4;
20444
20467
  const anchor = mediaAnchor(entry.payload.subject);
20445
- if (anchorFellBack(anchor, entry.payload.subject.occupancy !== void 0)) this.deps.logger.warn("occupancy footage anchored on the conclusion — moment unknown", {
20468
+ const selfDating = selfDatingKindOf(entry.payload.subject);
20469
+ if (anchorFellBack(anchor, selfDating !== null)) this.deps.logger.warn(`${selfDating ?? "occupancy"} footage anchored on the conclusion — moment unknown`, {
20446
20470
  tags: { deviceId: entry.payload.subject.deviceId },
20447
20471
  meta: {
20448
20472
  ruleId: entry.ruleId,
@@ -20470,7 +20494,7 @@ var NcDispatcher = class {
20470
20494
  preRollSec,
20471
20495
  retentionSec: this.ringRetentionSec(entry.payload.subject.deviceId),
20472
20496
  wanted: [...wantGif ? ["gif"] : [], ...wantMp4 ? ["video"] : []],
20473
- next: this.deps.cutRecordedClip !== void 0 ? "recorded-clip-follow-up" : "no-clip"
20497
+ next: this.recordedFollowUp(entry).kinds.length > 0 ? "recorded-clip-follow-up" : "no-clip"
20474
20498
  }
20475
20499
  });
20476
20500
  return {
@@ -20524,6 +20548,13 @@ var NcDispatcher = class {
20524
20548
  * alert for a complete one, which is precisely the wrong trade for the
20525
20549
  * notification that tells an operator someone is at the door.
20526
20550
  *
20551
+ * A GIF rule is served here too (D682): a package pick-up is concluded five
20552
+ * minutes after it happened, so a gif anchored on the pick-up is always
20553
+ * outside the ring. The gif is derived from THIS cut's bytes
20554
+ * (`deriveGifFromClip`) — one cut, never a second renderer — and a moment the
20555
+ * recordings cannot serve ships NO gif, by named reason, rather than a gif of
20556
+ * the wrong time.
20557
+ *
20527
20558
  * So the alert's latency is UNCHANGED, and **the clip is attached later**, as
20528
20559
  * a follow-up send to the same target carrying the same `tag` — a phone
20529
20560
  * collapses the two, so what the operator sees is the notification gaining
@@ -20546,7 +20577,9 @@ var NcDispatcher = class {
20546
20577
  */
20547
20578
  startRecordedFootage(entry, target, notification) {
20548
20579
  const cut = this.deps.cutRecordedClip;
20549
- if (cut === void 0 || entry.payload.mediaClip !== true) return;
20580
+ const plan = this.recordedFollowUp(entry);
20581
+ const wanted = plan.kinds;
20582
+ if (cut === void 0 || wanted.length === 0 && plan.gifRefused === void 0 && plan.videoRefused === void 0) return;
20550
20583
  const preRollSec = entry.payload.mediaClipPreRollSec ?? 4;
20551
20584
  const anchor = mediaAnchor(entry.payload.subject);
20552
20585
  if (footageSourceFor({
@@ -20556,34 +20589,70 @@ var NcDispatcher = class {
20556
20589
  ringRetentionSec: this.ringRetentionSec(entry.payload.subject.deviceId)
20557
20590
  }) !== "recordings") return;
20558
20591
  const deviceId = entry.payload.subject.deviceId;
20592
+ if (plan.gifRefused !== void 0) this.deps.logger.info("recorded gif follow-up: none — the gif is not cut from the recordings", {
20593
+ tags: { deviceId },
20594
+ meta: {
20595
+ ruleId: entry.ruleId,
20596
+ eventId: entry.recordId,
20597
+ targetId: target.id,
20598
+ reason: plan.gifRefused,
20599
+ anchoredOn: anchor.source,
20600
+ ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20601
+ next: wanted
20602
+ }
20603
+ });
20604
+ if (plan.videoRefused !== void 0) this.deps.logger.info("recorded video follow-up: none — the video is not cut from the recordings", {
20605
+ tags: { deviceId },
20606
+ meta: {
20607
+ ruleId: entry.ruleId,
20608
+ eventId: entry.recordId,
20609
+ targetId: target.id,
20610
+ reason: plan.videoRefused,
20611
+ anchoredOn: anchor.source,
20612
+ ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20613
+ next: wanted
20614
+ }
20615
+ });
20616
+ if (wanted.length === 0) return;
20617
+ const videoSpeed = entry.payload.mediaClipSpeed;
20559
20618
  const pending = (async () => {
20560
20619
  const result = await cut({
20561
20620
  deviceId,
20562
20621
  aroundMs: anchor.atMs,
20563
20622
  preRollSec,
20564
20623
  postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
20565
- ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {},
20624
+ ...videoSpeed !== void 0 ? { speed: videoSpeed } : {},
20566
20625
  ruleId: entry.ruleId,
20567
20626
  recordId: entry.recordId
20568
20627
  });
20569
20628
  if (!result.ok) {
20570
- this.deps.logger.info("recorded clip follow-up: none — this delivery keeps no clip", {
20629
+ this.deps.logger.info("recorded footage follow-up: none — the recorded cut failed", {
20571
20630
  tags: { deviceId },
20572
20631
  meta: {
20573
20632
  ruleId: entry.ruleId,
20574
20633
  eventId: entry.recordId,
20575
20634
  targetId: target.id,
20576
- reason: result.reason
20635
+ reason: result.reason,
20636
+ wanted
20577
20637
  }
20578
20638
  });
20579
20639
  return;
20580
20640
  }
20581
- const withUrls = await this.withArtifactUrls(entry, [{
20582
- mediaType: "video",
20583
- bytes: result.mp4,
20584
- mime: "video/mp4",
20585
- name: "event.mp4"
20586
- }]);
20641
+ const attachments = await this.recordedAttachments(entry, wanted, result.mp4);
20642
+ if (attachments.length === 0) {
20643
+ this.deps.logger.info("recorded clip follow-up: none — the gif could not be derived", {
20644
+ tags: { deviceId },
20645
+ meta: {
20646
+ ruleId: entry.ruleId,
20647
+ eventId: entry.recordId,
20648
+ targetId: target.id,
20649
+ reason: "gif-derive-failed",
20650
+ wanted
20651
+ }
20652
+ });
20653
+ return;
20654
+ }
20655
+ const withUrls = await this.withArtifactUrls(entry, attachments);
20587
20656
  const sent = await this.deps.send({
20588
20657
  addonId: target.addonId,
20589
20658
  targetId: target.id,
@@ -20612,6 +20681,8 @@ var NcDispatcher = class {
20612
20681
  target: target.name,
20613
20682
  profile: result.profile,
20614
20683
  bytes: result.mp4.byteLength,
20684
+ attachments: attachments.map((a) => a.mediaType),
20685
+ anchoredOn: anchor.source,
20615
20686
  lagMs: this.now() - entry.updatedAt
20616
20687
  }
20617
20688
  });
@@ -20631,6 +20702,84 @@ var NcDispatcher = class {
20631
20702
  });
20632
20703
  }
20633
20704
  /**
20705
+ * What a recorded follow-up for this row would CARRY — the one answer both
20706
+ * the refusal line (`next`) and the follow-up read, so the log cannot promise
20707
+ * a follow-up the dispatcher will not send.
20708
+ *
20709
+ * A video when the rule asked for one. A gif only for a package PICK-UP
20710
+ * whose moment is KNOWN (D682): the gif is derived from the recorded cut's
20711
+ * own bytes, the same way the broker derives it from a ring cut — one cut,
20712
+ * two containers, never a second renderer.
20713
+ *
20714
+ * Narrow on purpose. A pick-up is the one kind whose moment is ALWAYS outside
20715
+ * the ring; every other kind keeps the gif behaviour it had (the ring's, or
20716
+ * none) rather than silently gaining a recorder export per notification. And
20717
+ * a pick-up that fell back to its conclusion gets no recorded footage at all,
20718
+ * gif OR video — cutting the recordings there is exactly the doormat D682
20719
+ * removed (see {@link NcRecordedGifRefusal}, {@link NcRecordedVideoRefusal}).
20720
+ */
20721
+ recordedFollowUp(entry) {
20722
+ if (this.deps.cutRecordedClip === void 0) return { kinds: [] };
20723
+ const gifRefused = this.recordedGifRefusal(entry);
20724
+ const videoRefused = this.recordedVideoRefusal(entry);
20725
+ const kinds = [];
20726
+ if (entry.payload.mediaGif === true && gifRefused === void 0) kinds.push("gif");
20727
+ if (entry.payload.mediaClip === true && videoRefused === void 0) kinds.push("video");
20728
+ return {
20729
+ kinds,
20730
+ ...gifRefused !== void 0 ? { gifRefused } : {},
20731
+ ...videoRefused !== void 0 ? { videoRefused } : {}
20732
+ };
20733
+ }
20734
+ /** Why this row's video is not cut from the recordings, or `undefined` when
20735
+ * it is (or when the rule asked for no video). */
20736
+ recordedVideoRefusal(entry) {
20737
+ if (entry.payload.mediaClip !== true) return void 0;
20738
+ const subject = entry.payload.subject;
20739
+ if (selfDatingKindOf(subject) !== "package pick-up") return void 0;
20740
+ if (anchorFellBack(mediaAnchor(subject), true)) return "moment-unknown";
20741
+ }
20742
+ /** Why this row's gif is not cut from the recordings, or `undefined` when it
20743
+ * is (or when the rule asked for no gif). */
20744
+ recordedGifRefusal(entry) {
20745
+ if (entry.payload.mediaGif !== true) return void 0;
20746
+ if (this.deps.deriveGifFromClip === void 0) return "no-derive";
20747
+ const subject = entry.payload.subject;
20748
+ if (selfDatingKindOf(subject) !== "package pick-up") return "not-a-pick-up";
20749
+ if (anchorFellBack(mediaAnchor(subject), true)) return "moment-unknown";
20750
+ }
20751
+ /**
20752
+ * The follow-up's attachments, gif first and video second — the order the
20753
+ * ring path ships them in. A gif that could not be derived is left out (its
20754
+ * reason already logged); the video, when asked for, still ships.
20755
+ */
20756
+ async recordedAttachments(entry, wanted, mp4) {
20757
+ const out = [];
20758
+ const derive = this.deps.deriveGifFromClip;
20759
+ if (wanted.includes("gif") && derive !== void 0) {
20760
+ const gif = await derive({
20761
+ deviceId: entry.payload.subject.deviceId,
20762
+ mp4,
20763
+ ...entry.payload.mediaClipSpeed !== void 0 ? { videoSpeed: entry.payload.mediaClipSpeed } : {},
20764
+ ruleId: entry.ruleId,
20765
+ recordId: entry.recordId
20766
+ });
20767
+ if (gif.ok) out.push({
20768
+ mediaType: "gif",
20769
+ bytes: gif.gif,
20770
+ mime: "image/gif",
20771
+ name: "event.gif"
20772
+ });
20773
+ }
20774
+ if (wanted.includes("video")) out.push({
20775
+ mediaType: "video",
20776
+ bytes: mp4,
20777
+ mime: "video/mp4",
20778
+ name: "event.mp4"
20779
+ });
20780
+ return out;
20781
+ }
20782
+ /**
20634
20783
  * Settle every recorded-clip follow-up this dispatcher has in flight.
20635
20784
  *
20636
20785
  * A TEST SEAM, and the honest kind: the follow-up is detached by design, so
@@ -32558,6 +32707,9 @@ var NotificationCenter = class NotificationCenter {
32558
32707
  * delivered/picked-up), in the SAME moment as the durable insert. Feeds the
32559
32708
  * `package-event` trigger. Package events ARE object-event rows, so the boot
32560
32709
  * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
32710
+ *
32711
+ * When a pick-up HAPPENED is on the row (`event.observedAt`, D684), so this
32712
+ * hook and the reconcile read it from the same place.
32561
32713
  */
32562
32714
  onPackageEventPersisted(event, phase) {
32563
32715
  this.consumeEvent(incomingFromPackageEvent(event, phase));
@@ -53055,7 +53207,9 @@ var MOTION_EVENTS_COLLECTION = "pipeline-analytics:motion-events";
53055
53207
  * @durable class=ledger owner=pipeline-analytics
53056
53208
  * write="one row per detected object per processed frame — every tracked
53057
53209
  * detection the frame processor emits, plus the appearance events and the
53058
- * synthetic package-drop events. Every row carries the trackId it belongs to."
53210
+ * synthetic package-drop events. Every row carries the trackId it belongs to.
53211
+ * A package PICK-UP row also carries observedAt, when the parcel went,
53212
+ * beside its timestamp, when the departure was concluded (D684)."
53059
53213
  * retention="TRACK-OWNED: it has no clock of its own. Rows go only when their
53060
53214
  * track goes, through EventStore.deleteByTracks — which folds in the deletion
53061
53215
  * of the event's child crops so a caller cannot take the events and leave the
@@ -53262,7 +53416,21 @@ var OBJECT_COLUMNS = [
53262
53416
  {
53263
53417
  name: "importance",
53264
53418
  type: "REAL"
53265
- }
53419
+ },
53420
+ (
53421
+ /**
53422
+ * When a package PICK-UP happened, beside `timestamp` (when it was
53423
+ * CONCLUDED, five minutes later) — see `ObjectEvent.observedAt` (D684).
53424
+ *
53425
+ * DECLARED, or the backend drops it on write (see `frameId`). Additive: the
53426
+ * collection gains the column in place (`ALTER TABLE … ADD COLUMN`), and every
53427
+ * row written before it reads SQL NULL → absent after `stripNulls`, which is
53428
+ * the truth about it: the moment is unknown. Only the package detector sets it.
53429
+ */
53430
+ {
53431
+ name: "observedAt",
53432
+ type: "INTEGER"
53433
+ })
53266
53434
  ];
53267
53435
  var AUDIO_COLUMNS = [
53268
53436
  ...COMMON_BASE_COLUMNS,
@@ -59101,6 +59269,180 @@ function decodeBase64(base64) {
59101
59269
  }
59102
59270
  }
59103
59271
  //#endregion
59272
+ //#region src/notification-center/recorded-gif.ts
59273
+ /**
59274
+ * The notification GIF for a moment only the RECORDINGS still hold — derived
59275
+ * from the recorded clip's own bytes (D682).
59276
+ *
59277
+ * ## Why this exists
59278
+ *
59279
+ * A package pick-up is concluded five minutes after the parcel went
59280
+ * (`PACKAGE_STILLNESS_TTL_MS`). Once its footage is anchored on the pick-up
59281
+ * itself rather than on the conclusion (`media-anchor.ts`), the window is far
59282
+ * older than any clip ring (≤ 60 s, `NC_CLIP_RETENTION_MAX_SEC`), so the ring
59283
+ * refuses it. A gif-only rule then had NOTHING: the recorded follow-up served
59284
+ * only a rule that asked for a video. The honest fix for "the gif shows the
59285
+ * empty doormat" is not "no gif" — it is the gif of the pick-up, from the
59286
+ * recordings.
59287
+ *
59288
+ * ## One cut, and the gif is DERIVED from it
59289
+ *
59290
+ * The recorded clip (`recorded-clip.ts`, one `recordingExport` render) is the
59291
+ * only cut. The gif is derived from ITS bytes with the SAME filter chain the
59292
+ * broker uses on a ring cut (`gifFromMp4Args` in `@camstack/types`), so a gif
59293
+ * of an old moment and a gif of a live one look the same, and a rule that asks
59294
+ * for both a gif and a video gets two containers of ONE window — the guarantee
59295
+ * `NcEventMediaRequest` exists for.
59296
+ *
59297
+ * ## Never throws, every miss named
59298
+ *
59299
+ * `derive-failed` (ffmpeg could not produce it) and `stage-failed` (the clip
59300
+ * could not be written where ffmpeg can read it) are logged with
59301
+ * `tags: { deviceId }` (D400: a missing attachment names its reason).
59302
+ */
59303
+ /** Bound on one derive: a ~10 s window encodes in well under a second; a
59304
+ * derive that has not finished in 20 s is wedged. Same bound as the broker's. */
59305
+ var NC_RECORDED_GIF_TIMEOUT_MS = 2e4;
59306
+ /** A notification gif is a few hundred KB; this is headroom, not a budget. */
59307
+ var RECORDED_GIF_MAX_BUFFER = 32 * 1024 * 1024;
59308
+ /**
59309
+ * Derive the gif. Never throws; every `ok: false` has logged its reason.
59310
+ */
59311
+ async function deriveRecordedGif(ports, req) {
59312
+ const tags = { deviceId: req.deviceId };
59313
+ const common = {
59314
+ ruleId: req.ruleId,
59315
+ eventId: req.recordId,
59316
+ sourceBytes: req.mp4.byteLength
59317
+ };
59318
+ let file;
59319
+ try {
59320
+ file = await ports.writeTemp(req.mp4);
59321
+ } catch (err) {
59322
+ ports.logger.warn("recorded gif: could not stage the recorded clip — no gif", {
59323
+ tags,
59324
+ meta: {
59325
+ ...common,
59326
+ reason: "stage-failed",
59327
+ error: String(err)
59328
+ }
59329
+ });
59330
+ return {
59331
+ ok: false,
59332
+ reason: "stage-failed"
59333
+ };
59334
+ }
59335
+ try {
59336
+ const speed = require_clip_model_registry.gifDeriveSpeed(4, req.videoSpeed ?? 1);
59337
+ const args = require_clip_model_registry.gifFromMp4Args({
59338
+ maxWidth: 640,
59339
+ fps: 12,
59340
+ speed,
59341
+ input: file
59342
+ });
59343
+ const startedAt = Date.now();
59344
+ let ran;
59345
+ try {
59346
+ ran = await ports.run(await ports.resolveFfmpeg(), args);
59347
+ } catch (err) {
59348
+ ran = {
59349
+ ok: false,
59350
+ error: String(err)
59351
+ };
59352
+ }
59353
+ const out = ran.ok ? ran.stdout : null;
59354
+ if (out === null || out.byteLength === 0) {
59355
+ ports.logger.warn("recorded gif: the derive failed — the follow-up carries no gif", {
59356
+ tags,
59357
+ meta: {
59358
+ ...common,
59359
+ reason: "derive-failed",
59360
+ error: ran.ok ? "ffmpeg produced no bytes" : ran.error.slice(-400)
59361
+ }
59362
+ });
59363
+ return {
59364
+ ok: false,
59365
+ reason: "derive-failed"
59366
+ };
59367
+ }
59368
+ const gif = new Uint8Array(new ArrayBuffer(out.byteLength));
59369
+ gif.set(out);
59370
+ ports.logger.info("recorded gif: derived from the recorded clip", {
59371
+ tags,
59372
+ meta: {
59373
+ ...common,
59374
+ gifBytes: gif.byteLength,
59375
+ width: 640,
59376
+ fps: 12,
59377
+ speed,
59378
+ encodeMs: Date.now() - startedAt
59379
+ }
59380
+ });
59381
+ return {
59382
+ ok: true,
59383
+ gif
59384
+ };
59385
+ } finally {
59386
+ await ports.removeTemp(file).catch((err) => {
59387
+ ports.logger.warn("recorded gif: could not remove the staged clip", {
59388
+ tags,
59389
+ meta: {
59390
+ ...common,
59391
+ file,
59392
+ error: String(err)
59393
+ }
59394
+ });
59395
+ });
59396
+ }
59397
+ }
59398
+ /** The production ports: the pinned ffmpeg, a temp file, a bounded child. */
59399
+ function buildRecordedGifPorts(input) {
59400
+ return {
59401
+ logger: input.logger,
59402
+ resolveFfmpeg: input.resolveFfmpeg,
59403
+ run: (cmd, args) => new Promise((resolve) => {
59404
+ (0, node_child_process.execFile)(cmd, [...args], {
59405
+ encoding: "buffer",
59406
+ maxBuffer: RECORDED_GIF_MAX_BUFFER,
59407
+ timeout: NC_RECORDED_GIF_TIMEOUT_MS,
59408
+ killSignal: "SIGKILL"
59409
+ }, (err, stdout) => resolve(err !== null ? {
59410
+ ok: false,
59411
+ error: String(err)
59412
+ } : {
59413
+ ok: true,
59414
+ stdout
59415
+ }));
59416
+ }),
59417
+ writeTemp: (bytes) => stageTempClip(node_path.default.join(node_os.default.tmpdir(), `camstack-nc-gif-${(0, node_crypto.randomUUID)()}.mp4`), bytes, {
59418
+ write: (file, data) => node_fs_promises.default.writeFile(file, data),
59419
+ remove: (file) => node_fs_promises.default.rm(file, { force: true })
59420
+ }),
59421
+ removeTemp: (file) => node_fs_promises.default.rm(file, { force: true })
59422
+ };
59423
+ }
59424
+ /**
59425
+ * Write the clip to `file`, and on a failed write remove whatever landed.
59426
+ *
59427
+ * The path is named BEFORE the write, because a write can create the file and
59428
+ * then fail (ENOSPC half-way): the caller's `finally` only knows a path the
59429
+ * write RETURNED, so a partial `camstack-nc-gif-*.mp4` used to stay in the temp
59430
+ * directory for good. The write's error is re-thrown — the caller names it
59431
+ * (`stage-failed`, tagged per camera). A removal that ALSO fails is a real leak
59432
+ * and rides in that same error's message, so it reaches that one line rather
59433
+ * than a second, untagged one.
59434
+ */
59435
+ async function stageTempClip(file, bytes, io) {
59436
+ try {
59437
+ await io.write(file, bytes);
59438
+ return file;
59439
+ } catch (err) {
59440
+ const leaked = await io.remove(file).then(() => null, (removeErr) => String(removeErr));
59441
+ if (leaked === null) throw err;
59442
+ throw new Error(`${String(err)}; the partial file ${file} was NOT removed: ${leaked}`);
59443
+ }
59444
+ }
59445
+ //#endregion
59104
59446
  //#region src/shared/frame/crop-extractor.ts
59105
59447
  /**
59106
59448
  * The ONE reading of a normalized box against a frame: round, then clamp so
@@ -70574,10 +70916,12 @@ var PackageDropDetector = class {
70574
70916
  pickupAt: entry.lastConfirmedAt
70575
70917
  }
70576
70918
  });
70919
+ const observedAt = collector?.observedAt ?? pickupInstantOf(entry);
70577
70920
  const draft = {
70578
70921
  kind: "object",
70579
70922
  deviceId: entry.deviceId,
70580
70923
  timestamp,
70924
+ observedAt,
70581
70925
  source: "pipeline",
70582
70926
  idempotencyKey: pickedUpKey,
70583
70927
  trackId: mediaTrackId,
@@ -70629,6 +70973,8 @@ var PackageDropDetector = class {
70629
70973
  idempotencyKey: pickedUpKey,
70630
70974
  deliveredEventId: delivered.id,
70631
70975
  mediaTrackId,
70976
+ observedAt,
70977
+ concludedLagMs: timestamp - observedAt,
70632
70978
  ...collector !== void 0 ? {
70633
70979
  collectorTrackId: collector.trackId,
70634
70980
  collectorClassName: collector.className,
@@ -89143,6 +89489,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
89143
89489
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
89144
89490
  deleteExport: (input) => api.recordingExport.deleteExport.mutate(input)
89145
89491
  }, req),
89492
+ deriveGifFromClip: (req) => deriveRecordedGif(buildRecordedGifPorts({
89493
+ logger: logger.child("RecordedGif"),
89494
+ resolveFfmpeg: () => this.ctx.deps.ensureFfmpeg()
89495
+ }), req),
89146
89496
  publishArtifact: async (bytes, mime, deviceId) => await this.ncArtifactPlane?.publish(bytes, mime, deviceId) ?? null,
89147
89497
  viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
89148
89498
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {