@camstack/addon-pipeline 1.2.272 → 1.2.274

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 (33) 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-DqrqHCuw.mjs → default-detection-model-BXwE-Dvo.mjs} +1 -1
  4. package/dist/{default-detection-model-kQnvoRyv.js → default-detection-model-CAWhHQo2.js} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DBHsAlUE.mjs → dist-g5MfpiBh.mjs} +363 -20
  8. package/dist/{dist-DygcLvgG.js → dist-k6Vu-AhZ.js} +386 -19
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-CACCuE7t.js → node--u_jUObS.js} +1 -1
  12. package/dist/{node-j_3VvNQJ.mjs → node-BXsHQH5h.mjs} +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-fse8NnRi.js → process-memory-DN7opum2.js} +1 -1
  16. package/dist/{process-memory-DcoIQGG5.mjs → process-memory-Gl3EowL-.mjs} +1 -1
  17. package/dist/recorder/index.js +572 -3
  18. package/dist/recorder/index.mjs +572 -3
  19. package/dist/{segment-demux-js-Ce24Y2G0.js → segment-demux-js-DHX8o8pI.js} +1 -1
  20. package/dist/{segment-demux-js-DSgNEMf6.mjs → segment-demux-js-sYP_jndp.mjs} +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-BSZUBMBY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CEy8IryH.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-BlXyfeXU.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 +26 -0
  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-lVR0hopj.mjs → hostInit-UKNN4qes.mjs} +2 -2
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +5 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-_bHa4uUp.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CeR_IxxU.mjs +0 -26
@@ -1,5 +1,5 @@
1
- import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-DBHsAlUE.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-j_3VvNQJ.mjs";
1
+ import { A as RecordingConfigSchema, At as resolveLocationMode, Dt as recordingSignalCapability, Et as recordingExportCapability, Ft as storageOccupancyCapability, G as deriveBatteryPresence, Jt as BaseAddon, Lt as stripRetiredBandPreBufferSec, Mt as resolveRecordingProfiles, N as VISIT_MERGE_GAP_MS, Pt as storageEvictableCapability, Q as evaluateSensorEdge, Sn as string, T as OpsLogEntrySchema, Tt as recordingCapability, Vt as errMsg, Xt as DeviceFeature, Zt as DeviceType, bn as preprocess, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as sleep$1, dt as mayReadLocation, en as customAction, et as evictionPolicyForMode, fn as _enum, ft as mayWriteToLocation, g as EVENT_PAD_MS, h as EVENT_OWNER_TYPES, i as BatteryStatusSchema, j as RecordingSignalStatusSchema, k as RECORDING_EXPORT_MAX_READ_BYTES, mn as boolean, mt as modeMayWrite, ot as isFirstLevelMacroClass, pn as array, pt as migrateLegacyDeviceSignalConfig, q as deriveRecordingMode, rn as hydrateSchema, rt as gbToBytes, sn as nodePin, st as loadContributionCapability, tn as defineCustomActions, tt as evictionPolicyOfLocation, un as selectAssignedProfileSlots, v as ExportRecordSchema, vn as number, wn as EventCategory, xn as record, yn as object, z as batteryCapability, zt as videoclipsCapability } from "../dist-g5MfpiBh.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-BXsHQH5h.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";
@@ -11084,7 +11084,8 @@ Object.freeze({
11084
11084
  "storage-occupancy": ["getOccupancy"],
11085
11085
  "storage-provider": ["list"],
11086
11086
  "turn-provider": ["getTurnServers"],
11087
- "user-passkeys": ["listPasskeys"]
11087
+ "user-passkeys": ["listPasskeys"],
11088
+ "videoclips": ["listClips", "listSources"]
11088
11089
  });
11089
11090
  /**
11090
11091
  * Build the recorder's config mirror.
@@ -13488,6 +13489,567 @@ var PlacementService = class {
13488
13489
  }
13489
13490
  };
13490
13491
  //#endregion
13492
+ //#region src/recorder/addon/videoclip-visits.ts
13493
+ /**
13494
+ * Videoclip visits — the grain a clip should have on an events-mode camera.
13495
+ *
13496
+ * Disk writes one fMP4 per ffmpeg roll (~8–12 s). The recorder calendar merges
13497
+ * only gaps ≤ 5 s, so a single pantry walk shows up as several availability
13498
+ * ranges with ~8 s GOP holes between them. Analytics then mints one 15 s clip
13499
+ * per motion heartbeat. A *visit* is the keep-window those fragments belong
13500
+ * to: cluster availability with {@link VISIT_MERGE_GAP_MS}, annotate with
13501
+ * overlapping events. No second copy of the media; the files stay the source
13502
+ * of truth.
13503
+ */
13504
+ /** Synthetic event-id slot in `analytics:<start>:<end>:<id>` for a visit clip. */
13505
+ var VISIT_EVENT_ID = "visit";
13506
+ function clusterFootageVisits(ranges, gapMs = VISIT_MERGE_GAP_MS) {
13507
+ if (ranges.length === 0) return [];
13508
+ const sorted = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
13509
+ const visits = [];
13510
+ let pieces = [sorted[0]];
13511
+ let endMs = sorted[0].endMs;
13512
+ for (let i = 1; i < sorted.length; i++) {
13513
+ const next = sorted[i];
13514
+ if (next.startMs <= endMs + gapMs) {
13515
+ pieces.push(next);
13516
+ endMs = Math.max(endMs, next.endMs);
13517
+ continue;
13518
+ }
13519
+ visits.push(finishVisit(pieces, endMs));
13520
+ pieces = [next];
13521
+ endMs = next.endMs;
13522
+ }
13523
+ visits.push(finishVisit(pieces, endMs));
13524
+ return visits;
13525
+ }
13526
+ function finishVisit(pieces, endMs) {
13527
+ const holes = [];
13528
+ for (let i = 1; i < pieces.length; i++) {
13529
+ const prevEnd = pieces[i - 1].endMs;
13530
+ const nextStart = pieces[i].startMs;
13531
+ if (nextStart > prevEnd) holes.push({
13532
+ startMs: prevEnd,
13533
+ endMs: nextStart
13534
+ });
13535
+ }
13536
+ return {
13537
+ startMs: pieces[0].startMs,
13538
+ endMs,
13539
+ ranges: pieces,
13540
+ holes
13541
+ };
13542
+ }
13543
+ var KIND_ORDER = [
13544
+ "object",
13545
+ "motion",
13546
+ "audio"
13547
+ ];
13548
+ function eventOverlapsVisit(event, visit, pad) {
13549
+ if (event.timestamp >= visit.startMs && event.timestamp <= visit.endMs) return true;
13550
+ const windowStart = event.timestamp - pad.preMs;
13551
+ const windowEnd = event.timestamp + pad.postMs;
13552
+ return windowStart < visit.endMs && windowEnd > visit.startMs;
13553
+ }
13554
+ function dominantKind(attached) {
13555
+ for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
13556
+ return "motion";
13557
+ }
13558
+ /**
13559
+ * The instant a recorded still can actually be decoded inside a visit: the
13560
+ * midpoint of its LONGEST contiguous range.
13561
+ *
13562
+ * NOT the midpoint of the visit. A visit spans its holes by construction (it is
13563
+ * ranges glued across gaps up to `VISIT_MERGE_GAP_MS`), so the naive midpoint
13564
+ * lands in dead air roughly as often as not and the recorder's still route
13565
+ * answers 204 for a visit with plenty of footage.
13566
+ */
13567
+ function stillInstantMs(ranges) {
13568
+ let best;
13569
+ for (const range of ranges) if (best === void 0 || range.endMs - range.startMs > best.endMs - best.startMs) best = range;
13570
+ if (best === void 0) return void 0;
13571
+ return Math.floor((best.startMs + best.endMs) / 2);
13572
+ }
13573
+ /**
13574
+ * The distinct object classes attached to a visit, most frequent first,
13575
+ * capped at {@link MAX_CLIP_LABELS}. Empty when nothing classified overlapped
13576
+ * — the caller then omits the field so a motion/audio-only visit keeps its
13577
+ * kind label instead of carrying an empty array every consumer length-checks.
13578
+ */
13579
+ function dominantLabels(attached) {
13580
+ const counts = /* @__PURE__ */ new Map();
13581
+ for (const a of attached) {
13582
+ if (a.kind !== "object") continue;
13583
+ const name = a.event.className;
13584
+ if (typeof name !== "string" || name.length === 0) continue;
13585
+ counts.set(name, (counts.get(name) ?? 0) + 1);
13586
+ }
13587
+ return [...counts.entries()].toSorted((x, y) => y[1] - x[1]).slice(0, 3).map(([name]) => name);
13588
+ }
13589
+ function visitsToClips(visits, events, pad, vouched) {
13590
+ const tagged = [
13591
+ ...events.object.map((event) => ({
13592
+ kind: "object",
13593
+ event
13594
+ })),
13595
+ ...events.motion.map((event) => ({
13596
+ kind: "motion",
13597
+ event
13598
+ })),
13599
+ ...events.audio.map((event) => ({
13600
+ kind: "audio",
13601
+ event
13602
+ }))
13603
+ ];
13604
+ return visits.map((visit) => {
13605
+ const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
13606
+ const kind = dominantKind(attached);
13607
+ 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));
13608
+ const stillAtMs = stillInstantMs(visit.ranges);
13609
+ const labels = dominantLabels(attached);
13610
+ return {
13611
+ id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
13612
+ source: "analytics",
13613
+ kind,
13614
+ timeRange: {
13615
+ startMs: visit.startMs,
13616
+ endMs: visit.endMs
13617
+ },
13618
+ ...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
13619
+ ...stillAtMs !== void 0 ? { stillAtMs } : {},
13620
+ eventIds: attached.slice(0, 24).map((a) => a.event.id),
13621
+ eventCount: attached.length,
13622
+ holes: [...visit.holes],
13623
+ ...labels.length > 0 ? { labels } : {}
13624
+ };
13625
+ }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
13626
+ }
13627
+ //#endregion
13628
+ //#region src/recorder/addon/videoclips-provider.ts
13629
+ /**
13630
+ * Default `videoclips` provider — composes recorded footage visits with the
13631
+ * analytics event log. A clip is a time-WINDOW over existing footage, never a
13632
+ * separate file: `getClipPlayback` resolves to
13633
+ * `recording.getPlaybackManifest(startMs, endMs)`.
13634
+ *
13635
+ * When availability is present the grain is a *visit* (events-mode keep-window
13636
+ * clustered at `VISIT_MERGE_GAP_MS`), not one clip per motion heartbeat. The
13637
+ * clip id still encodes the window (`analytics:<startMs>:<endMs>:visit`) so
13638
+ * playback is self-contained. Without availability (or when that read fails)
13639
+ * the provider falls back to per-event padded windows.
13640
+ *
13641
+ * Pure helpers (`eventsToClips` / `clusterFootageVisits` / `clipIdFor` /
13642
+ * `parseClipId`) are unit-tested in isolation; the provider factory wires
13643
+ * injected fetch/resolve deps so the compose logic is testable without I/O.
13644
+ */
13645
+ var SOURCE = "analytics";
13646
+ /** The addon that registers this provider — stamped on its source row. */
13647
+ var CLIP_SOURCE_ADDON_ID = "recorder";
13648
+ /**
13649
+ * Backstop on the ANNOTATION read — never on the clip list.
13650
+ *
13651
+ * A visit is annotated by every event inside it, so the clip `limit` must not
13652
+ * reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
13653
+ * bound exists only so a pathological camera-day cannot pull an unbounded page:
13654
+ * the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
13655
+ * the EARLIEST visits silently lose their annotations — which is why hitting it
13656
+ * is logged, per camera, rather than absorbed.
13657
+ */
13658
+ var VISIT_EVENT_FETCH_LIMIT = 5e3;
13659
+ function clipIdFor(eventId, startMs, endMs) {
13660
+ return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
13661
+ }
13662
+ /** Parse a default-provider clip id back into its window + event id. Returns
13663
+ * `null` for any id this provider did not mint (e.g. a native clip id). */
13664
+ function parseClipId(clipId) {
13665
+ const parts = clipId.split(":");
13666
+ if (parts.length < 4 || parts[0] !== SOURCE) return null;
13667
+ const startMs = Number(parts[1]);
13668
+ const endMs = Number(parts[2]);
13669
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
13670
+ const eventId = parts.slice(3).join(":");
13671
+ if (!eventId) return null;
13672
+ return {
13673
+ eventId,
13674
+ startMs,
13675
+ endMs
13676
+ };
13677
+ }
13678
+ /** Map per-kind events into time-ranged clips, newest-first. Pure. */
13679
+ function eventsToClips(events, pad, vouched) {
13680
+ const make = (e, kind) => {
13681
+ const startMs = e.timestamp - pad.preMs;
13682
+ const endMs = e.timestamp + pad.postMs;
13683
+ return {
13684
+ id: clipIdFor(e.id, startMs, endMs),
13685
+ source: SOURCE,
13686
+ kind,
13687
+ timeRange: {
13688
+ startMs,
13689
+ endMs
13690
+ },
13691
+ ...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
13692
+ };
13693
+ };
13694
+ return [
13695
+ ...events.motion.map((e) => make(e, "motion")),
13696
+ ...events.object.map((e) => make(e, "object")),
13697
+ ...events.audio.map((e) => make(e, "audio"))
13698
+ ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
13699
+ }
13700
+ /** Every id in the three buckets, in the order the composers tag them. */
13701
+ function allEventIds(events) {
13702
+ return [
13703
+ ...events.motion,
13704
+ ...events.object,
13705
+ ...events.audio
13706
+ ].map((e) => e.id);
13707
+ }
13708
+ /**
13709
+ * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
13710
+ * `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
13711
+ * owning TRACK's. Up to four batched index reads — one PER EVENT KIND that has
13712
+ * any ids (D476: `motion` / `object` / `audio` are separate owner kinds now,
13713
+ * so a single combined read can no longer ask "does any of these ids own
13714
+ * media" — a motion id and an object id can be the same number post-D474 and
13715
+ * are answered by different rows) — plus the track fallback, only over the
13716
+ * object events the first pass did not already answer for (motion and audio
13717
+ * are trackless roots, so they never need it).
13718
+ *
13719
+ * THROWS on a failed read. The caller fails closed on the CLAIM and open on the
13720
+ * LIST; swallowing here would collapse those two opposite directions into one.
13721
+ */
13722
+ async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
13723
+ const ownMedia = /* @__PURE__ */ new Set();
13724
+ for (const ownerType of EVENT_OWNER_TYPES) {
13725
+ const ownerIds = events[ownerType].map((e) => e.id);
13726
+ if (ownerIds.length === 0) continue;
13727
+ const found = await fetchMediaOwners({
13728
+ deviceId,
13729
+ ownerKind: ownerType,
13730
+ ownerIds
13731
+ });
13732
+ for (const id of found) ownMedia.add(id);
13733
+ }
13734
+ const residualTracks = [];
13735
+ const seenTracks = /* @__PURE__ */ new Set();
13736
+ for (const event of events.object) {
13737
+ const trackId = event.trackId;
13738
+ if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
13739
+ seenTracks.add(trackId);
13740
+ residualTracks.push(trackId);
13741
+ }
13742
+ const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
13743
+ deviceId,
13744
+ ownerKind: "track",
13745
+ ownerIds: residualTracks
13746
+ });
13747
+ const vouched = new Set(ownMedia);
13748
+ for (const event of events.object) {
13749
+ const trackId = event.trackId;
13750
+ if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
13751
+ }
13752
+ return vouched;
13753
+ }
13754
+ /** Keep only clips that actually touch the caller's window. The reads are
13755
+ * widened by {@link leftOverhangMs}; the ANSWER is not. */
13756
+ function intersectsWindow(clip, since, until) {
13757
+ return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
13758
+ }
13759
+ /**
13760
+ * How far BEFORE `since` the event + availability reads must reach.
13761
+ *
13762
+ * A visit straddling `since` keeps its heartbeat rows and loses the transition
13763
+ * row — the only one that ever carries a snapshot — so the same visit shows a
13764
+ * picture or doesn't depending on where the operator scrolled. One wider range
13765
+ * scan on an indexed column buys a list that stops changing under the cursor.
13766
+ */
13767
+ function leftOverhangMs(pad) {
13768
+ return VISIT_MERGE_GAP_MS + pad.preMs;
13769
+ }
13770
+ /** No events at all — what the degraded branch annotates its visits with. */
13771
+ var NO_EVENTS = {
13772
+ motion: [],
13773
+ object: [],
13774
+ audio: []
13775
+ };
13776
+ function createVideoclipsProvider(deps) {
13777
+ /**
13778
+ * Cameras whose LAST `listClips` could not read the analytics event log, with
13779
+ * the refusal verbatim.
13780
+ *
13781
+ * Not a cache of anybody else's fact (D224): it is this provider reporting
13782
+ * what IT just did, refreshed on the same call that produces it and cleared
13783
+ * by the next read that succeeds. `listSources` has no window argument and
13784
+ * must not go and ask again — an extra events read on the listing path would
13785
+ * be a second, disagreeing answer to the question `listClips` already
13786
+ * answered.
13787
+ */
13788
+ const eventsUnreachable = /* @__PURE__ */ new Map();
13789
+ return {
13790
+ listClips: async ({ deviceId, since, until, limit }) => {
13791
+ const readSince = since - leftOverhangMs(deps.defaultPad);
13792
+ let events;
13793
+ try {
13794
+ events = await deps.fetchEvents({
13795
+ deviceId,
13796
+ since: readSince,
13797
+ until,
13798
+ limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
13799
+ });
13800
+ } catch (err) {
13801
+ return await listWithoutEvents(deps, eventsUnreachable, errMessage(err), {
13802
+ deviceId,
13803
+ since,
13804
+ until,
13805
+ readSince,
13806
+ limit
13807
+ });
13808
+ }
13809
+ eventsUnreachable.delete(deviceId);
13810
+ warnOnTruncatedFetch(deps, deviceId, events);
13811
+ let vouched = /* @__PURE__ */ new Set();
13812
+ try {
13813
+ vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
13814
+ } catch (err) {
13815
+ deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
13816
+ deviceId,
13817
+ events: allEventIds(events).length,
13818
+ error: err instanceof Error ? err.message : String(err)
13819
+ });
13820
+ }
13821
+ let clips = eventsToClips(events, deps.defaultPad, vouched);
13822
+ if (deps.fetchAvailability) try {
13823
+ clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
13824
+ deviceId,
13825
+ since: readSince,
13826
+ until
13827
+ }), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
13828
+ } catch (err) {
13829
+ deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
13830
+ deviceId,
13831
+ error: err instanceof Error ? err.message : String(err)
13832
+ });
13833
+ }
13834
+ const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
13835
+ const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
13836
+ reportUnvouchedClips(deps, deviceId, answer);
13837
+ return answer;
13838
+ },
13839
+ /**
13840
+ * The one source this provider serves, on every camera: the hub's own
13841
+ * analytics visits over recorded footage.
13842
+ *
13843
+ * Always `ok`, and that is not optimism — this source IS the process
13844
+ * answering the call, so there is no reachability question to report. A
13845
+ * read that fails INSIDE `listClips` degrades that list (and logs it); a
13846
+ * source that cannot be reached at all is reported by the collection
13847
+ * dispatcher, which is the only layer that can tell the difference.
13848
+ * `label` is what the picker draws.
13849
+ */
13850
+ listSources: async ({ deviceId }) => {
13851
+ const outage = eventsUnreachable.get(deviceId);
13852
+ return [{
13853
+ source: SOURCE,
13854
+ addonId: CLIP_SOURCE_ADDON_ID,
13855
+ label: "CamStack recordings",
13856
+ availability: outage === void 0 ? { state: "ok" } : {
13857
+ state: "ok",
13858
+ reason: `${EVENTS_UNREACHABLE_NOTICE} (${outage})`
13859
+ }
13860
+ }];
13861
+ },
13862
+ getClipPlayback: async ({ deviceId, clipId }) => {
13863
+ const parsed = parseClipId(clipId);
13864
+ if (!parsed) throw new Error(`videoclips: unrecognized clipId "${clipId}"`);
13865
+ return {
13866
+ ...await deps.resolvePlayback({
13867
+ deviceId,
13868
+ startMs: parsed.startMs,
13869
+ endMs: parsed.endMs
13870
+ }),
13871
+ format: "hls"
13872
+ };
13873
+ }
13874
+ };
13875
+ }
13876
+ /** What the source row says while the event log is down. */
13877
+ 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";
13878
+ function errMessage(err) {
13879
+ return err instanceof Error ? err.message : String(err);
13880
+ }
13881
+ /**
13882
+ * Footage present, EVENTS unreachable — the mirror of the two degradations
13883
+ * this provider already had, and the one that could not happen while a single
13884
+ * addon held both halves (D560).
13885
+ *
13886
+ * A visit is a window over recorded footage; the events only ANNOTATE it. So
13887
+ * when availability answers, the visits are still real and still playable —
13888
+ * their ids encode the window — and the honest answer is that list without its
13889
+ * annotations. An availability that answered `[]` yields an empty list that is
13890
+ * TRUE about footage: with availability present a clip only ever comes from a
13891
+ * recorded range, so it is the same answer a successful events read would have
13892
+ * given.
13893
+ *
13894
+ * Without availability (absent, or its read failed too) the clips ARE the
13895
+ * events, so there is nothing left that is true to say. That is a REFUSAL
13896
+ * naming both reads — never an empty list, which reads as "this camera has no
13897
+ * recordings".
13898
+ */
13899
+ async function listWithoutEvents(deps, eventsUnreachable, reason, window) {
13900
+ const { deviceId, since, until, readSince, limit } = window;
13901
+ const fetchAvailability = deps.fetchAvailability;
13902
+ if (fetchAvailability === void 0) {
13903
+ eventsUnreachable.set(deviceId, reason);
13904
+ 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`);
13905
+ }
13906
+ let ranges;
13907
+ try {
13908
+ ranges = await fetchAvailability({
13909
+ deviceId,
13910
+ since: readSince,
13911
+ until
13912
+ });
13913
+ } catch (err) {
13914
+ eventsUnreachable.set(deviceId, reason);
13915
+ throw new Error(`videoclips: neither read answered for device ${deviceId} — events: ${reason}; footage: ${errMessage(err)}. Refusing rather than answering an empty list.`);
13916
+ }
13917
+ eventsUnreachable.set(deviceId, reason);
13918
+ deps.logWarn?.("videoclips: event log unreachable — listing the recorded footage visits without their events", {
13919
+ deviceId,
13920
+ error: reason,
13921
+ ranges: ranges.length
13922
+ });
13923
+ const clips = visitsToClips(clusterFootageVisits(ranges, VISIT_MERGE_GAP_MS), NO_EVENTS, deps.defaultPad, /* @__PURE__ */ new Set()).filter((clip) => intersectsWindow(clip, since, until));
13924
+ return limit != null ? clips.slice(0, limit) : clips;
13925
+ }
13926
+ /** The annotation backstop biting is a SILENT loss of the earliest visits'
13927
+ * events — say so, per camera, instead of shipping a thinner list. */
13928
+ function warnOnTruncatedFetch(deps, deviceId, events) {
13929
+ if (!deps.fetchAvailability) return;
13930
+ const truncated = [
13931
+ "motion",
13932
+ "object",
13933
+ "audio"
13934
+ ].filter((kind) => events[kind].length >= VISIT_EVENT_FETCH_LIMIT);
13935
+ if (truncated.length === 0) return;
13936
+ deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
13937
+ deviceId,
13938
+ kinds: truncated,
13939
+ limit: VISIT_EVENT_FETCH_LIMIT
13940
+ });
13941
+ }
13942
+ /** The operator's acceptance criterion as a number: a rising count on one
13943
+ * camera is its motion-snapshot capturer failing, not a quiet day. */
13944
+ function reportUnvouchedClips(deps, deviceId, clips) {
13945
+ const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
13946
+ if (clipsWithoutStill === 0) return;
13947
+ deps.logInfo?.("videoclips: visit has no vouched still", {
13948
+ deviceId,
13949
+ clipsWithoutStill,
13950
+ clips: clips.length
13951
+ });
13952
+ }
13953
+ //#endregion
13954
+ //#region src/recorder/addon/videoclips-shared.ts
13955
+ /** Stringify the numeric event ids of one bucket. Lossless. */
13956
+ function toEventLike(events) {
13957
+ return events.map((e) => ({
13958
+ ...e,
13959
+ id: String(e.id)
13960
+ }));
13961
+ }
13962
+ //#endregion
13963
+ //#region src/recorder/addon/videoclips-wiring.ts
13964
+ /**
13965
+ * The `videoclips` source over THIS addon's archive — the deps half.
13966
+ *
13967
+ * The composer (`videoclips-provider.ts`, `videoclip-visits.ts`) is unchanged
13968
+ * and moved here verbatim from `addon-post-analysis`: a clip is still a time
13969
+ * WINDOW over recorded footage, visits are still clustered at
13970
+ * `VISIT_MERGE_GAP_MS`, and `getClipPlayback` still resolves through
13971
+ * `recording.getPlaybackManifest`. What moved is WHO registers it — the addon
13972
+ * that owns the footage, not one that borrowed it (D560).
13973
+ *
13974
+ * All four reads go over `ctx.api`, the recorder's own two included. That is
13975
+ * not laziness:
13976
+ *
13977
+ * - `recorder` is `placement: 'any-node'`, so several nodes register this
13978
+ * capability and the hub's collection dispatcher resolves the binding to
13979
+ * ONE of them (`addonIdentityOf` strips the `@<nodeId>`). That registration
13980
+ * is not necessarily the node holding this camera's segments.
13981
+ * - `ctx.api.recording.*` is device-scoped and routes to the node that IS
13982
+ * holding them. A short cut to the local `RecordingProvider` would answer a
13983
+ * camera recorded on little-unraid out of the hub's empty index — an empty
13984
+ * clip list on a camera with weeks of footage, which is the exact
13985
+ * misreading this whole line of work exists to end.
13986
+ *
13987
+ * The events arriving over a process boundary is the new failure mode, and the
13988
+ * provider answers it per branch — see `listWithoutEvents`.
13989
+ */
13990
+ /**
13991
+ * Every log line about a camera carries `tags: { deviceId }`, always the
13992
+ * numeric id. A miss rate or a media gap is always asked per camera, and a line
13993
+ * that omits the tag cannot answer it.
13994
+ */
13995
+ function stamp(logger, level) {
13996
+ return (message, meta) => {
13997
+ logger[level](message, {
13998
+ tags: { deviceId: Number(meta["deviceId"]) },
13999
+ meta
14000
+ });
14001
+ };
14002
+ }
14003
+ function createRecorderVideoclipsProvider(deps) {
14004
+ const api = deps.api;
14005
+ return createVideoclipsProvider({
14006
+ defaultPad: EVENT_PAD_MS,
14007
+ fetchEvents: async ({ deviceId, since, until, limit }) => {
14008
+ const query = {
14009
+ deviceId,
14010
+ since,
14011
+ until,
14012
+ limit,
14013
+ projection: "slim"
14014
+ };
14015
+ const [motion, object, audio] = await Promise.all([
14016
+ api.pipelineAnalytics.getMotionEvents.query(query),
14017
+ api.pipelineAnalytics.getObjectEvents.query(query),
14018
+ api.pipelineAnalytics.getAudioEvents.query(query)
14019
+ ]);
14020
+ return {
14021
+ motion: toEventLike(motion),
14022
+ object: toEventLike(object),
14023
+ audio: toEventLike(audio)
14024
+ };
14025
+ },
14026
+ fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => new Set(await api.pipelineAnalytics.ownersWithMedia.query({
14027
+ deviceId,
14028
+ ownerKind,
14029
+ ownerIds
14030
+ })),
14031
+ fetchAvailability: async ({ deviceId, since, until }) => (await api.recording.getAvailability.query({
14032
+ deviceId,
14033
+ fromMs: since,
14034
+ toMs: until
14035
+ })).ranges,
14036
+ logWarn: stamp(deps.logger, "warn"),
14037
+ logInfo: stamp(deps.logger, "info"),
14038
+ resolvePlayback: async ({ deviceId, startMs, endMs }) => {
14039
+ const manifest = await api.recording.getPlaybackManifest.query({
14040
+ deviceId,
14041
+ fromMs: startMs,
14042
+ toMs: endMs
14043
+ });
14044
+ if (manifest.playbackUrl == null) throw new Error(`videoclips: no footage for clip window [${String(startMs)},${String(endMs)}] on device ${String(deviceId)}`);
14045
+ return {
14046
+ playbackUrl: manifest.playbackUrl,
14047
+ ...manifest.playbackEndpoints === void 0 ? {} : { playbackEndpoints: [...manifest.playbackEndpoints] }
14048
+ };
14049
+ }
14050
+ });
14051
+ }
14052
+ //#endregion
13491
14053
  //#region src/recorder/band-engine.ts
13492
14054
  function toMin(hhmm) {
13493
14055
  const [h, m] = hhmm.split(":");
@@ -18645,6 +19207,13 @@ var RecorderV2Addon = class extends BaseAddon {
18645
19207
  capability: recordingCapability,
18646
19208
  provider: recordingProvider
18647
19209
  },
19210
+ {
19211
+ capability: videoclipsCapability,
19212
+ provider: createRecorderVideoclipsProvider({
19213
+ api: this.ctx.api,
19214
+ logger: this.ctx.logger
19215
+ })
19216
+ },
18648
19217
  {
18649
19218
  capability: storageEvictableCapability,
18650
19219
  provider: evictableProvider
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-emK7D4bc.js");
2
- const require_dist = require("./dist-DygcLvgG.js");
2
+ const require_dist = require("./dist-k6Vu-AhZ.js");
3
3
  let node_child_process = require("node:child_process");
4
4
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
5
5
  var require_mp4box_all = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "./chunk-DnnnRqeS.mjs";
2
- import { Lt as errMsg } from "./dist-DBHsAlUE.mjs";
2
+ import { Vt as errMsg } from "./dist-g5MfpiBh.mjs";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
5
5
  var require_mp4box_all = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -1,7 +1,7 @@
1
- import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, p as c, r as l, s as u, t as d, u as f } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CeR_IxxU.mjs";
1
+ import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, p as c, r as l, s as u, t as d, u as f } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-MU3_q1MX.mjs";
2
2
  import { a as p, i as m, n as h, o as g, r as _, t as v } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs";
3
3
  import { n as y, r as b, t as x } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs";
4
- import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-_bHa4uUp.mjs";
4
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BlXyfeXU.mjs";
5
5
  import { n as C, t as w } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs";
6
6
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.js
7
7
  var T = (...e) => e.filter((e, t, n) => !!e && e.trim() !== "" && n.indexOf(e) === t).join(" ").trim(), E = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), D = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), O = (e) => {
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.233",
21
+ version: "1.2.236",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.189",
36
+ version: "1.2.192",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",