@camstack/ui-library 1.2.191 → 1.2.193

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -6407,6 +6407,8 @@ var usePipelineAnalyticsGetTrackMedia = trpc.pipelineAnalytics.getTrackMedia.use
6407
6407
  var usePipelineAnalyticsListTrackMedia = trpc.pipelineAnalytics.listTrackMedia.useQuery;
6408
6408
  /** Generated alias around `trpc.pipelineAnalytics.listEventMedia.useQuery`. */
6409
6409
  var usePipelineAnalyticsListEventMedia = trpc.pipelineAnalytics.listEventMedia.useQuery;
6410
+ /** Generated alias around `trpc.pipelineAnalytics.ownersWithMedia.useQuery`. */
6411
+ var usePipelineAnalyticsOwnersWithMedia = trpc.pipelineAnalytics.ownersWithMedia.useQuery;
6410
6412
  /** Generated alias around `trpc.pipelineAnalytics.searchObjectEvents.useQuery`. */
6411
6413
  var usePipelineAnalyticsSearchObjectEvents = trpc.pipelineAnalytics.searchObjectEvents.useQuery;
6412
6414
  /** Generated alias around `trpc.pipelineAnalytics.wipeObjectEmbeddings.useMutation`. */
@@ -17794,6 +17796,201 @@ var Skeleton = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
17794
17796
  });
17795
17797
  Skeleton.displayName = "Skeleton";
17796
17798
  //#endregion
17799
+ //#region src/composites/cap-settings/recording-timeline-model.ts
17800
+ /** Tie-break precedence for a bucket's dominant kind (first wins on equal count). */
17801
+ var KIND_PRECEDENCE = [
17802
+ "object",
17803
+ "motion",
17804
+ "audio"
17805
+ ];
17806
+ /**
17807
+ * Convert server-side density rows into `DensityBucket[]` for the timeline
17808
+ * heatmap. Rows with total === 0 are skipped (defensive; server already omits
17809
+ * them). Returns a sparse, ascending-by-index array — EventHeatmap positions
17810
+ * each bar by `b.startMs` rather than array index, so gaps are fine.
17811
+ */
17812
+ function densityRowsToBuckets(rows, windowFrom, bucketMs) {
17813
+ const buckets = [];
17814
+ for (const row of rows) {
17815
+ const counts = {
17816
+ motion: row.motion,
17817
+ object: row.object,
17818
+ audio: row.audio
17819
+ };
17820
+ const total = counts.motion + counts.object + counts.audio;
17821
+ if (total === 0) continue;
17822
+ const index = Math.floor((row.bucketStart - windowFrom) / bucketMs);
17823
+ let dominant = null;
17824
+ let best = -1;
17825
+ for (const kind of KIND_PRECEDENCE) if (counts[kind] > best) {
17826
+ best = counts[kind];
17827
+ dominant = kind;
17828
+ }
17829
+ buckets.push({
17830
+ index,
17831
+ startMs: row.bucketStart,
17832
+ endMs: row.bucketStart + bucketMs,
17833
+ counts,
17834
+ total,
17835
+ dominant
17836
+ });
17837
+ }
17838
+ return buckets.toSorted((a, b) => a.index - b.index);
17839
+ }
17840
+ /** Distance from `ms` to the interval `[startMs, endMs]` (0 if inside). */
17841
+ function distanceToSpan(ms, span) {
17842
+ if (ms < span.startMs) return span.startMs - ms;
17843
+ if (ms > span.endMs) return ms - span.endMs;
17844
+ return 0;
17845
+ }
17846
+ /**
17847
+ * Resolve a playback window for a click at `clickMs` against recorded `spans`.
17848
+ * Inside a span → play from the click to that span's end. In a gap / outside →
17849
+ * snap to the WHOLE nearest span. No spans → null (nothing to play).
17850
+ * `spans` are expected ascending by start (as `mergeAvailabilitySpans` returns).
17851
+ */
17852
+ function seekTargetForSpans(clickMs, spans) {
17853
+ if (spans.length === 0) return null;
17854
+ const containing = spans.find((s) => clickMs >= s.startMs && clickMs <= s.endMs);
17855
+ if (containing) return {
17856
+ fromMs: clickMs,
17857
+ toMs: containing.endMs
17858
+ };
17859
+ let nearest = null;
17860
+ let bestDist = Infinity;
17861
+ for (const s of spans) {
17862
+ const d = distanceToSpan(clickMs, s);
17863
+ if (d < bestDist) {
17864
+ bestDist = d;
17865
+ nearest = s;
17866
+ }
17867
+ }
17868
+ return nearest ? {
17869
+ fromMs: nearest.startMs,
17870
+ toMs: nearest.endMs
17871
+ } : null;
17872
+ }
17873
+ /**
17874
+ * Position `ms` inside the window `[fromMs, toMs]` as a clamped percentage
17875
+ * (0–100). A zero/negative-width window yields 0. Callers use this for `left:`.
17876
+ */
17877
+ function pctForMs(ms, fromMs, toMs) {
17878
+ const span = toMs - fromMs;
17879
+ if (span <= 0) return 0;
17880
+ const pct = (ms - fromMs) / span * 100;
17881
+ if (pct < 0) return 0;
17882
+ if (pct > 100) return 100;
17883
+ return pct;
17884
+ }
17885
+ /**
17886
+ * Build a DayWindow for the given calendar `date` using local time.
17887
+ * For any day strictly in the past: `fromMs` = local midnight, `toMs` =
17888
+ * next local midnight (exactly 24 h).
17889
+ * For today (or any future day that contains `nowMs`): `toMs = nowMs` so the
17890
+ * window never extends into the future.
17891
+ */
17892
+ function dayWindow(date, nowMs) {
17893
+ const fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime();
17894
+ const nextMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
17895
+ return {
17896
+ fromMs,
17897
+ toMs: Math.max(fromMs, Math.min(nextMidnight, nowMs))
17898
+ };
17899
+ }
17900
+ /** Available zoom levels in ms, from widest (24h) to narrowest (15 min). */
17901
+ var ZOOM_LEVELS = [
17902
+ 24 * 36e5,
17903
+ 12 * 36e5,
17904
+ 6 * 36e5,
17905
+ 36e5,
17906
+ 15 * 6e4
17907
+ ];
17908
+ /**
17909
+ * Produce a `levelMs`-wide window whose RIGHT edge stays anchored at
17910
+ * `rightEdgeMs` (clamped within `dayBounds`), rather than centring on the view
17911
+ * midpoint. On today's timeline the view's right edge is "now", so zooming this
17912
+ * way keeps the most recent footage pinned to the right instead of drifting to
17913
+ * a midday range that no longer contains now. Panning then moves back/forward.
17914
+ *
17915
+ * Falls back to `dayBounds` when `levelMs` meets or exceeds the day span (can't
17916
+ * show a wider window than the day holds — e.g. early morning with little
17917
+ * elapsed time).
17918
+ */
17919
+ function zoomWindowAnchoredRight(dayBounds, levelMs, rightEdgeMs) {
17920
+ if (levelMs >= dayBounds.toMs - dayBounds.fromMs) return dayBounds;
17921
+ const toMs = Math.min(Math.max(rightEdgeMs, dayBounds.fromMs + levelMs), dayBounds.toMs);
17922
+ return {
17923
+ fromMs: toMs - levelMs,
17924
+ toMs
17925
+ };
17926
+ }
17927
+ /**
17928
+ * Shift `win` by `deltaMs`, preserving width, clamped within `dayBounds`.
17929
+ */
17930
+ function panWindow(win, dayBounds, deltaMs) {
17931
+ const width = win.toMs - win.fromMs;
17932
+ let fromMs = win.fromMs + deltaMs;
17933
+ let toMs = win.toMs + deltaMs;
17934
+ if (fromMs < dayBounds.fromMs) {
17935
+ fromMs = dayBounds.fromMs;
17936
+ toMs = fromMs + width;
17937
+ }
17938
+ if (toMs > dayBounds.toMs) {
17939
+ toMs = dayBounds.toMs;
17940
+ fromMs = toMs - width;
17941
+ }
17942
+ return {
17943
+ fromMs,
17944
+ toMs
17945
+ };
17946
+ }
17947
+ /** Candidate bucket sizes in ms (coarsest first). */
17948
+ var BUCKET_CANDIDATES = [
17949
+ 15 * 6e4,
17950
+ 5 * 6e4,
17951
+ 6e4,
17952
+ 3e4,
17953
+ 15e3,
17954
+ 5e3
17955
+ ];
17956
+ /**
17957
+ * Choose a sensible heatmap bucket size for a visible span of `spanMs`,
17958
+ * targeting ~96-120 buckets. Always picks from `BUCKET_CANDIDATES`.
17959
+ */
17960
+ function bucketMsForSpan(spanMs) {
17961
+ const targetBucket = spanMs / 100;
17962
+ for (const candidate of BUCKET_CANDIDATES) if (candidate <= targetBucket) return candidate;
17963
+ return BUCKET_CANDIDATES[BUCKET_CANDIDATES.length - 1] ?? 5e3;
17964
+ }
17965
+ /**
17966
+ * Produce `count` evenly-spaced axis ticks spanning [fromMs, toMs].
17967
+ * - `pct` is via pctForMs (first=0, last=100).
17968
+ * - `label` is a localised HH:MM clock string.
17969
+ */
17970
+ function axisTicks(fromMs, toMs, count) {
17971
+ if (count <= 1) return [{
17972
+ pct: 0,
17973
+ label: new Date(fromMs).toLocaleTimeString(void 0, {
17974
+ hour: "2-digit",
17975
+ minute: "2-digit"
17976
+ })
17977
+ }];
17978
+ const ticks = [];
17979
+ for (let i = 0; i < count; i++) {
17980
+ const ms = fromMs + i / (count - 1) * (toMs - fromMs);
17981
+ const pct = pctForMs(ms, fromMs, toMs);
17982
+ const label = new Date(ms).toLocaleTimeString(void 0, {
17983
+ hour: "2-digit",
17984
+ minute: "2-digit"
17985
+ });
17986
+ ticks.push({
17987
+ pct,
17988
+ label
17989
+ });
17990
+ }
17991
+ return ticks;
17992
+ }
17993
+ //#endregion
17797
17994
  //#region src/hooks/use-is-mobile.ts
17798
17995
  var MOBILE_QUERY = "(max-width: 767px)";
17799
17996
  /**
@@ -18430,6 +18627,266 @@ function ErrorBox({ message, title, onRetry, action, variant = "danger", classNa
18430
18627
  });
18431
18628
  }
18432
18629
  //#endregion
18630
+ //#region src/composites/cap-settings/EventHeatmap.tsx
18631
+ var KIND_BG = {
18632
+ motion: "bg-emerald-500",
18633
+ object: "bg-violet-500",
18634
+ audio: "bg-orange-500"
18635
+ };
18636
+ function EventHeatmap({ windowFrom, windowTo, buckets, selectedIndex, onSelectBucket }) {
18637
+ const peak = buckets.reduce((m, b) => Math.max(m, b.total), 0);
18638
+ const firstBucket = buckets[0];
18639
+ const bucketWidthPct = firstBucket ? pctForMs(firstBucket.endMs, windowFrom, windowTo) - pctForMs(firstBucket.startMs, windowFrom, windowTo) : 0;
18640
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18641
+ className: "relative h-6 rounded bg-background/40",
18642
+ children: buckets.map((b) => {
18643
+ if (b.total === 0 || b.dominant === null) return null;
18644
+ const heightPct = peak > 0 ? Math.max(15, b.total / peak * 100) : 0;
18645
+ const left = pctForMs(b.startMs, windowFrom, windowTo);
18646
+ const selected = b.index === selectedIndex;
18647
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18648
+ type: "button",
18649
+ "data-testid": "heatmap-bar",
18650
+ title: `${new Date(b.startMs).toLocaleTimeString(void 0, {
18651
+ hour: "2-digit",
18652
+ minute: "2-digit"
18653
+ })} · motion ${b.counts.motion} · object ${b.counts.object}`,
18654
+ onClick: () => onSelectBucket(b.index),
18655
+ className: `absolute bottom-0 rounded-t-sm transition-opacity ${KIND_BG[b.dominant]} ${selected ? "opacity-100 ring-1 ring-amber-400" : "opacity-80 hover:opacity-100"}`,
18656
+ style: {
18657
+ left: `${left}%`,
18658
+ width: `${Math.max(.6, bucketWidthPct)}%`,
18659
+ height: `${heightPct}%`
18660
+ }
18661
+ }, b.index);
18662
+ })
18663
+ });
18664
+ }
18665
+ //#endregion
18666
+ //#region src/composites/cap-settings/TimelineControls.tsx
18667
+ /** Format a Date as "YYYY-MM-DD" for <input type="date">. */
18668
+ function formatDateInput(date) {
18669
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
18670
+ }
18671
+ /** Parse "YYYY-MM-DD" from <input type="date"> into a local-midnight Date. */
18672
+ function parseDateInput(value) {
18673
+ const parts = value.split("-");
18674
+ const y = parts[0] !== void 0 ? parseInt(parts[0], 10) : NaN;
18675
+ const m = parts[1] !== void 0 ? parseInt(parts[1], 10) : NaN;
18676
+ const d = parts[2] !== void 0 ? parseInt(parts[2], 10) : NaN;
18677
+ if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
18678
+ return new Date(y, m - 1, d);
18679
+ }
18680
+ /**
18681
+ * Date selector + prev/next pan + zoom-in / zoom-out buttons for the recording
18682
+ * timeline. Zoom cycles through ZOOM_LEVELS (widest → narrowest), anchored on
18683
+ * the view's RIGHT edge — so on today's timeline "now" stays pinned to the right
18684
+ * instead of drifting to a midday range. The ◀ / ▶ buttons step the visible
18685
+ * window earlier/later within the day (the discoverable equivalent of the
18686
+ * horizontal wheel-pan wired in RecordingTimeline), so a zoomed-in day can be
18687
+ * navigated back and forth without a mouse wheel.
18688
+ */
18689
+ function TimelineControls({ day, onDayChange, view, dayBounds, onViewChange }) {
18690
+ const handleDateChange = (e) => {
18691
+ const parsed = parseDateInput(e.target.value);
18692
+ if (parsed !== null) onDayChange(parsed);
18693
+ };
18694
+ const viewSpan = view.toMs - view.fromMs;
18695
+ const currentLevelIdx = (() => {
18696
+ let best = 0;
18697
+ let bestDiff = Infinity;
18698
+ for (let i = 0; i < ZOOM_LEVELS.length; i++) {
18699
+ const level = ZOOM_LEVELS[i];
18700
+ if (level === void 0) continue;
18701
+ const diff = Math.abs(level - viewSpan);
18702
+ if (diff < bestDiff) {
18703
+ bestDiff = diff;
18704
+ best = i;
18705
+ }
18706
+ }
18707
+ return best;
18708
+ })();
18709
+ const rightEdgeMs = view.toMs;
18710
+ const handleZoomIn = () => {
18711
+ const nextLevel = ZOOM_LEVELS[Math.min(currentLevelIdx + 1, ZOOM_LEVELS.length - 1)];
18712
+ if (nextLevel === void 0) return;
18713
+ onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
18714
+ };
18715
+ const handleZoomOut = () => {
18716
+ const nextLevel = ZOOM_LEVELS[Math.max(currentLevelIdx - 1, 0)];
18717
+ if (nextLevel === void 0) return;
18718
+ onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
18719
+ };
18720
+ const canZoomIn = currentLevelIdx < ZOOM_LEVELS.length - 1;
18721
+ const canZoomOut = currentLevelIdx > 0;
18722
+ const panStepMs = Math.max(1, Math.round(viewSpan * .9));
18723
+ const canPanPrev = view.fromMs > dayBounds.fromMs;
18724
+ const canPanNext = view.toMs < dayBounds.toMs;
18725
+ const handlePanPrev = () => {
18726
+ onViewChange(panWindow(view, dayBounds, -panStepMs));
18727
+ };
18728
+ const handlePanNext = () => {
18729
+ onViewChange(panWindow(view, dayBounds, panStepMs));
18730
+ };
18731
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18732
+ className: "flex items-center gap-2",
18733
+ children: [
18734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18735
+ type: "date",
18736
+ value: formatDateInput(day),
18737
+ max: formatDateInput(/* @__PURE__ */ new Date()),
18738
+ onChange: handleDateChange,
18739
+ className: "rounded border border-border bg-background px-2 py-0.5 text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
18740
+ "aria-label": "Select recording day"
18741
+ }),
18742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18743
+ className: "ml-auto flex items-center gap-1",
18744
+ children: [
18745
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18746
+ type: "button",
18747
+ onClick: handlePanPrev,
18748
+ disabled: !canPanPrev,
18749
+ title: "Earlier (pan back)",
18750
+ "aria-label": "Pan earlier",
18751
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18752
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { size: 14 })
18753
+ }),
18754
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18755
+ type: "button",
18756
+ onClick: handlePanNext,
18757
+ disabled: !canPanNext,
18758
+ title: "Later (pan forward)",
18759
+ "aria-label": "Pan later",
18760
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18761
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { size: 14 })
18762
+ }),
18763
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18764
+ className: "mx-0.5 h-3 w-px bg-border",
18765
+ "aria-hidden": "true"
18766
+ }),
18767
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18768
+ type: "button",
18769
+ onClick: handleZoomOut,
18770
+ disabled: !canZoomOut,
18771
+ title: "Zoom out",
18772
+ "aria-label": "Zoom out",
18773
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18774
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { size: 13 })
18775
+ }),
18776
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18777
+ type: "button",
18778
+ onClick: handleZoomIn,
18779
+ disabled: !canZoomIn,
18780
+ title: "Zoom in",
18781
+ "aria-label": "Zoom in",
18782
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18783
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { size: 13 })
18784
+ })
18785
+ ]
18786
+ }),
18787
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18788
+ className: "text-[9px] text-foreground-subtle",
18789
+ children: zoomLabel(viewSpan)
18790
+ })
18791
+ ]
18792
+ });
18793
+ }
18794
+ function zoomLabel(spanMs) {
18795
+ const h = spanMs / 36e5;
18796
+ if (h >= 24) return "24h";
18797
+ if (h >= 1) return `${Math.round(h)}h`;
18798
+ const m = spanMs / 6e4;
18799
+ return `${Math.round(m)}m`;
18800
+ }
18801
+ //#endregion
18802
+ //#region src/composites/clips/ClipBandBar.tsx
18803
+ /**
18804
+ * The clips section's timebar — the recording section's own controls, driving
18805
+ * a clip list instead of a player (D564).
18806
+ *
18807
+ * *"Possiamo utilizzare lo stesso componente della sezione recording con la
18808
+ * timebar dove selezionare un periodo e mostrare le clip solo di quella banda
18809
+ * selezionata, espandibile."* — the operator, 2026-09-20. So this is not a
18810
+ * second timebar: it is `TimelineControls` and `EventHeatmap`, the two pieces
18811
+ * `RecordingTimeline` is itself built from, over the same
18812
+ * `recording-timeline-model` (`dayWindow`, `panWindow`,
18813
+ * `zoomWindowAnchoredRight`, `bucketMsForSpan`, `axisTicks`). One
18814
+ * implementation, one model, two surfaces.
18815
+ *
18816
+ * ## What it does NOT take from the recording timeline
18817
+ *
18818
+ * `CoverageTrack` stays behind. There it is the seek surface — a click drives
18819
+ * the hero player through the `camstack-control` channel or an HLS VOD
18820
+ * request. A copy here would either be a dead bar or a SECOND thing claiming
18821
+ * to move the playhead, and two components holding two ideas of the same
18822
+ * position is the defect this repo keeps paying for. The clips section plays a
18823
+ * clip through `ClipPanel`, which is its own, already-shipped path.
18824
+ *
18825
+ * ## This component is presentational
18826
+ *
18827
+ * It holds no state. The day, the visible window and the picked bucket all
18828
+ * live in `ClipsSection`, because that is where the band is turned into the
18829
+ * `listClips` argument — a bar that kept its own copy of the selection would
18830
+ * be the second authority again, one component down.
18831
+ */
18832
+ /**
18833
+ * How many ms one pixel of horizontal wheel delta pans.
18834
+ *
18835
+ * The same calibration `RecordingTimeline` uses — a standard wheel tick
18836
+ * (~100 px of `deltaX`) pans ~30 min. Two timebars that panned at different
18837
+ * rates would read as two different controls.
18838
+ */
18839
+ var PAN_MS_PER_PX$1 = 18e3 / 100;
18840
+ function ClipBandBar({ day, onDayChange, view, dayBounds, onViewChange, buckets, selectedBucketIndex, onSelectBucket, densityLoading }) {
18841
+ const trackAreaRef = (0, react$1.useRef)(null);
18842
+ const handleWheel = (0, react$1.useCallback)((e) => {
18843
+ const deltaX = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;
18844
+ if (deltaX === 0) return;
18845
+ e.preventDefault();
18846
+ onViewChange(panWindow(view, dayBounds, deltaX * PAN_MS_PER_PX$1));
18847
+ }, [
18848
+ view,
18849
+ dayBounds,
18850
+ onViewChange
18851
+ ]);
18852
+ const ticks = axisTicks(view.fromMs, view.toMs, 5);
18853
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18854
+ className: "space-y-1",
18855
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimelineControls, {
18856
+ day,
18857
+ onDayChange,
18858
+ view,
18859
+ dayBounds,
18860
+ onViewChange
18861
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18862
+ ref: trackAreaRef,
18863
+ onWheel: handleWheel,
18864
+ className: "space-y-1",
18865
+ children: [
18866
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18867
+ className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
18868
+ children: "Events · density"
18869
+ }),
18870
+ densityLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-6 animate-pulse rounded bg-background/40" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventHeatmap, {
18871
+ windowFrom: view.fromMs,
18872
+ windowTo: view.toMs,
18873
+ buckets,
18874
+ selectedIndex: selectedBucketIndex,
18875
+ onSelectBucket
18876
+ }),
18877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18878
+ className: "relative h-3",
18879
+ children: ticks.map((tick) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18880
+ className: "absolute -translate-x-1/2 text-[9px] text-foreground-subtle",
18881
+ style: { left: `${String(tick.pct)}%` },
18882
+ children: tick.label
18883
+ }, tick.pct))
18884
+ })
18885
+ ]
18886
+ })]
18887
+ });
18888
+ }
18889
+ //#endregion
18433
18890
  //#region src/contexts/vod-playback.tsx
18434
18891
  /**
18435
18892
  * VodPlayback — shared context coordinating recorded (VOD) playback between a
@@ -18588,24 +19045,58 @@ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
18588
19045
  });
18589
19046
  }
18590
19047
  //#endregion
19048
+ //#region src/lib/format-duration.ts
19049
+ /**
19050
+ * How long something lasted, the way this product says it.
19051
+ *
19052
+ * Promoted verbatim from `composites/cap-settings/RecordingPanel.tsx`, which
19053
+ * had been the only place answering this question about MEDIA. It is now the
19054
+ * only place, so a clip's length and a recording's retention read the same.
19055
+ *
19056
+ * Two other private formatters stay where they are, on purpose:
19057
+ * `config-form-field.tsx` formats a config VALUE (sub-second `ms`, `—` for a
19058
+ * value that is not a number), and `scene-monitor-copy.ts`'s `humanDuration`
19059
+ * is deliberately single-unit and coarse for a sentence. Neither is a media
19060
+ * length, and folding them in here would change what they say.
19061
+ */
19062
+ function formatDurationMs(ms) {
19063
+ const totalSec = Math.round(ms / 1e3);
19064
+ const h = Math.floor(totalSec / 3600);
19065
+ const m = Math.floor(totalSec % 3600 / 60);
19066
+ const s = totalSec % 60;
19067
+ if (h > 0) return `${h}h ${m}m`;
19068
+ if (m > 0) return `${m}m ${s}s`;
19069
+ return `${s}s`;
19070
+ }
19071
+ //#endregion
18591
19072
  //#region src/composites/clips/clip-tile-media.ts
18592
19073
  /**
18593
- * Why no still exists, in words. Keyed by the provider's own vocabulary so a
18594
- * reason this table does not know still reaches the operator verbatim.
19074
+ * Why no still exists, in words, keyed by the refusal CODE.
19075
+ *
19076
+ * A code this table does not know falls back to the provider's own prose, so a
19077
+ * firmware inventing a refusal reaches the operator rather than becoming a
19078
+ * blank tile.
18595
19079
  */
18596
19080
  var REFUSAL_TEXT = {
18597
- sleeping: "The camera is asleep — a still would cost a wake",
18598
- "camera-refused": "The camera refused the still request",
18599
- "no-keyframe": "No decodable frame inside this window",
18600
- unsupported: "This source cannot produce stills",
18601
- "queue-full": "The camera is busy — its still queue is full",
18602
- "no-catalog-row": "This clip is no longer in the catalog"
19081
+ sleeping: () => "The camera is asleep — a still would cost a wake",
19082
+ "camera-refused": (reason) => `The camera refused — ${reason}`,
19083
+ "no-keyframe": () => "No decodable frame inside this window",
19084
+ unsupported: () => "This source cannot produce stills",
19085
+ "queue-full": () => "Waiting for the camera — its still queue is full",
19086
+ "camera-backoff": () => "The camera is resting after refusing — trying again shortly",
19087
+ "no-catalog-row": () => "This clip is no longer in the catalog",
19088
+ "unknown-device": () => "This camera is not served here",
19089
+ "uid-missing": () => "The camera’s id is not known yet"
18603
19090
  };
18604
- function refusal(reason) {
19091
+ function wordsFor(code, reason) {
19092
+ const known = code === void 0 ? void 0 : REFUSAL_TEXT[code];
19093
+ return known === void 0 ? reason : known(reason);
19094
+ }
19095
+ function refusal(reason, code) {
18605
19096
  return {
18606
19097
  kind: "unavailable",
18607
19098
  reason,
18608
- text: REFUSAL_TEXT[reason] ?? `Refused: ${reason}`
19099
+ text: wordsFor(code, reason)
18609
19100
  };
18610
19101
  }
18611
19102
  /** What to draw for one clip's tile. */
@@ -18614,7 +19105,7 @@ function resolveTileMedia(clip) {
18614
19105
  kind: "vouched",
18615
19106
  url: clip.thumbnail
18616
19107
  };
18617
- if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
19108
+ if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason, clip.thumbnailUnavailable.reason);
18618
19109
  if (clip.thumbnailMint !== void 0) return {
18619
19110
  kind: "mint",
18620
19111
  url: clip.thumbnailMint
@@ -18625,26 +19116,32 @@ function resolveTileMedia(clip) {
18625
19116
  };
18626
19117
  }
18627
19118
  /**
18628
- * A mint that answered 204 — latch its `x-camstack-reason` to this instant.
19119
+ * A 204, as something the tile can draw.
18629
19120
  *
18630
- * The refusal is about NOW (a full queue, a sleeping camera), so it replaces
18631
- * the offer for as long as the tile is mounted and is not retried behind the
18632
- * operator's back. An unknown reason is shown raw rather than dropped: a
18633
- * firmware inventing a refusal must not become a blank tile.
19121
+ * `deferred` is the ONLY answer that comes back, and only because the server
19122
+ * said so. Everything else — an unknown disposition, an absent one, a code
19123
+ * this build has never heard of — is final, so the failure mode of a
19124
+ * vocabulary that moves is a tile that stops early, never one that asks for
19125
+ * ever.
18634
19126
  */
18635
- function latchMintRefusal(reason) {
18636
- return refusal(reason);
19127
+ function describeRefusal(headers) {
19128
+ if (headers.disposition === "deferred") return {
19129
+ kind: "deferred",
19130
+ reason: headers.reason,
19131
+ text: wordsFor(headers.code, headers.reason),
19132
+ retryAfterMs: headers.retryAfterMs ?? null
19133
+ };
19134
+ return refusal(headers.reason, headers.code);
18637
19135
  }
18638
19136
  //#endregion
18639
19137
  //#region src/composites/clips/clip-thumbnail-fetch.ts
18640
19138
  /**
18641
- * Asking a `thumbnailMint` route for a still — and hearing its refusal.
19139
+ * Asking a `thumbnailMint` route for a still — and hearing what it says.
18642
19140
  *
18643
- * The route answers **200** with a JPEG or **204** with the reason in
18644
- * `x-camstack-reason` (`queue-full`, `sleeping`, `camera-refused`,
18645
- * `no-keyframe`, `no-catalog-row`). An `<img src>` cannot read that header:
18646
- * a 204 fires `onerror` with nothing to say, turning a named refusal into an
18647
- * anonymous broken tile. So a mint goes through `fetch`.
19141
+ * The route answers **200** with a JPEG or **204** with a refusal. An
19142
+ * `<img src>` cannot read a 204's headers: it fires `onerror` with nothing to
19143
+ * say, turning a named refusal into an anonymous broken tile. So a mint goes
19144
+ * through `fetch`.
18648
19145
  *
18649
19146
  * **Nothing is appended to the URL.** Both data-plane routes are registered
18650
19147
  * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
@@ -18652,8 +19149,18 @@ function latchMintRefusal(reason) {
18652
19149
  * credential (D549 decision 22) — it may sit in history or a `Referer` and
18653
19150
  * open nothing.
18654
19151
  *
18655
- * The refusal is about NOW and is LATCHED by the caller: a camera that just
18656
- * said "busy" is not asked again behind the operator's back.
19152
+ * ## Three headers, one of which decides everything (D563)
19153
+ *
19154
+ * `x-camstack-reason-code` is the token the tile keys its words on,
19155
+ * `x-camstack-reason` is the camera's own prose, and
19156
+ * `x-camstack-disposition` says whether to come back. This module reads them;
19157
+ * it does not classify them. A response with no disposition — every
19158
+ * provider-reolink build before D563 — is FINAL, which is precisely the
19159
+ * behaviour that shipped.
19160
+ *
19161
+ * A thrown fetch is DEFERRED: nothing refused anything, one request lost a
19162
+ * race, and a tile that goes permanently blank for that is the same defect
19163
+ * with a different cause.
18657
19164
  */
18658
19165
  /**
18659
19166
  * Ask a mint route for one still.
@@ -18667,19 +19174,56 @@ async function fetchMintedThumbnail(url, fetcher) {
18667
19174
  try {
18668
19175
  response = await fetcher(url, { credentials: "same-origin" });
18669
19176
  } catch (err) {
18670
- return latchMintRefusal(err instanceof Error ? err.message : String(err));
19177
+ return deferredNetworkFailure(err);
18671
19178
  }
18672
- if (response.status === 204) return latchMintRefusal(response.headers.get("x-camstack-reason") ?? "no reason given");
18673
- if (!response.ok) return latchMintRefusal(`HTTP ${String(response.status)}`);
19179
+ if (response.status === 204) return describeRefusal(refusalHeadersOf(response));
19180
+ if (!response.ok) return describeRefusal({
19181
+ code: void 0,
19182
+ reason: `HTTP ${String(response.status)}`,
19183
+ disposition: void 0
19184
+ });
18674
19185
  try {
18675
19186
  return {
18676
19187
  kind: "image",
18677
19188
  blob: await response.blob()
18678
19189
  };
18679
19190
  } catch (err) {
18680
- return latchMintRefusal(err instanceof Error ? err.message : String(err));
19191
+ return deferredNetworkFailure(err);
18681
19192
  }
18682
19193
  }
19194
+ function refusalHeadersOf(response) {
19195
+ const retryAfter = response.headers.get("retry-after");
19196
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
19197
+ return {
19198
+ code: response.headers.get(_camstack_types.CLIP_STILL_REASON_CODE_HEADER) ?? void 0,
19199
+ reason: response.headers.get(_camstack_types.CLIP_STILL_REASON_HEADER) ?? "no reason given",
19200
+ disposition: response.headers.get(_camstack_types.CLIP_STILL_DISPOSITION_HEADER) ?? void 0,
19201
+ retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null
19202
+ };
19203
+ }
19204
+ function deferredNetworkFailure(err) {
19205
+ return describeRefusal({
19206
+ code: void 0,
19207
+ reason: err instanceof Error ? err.message : String(err),
19208
+ disposition: "deferred"
19209
+ });
19210
+ }
19211
+ /** First wait, doubling from there. */
19212
+ var STILL_RETRY_BASE_MS = 4e3;
19213
+ /** No single wait is longer than this, whatever the server hints. */
19214
+ var STILL_RETRY_MAX_WAIT_MS = 3e4;
19215
+ /** ±25 %: enough to break a synchronised herd, not enough to stop being a
19216
+ * schedule. */
19217
+ var JITTER_FRACTION = .25;
19218
+ /** The next wait, or `null` when the budget is spent. */
19219
+ function planStillRetry(input) {
19220
+ if (input.attempt >= 4 || input.attempt < 0) return null;
19221
+ const curve = STILL_RETRY_BASE_MS * 2 ** input.attempt;
19222
+ const chosen = Math.max(curve, input.serverHintMs ?? 0);
19223
+ const bounded = Math.min(chosen, STILL_RETRY_MAX_WAIT_MS);
19224
+ const jitter = 1 + (input.random() * 2 - 1) * JITTER_FRACTION;
19225
+ return { waitMs: Math.round(bounded * jitter) };
19226
+ }
18683
19227
  //#endregion
18684
19228
  //#region src/composites/clips/ClipTile.tsx
18685
19229
  /**
@@ -18710,7 +19254,7 @@ function clockOf(ms) {
18710
19254
  });
18711
19255
  }
18712
19256
  /**
18713
- * Ask a mint route once, and LATCH whatever it says.
19257
+ * Ask a mint route, and come back only for the answers that say to.
18714
19258
  *
18715
19259
  * The offer is memoised on the three fields it is derived from, not rebuilt
18716
19260
  * per render: `resolveTileMedia` returns a fresh object every call, and an
@@ -18718,8 +19262,12 @@ function clockOf(ms) {
18718
19262
  * effect sets state, is an infinite render loop. It was one here, and it hung
18719
19263
  * the suite rather than failing it.
18720
19264
  *
18721
- * The refusal is latched for the life of the tile: a camera that just said
18722
- * "busy" is not asked again behind the operator's back (D549 decision 5).
19265
+ * A FINAL refusal is latched for the life of the tile: a camera that just said
19266
+ * "asleep" is not asked again behind the operator's back. A DEFERRED one is
19267
+ * not a refusal at all — the still is queued — so the tile keeps showing
19268
+ * loading and re-asks on `planStillRetry`'s bounded, jittered schedule. When
19269
+ * that budget runs out the deferred answer becomes what the tile SAYS, so the
19270
+ * end state is words, never a blank box and never another request (D563).
18723
19271
  */
18724
19272
  function useMintedStill(clip) {
18725
19273
  const offered = (0, react$1.useMemo)(() => resolveTileMedia(clip), [
@@ -18734,7 +19282,8 @@ function useMintedStill(clip) {
18734
19282
  if (mintUrl === null || typeof fetch !== "function") return void 0;
18735
19283
  let objectUrl = null;
18736
19284
  let cancelled = false;
18737
- (async () => {
19285
+ let timer;
19286
+ const ask = async (attempt) => {
18738
19287
  const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
18739
19288
  if (cancelled) return;
18740
19289
  if (answer.kind === "image") {
@@ -18745,10 +19294,31 @@ function useMintedStill(clip) {
18745
19294
  });
18746
19295
  return;
18747
19296
  }
18748
- setLatched(answer);
18749
- })();
19297
+ if (answer.kind !== "deferred") {
19298
+ setLatched(answer);
19299
+ return;
19300
+ }
19301
+ const plan = planStillRetry({
19302
+ attempt,
19303
+ serverHintMs: answer.retryAfterMs,
19304
+ random: Math.random
19305
+ });
19306
+ if (plan === null) {
19307
+ setLatched({
19308
+ kind: "unavailable",
19309
+ reason: answer.reason,
19310
+ text: answer.text
19311
+ });
19312
+ return;
19313
+ }
19314
+ timer = setTimeout(() => {
19315
+ ask(attempt + 1);
19316
+ }, plan.waitMs);
19317
+ };
19318
+ ask(0);
18750
19319
  return () => {
18751
19320
  cancelled = true;
19321
+ if (timer !== void 0) clearTimeout(timer);
18752
19322
  if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
18753
19323
  };
18754
19324
  }, [mintUrl]);
@@ -18772,16 +19342,27 @@ function ClipTile({ clip, selected, onSelect }) {
18772
19342
  src: media.url,
18773
19343
  alt: "",
18774
19344
  className: "h-full w-full object-cover"
18775
- }) : media.kind === "mint" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-full w-full rounded-none" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19345
+ }) : media.kind === "mint" || media.kind === "deferred" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19346
+ role: "status",
19347
+ "aria-label": media.kind === "mint" ? "Loading the still" : media.text,
19348
+ className: "h-full w-full",
19349
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-full w-full rounded-none" })
19350
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18776
19351
  className: "px-1 text-center leading-tight",
18777
19352
  children: media.text
18778
19353
  })
18779
19354
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18780
19355
  className: "min-w-0 flex-1 space-y-1",
18781
19356
  children: [
18782
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18783
- className: "text-sm font-medium text-foreground",
18784
- children: clockOf(clip.timeRange.startMs)
19357
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19358
+ className: "flex items-baseline gap-2",
19359
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19360
+ className: "text-sm font-medium text-foreground",
19361
+ children: clockOf(clip.timeRange.startMs)
19362
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19363
+ className: "text-[11px] text-foreground-subtle",
19364
+ children: clip.inProgress === true ? "Recording…" : formatDurationMs(clip.timeRange.endMs - clip.timeRange.startMs)
19365
+ })]
18785
19366
  }),
18786
19367
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18787
19368
  className: "text-[10px] text-foreground-subtle",
@@ -18808,6 +19389,29 @@ function ClipTile({ clip, selected, onSelect }) {
18808
19389
  }
18809
19390
  //#endregion
18810
19391
  //#region src/composites/clips/clip-day-window.ts
19392
+ /**
19393
+ * LOCAL calendar-day arithmetic for the clips browser.
19394
+ *
19395
+ * The browser no longer NAVIGATES days — it follows a band selected on the
19396
+ * recording timebar (D564) — but the local day survived the move as the unit
19397
+ * the COST is counted in: a Reolink search is clamped to one camera-local day
19398
+ * per call, so `clip-band.ts` counts a band's local days to decide whether it
19399
+ * may be asked for. The window builder this file used to own went with the day
19400
+ * picker; `recording-timeline-model.ts`'s `dayWindow(date, nowMs)` is the one
19401
+ * that builds windows now, and one directory holding two functions of that
19402
+ * name was the confusion worth deleting.
19403
+ *
19404
+ * Two properties are pinned by spec because both are invisible until they are
19405
+ * wrong:
19406
+ *
19407
+ * - Deriving the day in UTC shifts every band by the browser's offset, and an
19408
+ * operator in Berlin would see a 22:00 clip filed under the next day.
19409
+ * - Walking days by adding 86 400 000 ms repeats or skips a calendar day
19410
+ * across a DST change. The walk goes through `Date`'s own day arithmetic.
19411
+ *
19412
+ * A day id is `YYYY-MM-DD` — it orders lexicographically, which is what lets
19413
+ * the band's day walk terminate on a comparison rather than on a count.
19414
+ */
18811
19415
  function pad2(value) {
18812
19416
  return value < 10 ? `0${String(value)}` : String(value);
18813
19417
  }
@@ -18820,16 +19424,6 @@ function startOfDay(dayId) {
18820
19424
  function toDayId(instant) {
18821
19425
  return `${String(instant.getFullYear())}-${pad2(instant.getMonth() + 1)}-${pad2(instant.getDate())}`;
18822
19426
  }
18823
- /** The half-open local-wall-clock window of one day. */
18824
- function dayWindow$1(dayId) {
18825
- const start = startOfDay(dayId);
18826
- const end = startOfDay(dayId);
18827
- end.setDate(end.getDate() + 1);
18828
- return {
18829
- since: start.getTime(),
18830
- until: end.getTime()
18831
- };
18832
- }
18833
19427
  /** Walk `days` calendar days from `dayId` — DST-safe, because `Date` does it. */
18834
19428
  function shiftDay(dayId, days) {
18835
19429
  const moved = startOfDay(dayId);
@@ -18837,11 +19431,12 @@ function shiftDay(dayId, days) {
18837
19431
  return toDayId(moved);
18838
19432
  }
18839
19433
  /**
18840
- * The day, spelled out for the header.
19434
+ * The day, spelled out — the calendar half of a band's label.
18841
19435
  *
18842
- * An empty list has to say WHICH day it is empty for — "no clips" with no date
18843
- * beside it is the ambiguity this surface exists to remove — so this label is
18844
- * rendered next to every state, not only the populated one.
19436
+ * An empty list has to say WHICH period it is empty for; "no clips" with no
19437
+ * date beside it is the ambiguity this surface exists to remove. `formatBandLabel`
19438
+ * puts the times around it, and every state renders the result, not only the
19439
+ * populated one.
18845
19440
  */
18846
19441
  function formatDayLabel(dayId) {
18847
19442
  return startOfDay(dayId).toLocaleDateString(void 0, {
@@ -18851,6 +19446,77 @@ function formatDayLabel(dayId) {
18851
19446
  day: "numeric"
18852
19447
  });
18853
19448
  }
19449
+ /** Defensive ceiling on the day walk so an inverted band can never loop. */
19450
+ var MAX_WALK_DAYS = 366;
19451
+ /**
19452
+ * How many LOCAL calendar days the half-open band touches.
19453
+ *
19454
+ * The end instant is exclusive, so a band that ends exactly at the next local
19455
+ * midnight is one day, not two — otherwise every whole-day band would report
19456
+ * double what it costs.
19457
+ */
19458
+ function localDaysInBand(band) {
19459
+ const lastInstant = Math.max(band.fromMs, band.toMs - 1);
19460
+ const endDay = toDayId(new Date(lastInstant));
19461
+ let cursor = toDayId(new Date(band.fromMs));
19462
+ let days = 0;
19463
+ while (cursor <= endDay && days < MAX_WALK_DAYS) {
19464
+ days += 1;
19465
+ cursor = shiftDay(cursor, 1);
19466
+ }
19467
+ return days;
19468
+ }
19469
+ /** Whether this band may be asked for, and what it costs, in one value. */
19470
+ function planClipBand(band) {
19471
+ const localDays = localDaysInBand(band);
19472
+ if (localDays > 2) return {
19473
+ kind: "too-wide",
19474
+ band,
19475
+ localDays,
19476
+ maxLocalDays: 2
19477
+ };
19478
+ return {
19479
+ kind: "askable",
19480
+ band,
19481
+ localDays
19482
+ };
19483
+ }
19484
+ /**
19485
+ * The band for the current selection: the picked bucket, or — when nothing is
19486
+ * picked — the whole visible window.
19487
+ *
19488
+ * "Nothing picked means everything visible" is safe here and only here,
19489
+ * because the visible window is itself bounded by the timebar. It is never
19490
+ * "everything the camera holds" (D382's unstated ask).
19491
+ */
19492
+ function bandForSelection(view, bucket) {
19493
+ if (bucket === null) return {
19494
+ fromMs: view.fromMs,
19495
+ toMs: view.toMs
19496
+ };
19497
+ return {
19498
+ fromMs: Math.max(bucket.startMs, view.fromMs),
19499
+ toMs: Math.min(bucket.endMs, view.toMs)
19500
+ };
19501
+ }
19502
+ function formatTime$2(instant) {
19503
+ return new Date(instant).toLocaleTimeString(void 0, {
19504
+ hour: "2-digit",
19505
+ minute: "2-digit"
19506
+ });
19507
+ }
19508
+ /**
19509
+ * The band, spelled out.
19510
+ *
19511
+ * Every state renders it — an empty list has to say WHICH period it is empty
19512
+ * for, the same rule the day label carried before the band replaced it.
19513
+ */
19514
+ function formatBandLabel(band) {
19515
+ const fromDay = toDayId(new Date(band.fromMs));
19516
+ const toDay = toDayId(new Date(Math.max(band.fromMs, band.toMs - 1)));
19517
+ if (fromDay === toDay) return `${formatDayLabel(fromDay)}, ${formatTime$2(band.fromMs)} – ${formatTime$2(band.toMs)}`;
19518
+ return `${formatDayLabel(fromDay)} ${formatTime$2(band.fromMs)} – ${formatDayLabel(toDay)} ${formatTime$2(band.toMs)}`;
19519
+ }
18854
19520
  //#endregion
18855
19521
  //#region src/composites/clips/clip-source-state.ts
18856
19522
  var STATE_COPY = {
@@ -18913,6 +19579,12 @@ function formatCatalogAge(catalogAsOf, now) {
18913
19579
  //#endregion
18914
19580
  //#region src/composites/clips/clip-view-state.ts
18915
19581
  function resolveClipsView(input) {
19582
+ if (input.plan.kind === "too-wide") return {
19583
+ kind: "refused",
19584
+ band: input.plan.band,
19585
+ localDays: input.plan.localDays,
19586
+ maxLocalDays: input.plan.maxLocalDays
19587
+ };
18916
19588
  if (input.clipsStatus === "error") return {
18917
19589
  kind: "failed",
18918
19590
  message: input.clipsError ?? "The clip list could not be read"
@@ -18921,14 +19593,21 @@ function resolveClipsView(input) {
18921
19593
  kind: "failed",
18922
19594
  message: input.sourcesError ?? "The source list could not be read"
18923
19595
  };
18924
- if (input.clipsStatus === "pending" || input.sourcesStatus === "pending") return { kind: "loading" };
18925
19596
  const notices = input.sources.map(describeSource);
18926
19597
  const answered = notices.filter((notice) => notice.answered);
18927
19598
  const silent = notices.filter((notice) => !notice.answered);
18928
19599
  const clips = input.clips;
19600
+ if (input.provider === null && input.sourcesStatus === "success") return {
19601
+ kind: "empty",
19602
+ band: input.plan.band,
19603
+ answered,
19604
+ silent,
19605
+ nobodyAnswered: true
19606
+ };
19607
+ if (input.clipsStatus === "pending" || input.sourcesStatus === "pending") return { kind: "loading" };
18929
19608
  if (clips.length === 0) return {
18930
19609
  kind: "empty",
18931
- dayId: input.dayId,
19610
+ band: input.plan.band,
18932
19611
  answered,
18933
19612
  silent,
18934
19613
  nobodyAnswered: answered.length === 0
@@ -18979,6 +19658,28 @@ function resolveClipsView(input) {
18979
19658
  * camera whose own index disagrees with its own calendar (618, 6–20
18980
19659
  * September) is not quiet either.
18981
19660
  *
19661
+ * ## The period comes from the recording section's timebar, not a day picker
19662
+ *
19663
+ * It shipped with its own `‹ day ›` pair and the operator replaced it the same
19664
+ * way he replaced the chrome: *"Possiamo utilizzare lo stesso componente della
19665
+ * sezione recording con la timebar dove selezionare un periodo e mostrare le
19666
+ * clip solo di quella banda selezionata, espandibile."* So the list follows a
19667
+ * BAND (D564) — `TimelineControls` for the day, the zoom and the pan,
19668
+ * `EventHeatmap` for picking a narrower window out of it, both of them the
19669
+ * pieces `RecordingTimeline` is itself built from, over the same
19670
+ * `recording-timeline-model`. Three things that are decisions, not details:
19671
+ *
19672
+ * - **The band lives HERE, once.** `ClipBandBar` is presentational. Two
19673
+ * components holding two ideas of the selected period is the defect this
19674
+ * repo keeps paying for, and the period is what the query is made of.
19675
+ * - **`CoverageTrack` stayed behind**, because there it is the seek surface
19676
+ * that moves the hero player's playhead. A copy here would be a dead bar or
19677
+ * a second claimant on that position.
19678
+ * - **A band is asked for only if it is affordable.** `clip-band.ts` counts
19679
+ * the LOCAL days it touches — the camera's own search is clamped to one per
19680
+ * call, and a day listing is 4.3–6.0 s on 592 — and refuses past the bound
19681
+ * by name rather than asking and rendering whichever days answered.
19682
+ *
18982
19683
  * ## The picker WRITES nothing, and there is nothing for it to write
18983
19684
  *
18984
19685
  * A clip source is not enabled or disabled — it EXISTS, because of a
@@ -18995,23 +19696,34 @@ function resolveClipsView(input) {
18995
19696
  * ## Why the picker is a SELECT and not a multiselect
18996
19697
  *
18997
19698
  * The operator asked for *"un multiselect common"* while `listClips` still
18998
- * took `sources?: string[]`. It now takes ONE `provider` — *"il get videoclips
18999
- * prendere un provider opzionale, di default va su camstack … l'utilizzatore è
19000
- * uno solo"* — and a multi-pick control over a one-at-a-time argument offers a
19001
- * sum the hub cannot answer. So the control is the shared single-choice one,
19002
- * `Select`: the same component the Recordings page's own camera picker uses,
19003
- * which is what *"segue la nostra UI"* means here. Absent means the camera's
19004
- * BOUND provider, and the option that carries it is labelled with that
19005
- * provider's own sources rather than the word "default".
19699
+ * took `sources?: string[]`. It now takes ONE `provider` —
19700
+ * *"l'utilizzatore è uno solo"* — and a multi-pick control over a
19701
+ * one-at-a-time argument offers a sum the hub cannot answer. So the control is
19702
+ * the shared single-choice one, `Select`: the same component the Recordings
19703
+ * page's own camera picker uses, which is what *"segue la nostra UI"* means
19704
+ * here.
19705
+ *
19706
+ * ## The control's value is ALWAYS what the query carries
19707
+ *
19708
+ * There is no "default" option and no empty value. This section used to send
19709
+ * no `provider` at all until the operator chose, because the cap documented
19710
+ * absence as "the device's bound provider, which is CamStack on every camera".
19711
+ * No code implemented that: measured on device 592 (2026-09-20, bound to
19712
+ * `recorder` AND `provider-reolink`) a bare `listClips` with `limit: 3`
19713
+ * answered SIX rows — three per source, merged — while the default option read
19714
+ * "CamStack recordings". The label named one thing and the query asked two.
19715
+ *
19716
+ * Naming a provider is now required (D554 amended), and the default is this surface's
19717
+ * to make: the FIRST source the camera lists, shown selected in the control, so
19718
+ * what is on screen and what was asked are the same fact. A camera that lists
19719
+ * no source is asked NOTHING — the query never fires and the body says nobody
19720
+ * answered, rather than spinning against a question with no addressee.
19006
19721
  *
19007
19722
  * ## One provider is a fleet STATE
19008
19723
  *
19009
- * `listSources` derives from `deviceManager.getBindings`, and today nothing
19010
- * binds `provider-reolink` for `videoclips`, so a real camera answers with one
19011
- * provider. The selector is then inert and SAYS why. An empty or silently
19012
- * single-entry control would read as a defect; the binding store learning to
19013
- * hold several providers for a collection cap is what changes this, and
19014
- * nothing here needs to change with it.
19724
+ * `listSources` derives from `deviceManager.getBindings`. A camera bound to one
19725
+ * provider makes the selector inert, and it SAYS why: an empty or silently
19726
+ * single-entry control would read as a defect.
19015
19727
  */
19016
19728
  /** Newest-first, and bounded: a busy day on 592 lists 546 clips. */
19017
19729
  var CLIP_PAGE_LIMIT = 500;
@@ -19098,24 +19810,54 @@ function SilentSources({ silent }) {
19098
19810
  });
19099
19811
  }
19100
19812
  function ClipsSection({ deviceId }) {
19101
- const [dayId, setDayId] = (0, react$1.useState)(() => toDayId(/* @__PURE__ */ new Date()));
19813
+ const [day, setDay] = (0, react$1.useState)(() => /* @__PURE__ */ new Date());
19814
+ const [view, setView] = (0, react$1.useState)(() => dayWindow(/* @__PURE__ */ new Date(), Date.now()));
19815
+ const [selectedBucket, setSelectedBucket] = (0, react$1.useState)(null);
19102
19816
  const [provider, setProvider] = (0, react$1.useState)("");
19103
19817
  const [page, setPage] = (0, react$1.useState)(1);
19104
19818
  const [selectedClip, setSelectedClip] = (0, react$1.useState)(null);
19105
19819
  const [profile, setProfile] = (0, react$1.useState)("low");
19106
19820
  const presence = useCapabilityPresence("videoclips");
19107
- const window = (0, react$1.useMemo)(() => dayWindow$1(dayId), [dayId]);
19821
+ const dayBounds = (0, react$1.useMemo)(() => dayWindow(day, Date.now()), [day]);
19822
+ const bucketMs = bucketMsForSpan(view.toMs - view.fromMs);
19823
+ const density = usePipelineAnalyticsGetEventDensity({
19824
+ deviceId,
19825
+ since: view.fromMs,
19826
+ until: view.toMs,
19827
+ bucketMs
19828
+ });
19829
+ const buckets = (0, react$1.useMemo)(() => densityRowsToBuckets(density.data ?? [], view.fromMs, bucketMs), [
19830
+ density.data,
19831
+ view.fromMs,
19832
+ bucketMs
19833
+ ]);
19834
+ const selected = selectedBucket === null ? void 0 : buckets.find((b) => b.index === selectedBucket);
19835
+ const band = (0, react$1.useMemo)(() => bandForSelection(view, selected === void 0 ? null : {
19836
+ startMs: selected.startMs,
19837
+ endMs: selected.endMs
19838
+ }), [view, selected]);
19839
+ const plan = planClipBand(band);
19108
19840
  const sources = useVideoclipsListSources({ deviceId });
19841
+ const sourceRows = sources.data ?? [];
19842
+ /**
19843
+ * The provider the list is ASKED of: the operator's pick, else the first
19844
+ * source the camera lists. Empty only while the camera has no source at all
19845
+ * — and then nothing is asked, because there is nobody to ask.
19846
+ */
19847
+ const askedProvider = (0, react$1.useMemo)(() => {
19848
+ if (provider !== "") return provider;
19849
+ return sourceRows.find((row) => row.addonId !== void 0)?.addonId ?? "";
19850
+ }, [provider, sourceRows]);
19109
19851
  const clips = useVideoclipsListClips({
19110
19852
  deviceId,
19111
- since: window.since,
19112
- until: window.until,
19853
+ since: band.fromMs,
19854
+ until: band.toMs,
19113
19855
  limit: CLIP_PAGE_LIMIT,
19114
- ...provider === "" ? {} : { provider }
19115
- });
19116
- const sourceRows = sources.data ?? [];
19117
- const view = resolveClipsView({
19118
- dayId,
19856
+ provider: askedProvider
19857
+ }, { enabled: plan.kind === "askable" && askedProvider !== "" });
19858
+ const clipsView = resolveClipsView({
19859
+ plan,
19860
+ provider: askedProvider === "" ? null : askedProvider,
19119
19861
  clipsStatus: clips.status,
19120
19862
  clips: clips.data ?? [],
19121
19863
  sourcesStatus: sources.status,
@@ -19133,23 +19875,29 @@ function ClipsSection({ deviceId }) {
19133
19875
  if (labels === void 0) byAddon.set(addonId, [row.label]);
19134
19876
  else labels.push(row.label);
19135
19877
  }
19136
- const rest = [...byAddon].map(([value, labels]) => ({
19878
+ return [...byAddon].map(([value, labels]) => ({
19137
19879
  value,
19138
19880
  label: labels.join(" · ")
19139
19881
  }));
19140
- const first = rest[0];
19141
- return [{
19142
- value: "",
19143
- label: first === void 0 ? "The camera’s bound provider" : first.label
19144
- }, ...rest.slice(1)];
19145
19882
  }, [sourceRows]);
19146
19883
  const tableRows = (0, react$1.useMemo)(() => sourceRows.map((row) => ({
19147
19884
  notice: describeSource(row),
19148
19885
  catalogAge: formatCatalogAge(row.availability.catalogAsOf, now)
19149
19886
  })), [sourceRows]);
19150
- const goToDay = (next) => {
19151
- setDayId(next);
19887
+ (0, react$1.useEffect)(() => {
19152
19888
  setPage(1);
19889
+ }, [band.fromMs, band.toMs]);
19890
+ const handleDayChange = (next) => {
19891
+ setDay(next);
19892
+ setView(dayWindow(next, Date.now()));
19893
+ setSelectedBucket(null);
19894
+ };
19895
+ const handleViewChange = (next) => {
19896
+ setView(next);
19897
+ setSelectedBucket(null);
19898
+ };
19899
+ const handleSelectBucket = (index) => {
19900
+ setSelectedBucket((current) => current === index ? null : index);
19153
19901
  };
19154
19902
  const pick = (next) => {
19155
19903
  setProvider(next);
@@ -19176,18 +19924,25 @@ function ClipsSection({ deviceId }) {
19176
19924
  description: "Nothing is installed that could list a camera's stored clips."
19177
19925
  })
19178
19926
  });
19179
- const visible = view.kind === "listed" ? view.clips : [];
19927
+ const visible = clipsView.kind === "listed" ? clipsView.clips : [];
19180
19928
  const pageClips = visible.slice((page - 1) * TILES_PER_PAGE, page * TILES_PER_PAGE);
19181
19929
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19182
19930
  className: "space-y-3",
19183
19931
  children: [
19184
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
19185
- className: "text-sm font-semibold text-foreground",
19186
- children: "Clips"
19187
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19188
- className: "mt-1 text-[11px] text-foreground-subtle",
19189
- children: "Every source this camera has, side by side — CamStack's own recordings and whatever the camera or an export keeps of its own. A source is listed because it exists, not because it was switched on here; pick sources to narrow the view."
19190
- })] }),
19932
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
19933
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
19934
+ className: "text-sm font-semibold text-foreground",
19935
+ children: "Clips"
19936
+ }),
19937
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19938
+ className: "mt-1 text-[11px] text-foreground-subtle",
19939
+ children: "Every source this camera has, side by side — CamStack's own recordings and whatever the camera or an export keeps of its own. A source is listed because it exists, not because it was switched on here; pick sources to narrow the view."
19940
+ }),
19941
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19942
+ className: "mt-1 text-[11px] text-foreground-subtle",
19943
+ children: "The list follows the period selected on the timebar below — the recording section's own control. Zoom out to widen it, pan or pick a day to move it, click a density bar to narrow it to that window."
19944
+ })
19945
+ ] }),
19191
19946
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19192
19947
  className: "flex flex-wrap items-center gap-2",
19193
19948
  children: [
@@ -19201,7 +19956,7 @@ function ClipsSection({ deviceId }) {
19201
19956
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Select, {
19202
19957
  id: "clips-provider",
19203
19958
  options,
19204
- value: provider,
19959
+ value: askedProvider,
19205
19960
  disabled: options.length < 2,
19206
19961
  onChange: (e) => pick(e.target.value)
19207
19962
  })
@@ -19212,32 +19967,28 @@ function ClipsSection({ deviceId }) {
19212
19967
  }) : null,
19213
19968
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19214
19969
  className: "ml-auto flex items-center gap-1",
19215
- children: [
19216
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19217
- variant: "ghost",
19218
- "aria-label": "Previous day",
19219
- onClick: () => goToDay(shiftDay(dayId, -1)),
19220
- children: "‹"
19221
- }),
19222
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19223
- className: "px-1 text-xs font-medium text-foreground",
19224
- children: formatDayLabel(dayId)
19225
- }),
19226
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19227
- variant: "ghost",
19228
- "aria-label": "Next day",
19229
- onClick: () => goToDay(shiftDay(dayId, 1)),
19230
- children: "›"
19231
- }),
19232
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19233
- variant: "outline",
19234
- onClick: () => goToDay(toDayId(/* @__PURE__ */ new Date())),
19235
- children: "Today"
19236
- })
19237
- ]
19970
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19971
+ className: "px-1 text-xs font-medium text-foreground",
19972
+ children: formatBandLabel(band)
19973
+ }), selectedBucket === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19974
+ variant: "outline",
19975
+ onClick: () => setSelectedBucket(null),
19976
+ children: "Expand to the visible window"
19977
+ })]
19238
19978
  })
19239
19979
  ]
19240
19980
  }),
19981
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipBandBar, {
19982
+ day,
19983
+ onDayChange: handleDayChange,
19984
+ view,
19985
+ dayBounds,
19986
+ onViewChange: handleViewChange,
19987
+ buckets,
19988
+ selectedBucketIndex: selectedBucket,
19989
+ onSelectBucket: handleSelectBucket,
19990
+ densityLoading: density.status === "pending"
19991
+ }),
19241
19992
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
19242
19993
  "aria-label": "Clip sources",
19243
19994
  className: "space-y-1",
@@ -19254,26 +20005,30 @@ function ClipsSection({ deviceId }) {
19254
20005
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
19255
20006
  "aria-label": "Clips",
19256
20007
  className: "space-y-2",
19257
- children: view.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
20008
+ children: clipsView.kind === "refused" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
20009
+ variant: "warning",
20010
+ title: "That period is too wide to list",
20011
+ message: `${formatBandLabel(clipsView.band)} covers ${String(clipsView.localDays)} local days, and a clip listing is asked one local day at a time — at most ${String(clipsView.maxLocalDays)}. Nothing was asked for; narrow the band and the list follows it.`
20012
+ }) : clipsView.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19258
20013
  role: "status",
19259
20014
  className: "text-xs text-foreground-subtle",
19260
20015
  children: [
19261
20016
  "Loading clips for ",
19262
- formatDayLabel(dayId),
20017
+ formatBandLabel(band),
19263
20018
  "…"
19264
20019
  ]
19265
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })] }) : view.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
20020
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })] }) : clipsView.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19266
20021
  title: "The clip list could not be read",
19267
- message: view.message
19268
- }) : view.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
20022
+ message: clipsView.message
20023
+ }) : clipsView.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19269
20024
  role: "status",
19270
20025
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
19271
20026
  icon: Film,
19272
- title: view.nobodyAnswered ? `No source could answer for ${formatDayLabel(dayId)}` : `No clips on ${formatDayLabel(dayId)}`,
19273
- ...view.nobodyAnswered ? {} : { description: `Asked ${view.answered.map((notice) => notice.label).join(", ")}.` }
20027
+ title: clipsView.nobodyAnswered ? `No source could answer for ${formatBandLabel(clipsView.band)}` : `No clips in ${formatBandLabel(clipsView.band)}`,
20028
+ ...clipsView.nobodyAnswered ? {} : { description: `Asked ${clipsView.answered.map((notice) => notice.label).join(", ")}.` }
19274
20029
  })
19275
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: view.silent })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
19276
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: view.silent }),
20030
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: clipsView.silent })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
20031
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: clipsView.silent }),
19277
20032
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
19278
20033
  className: "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
19279
20034
  children: pageClips.map((clip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipTile, {
@@ -21469,201 +22224,6 @@ function formatBytes(bytes) {
21469
22224
  return `${value >= 100 ? Math.round(value).toString() : value.toFixed(1)} ${unit}`;
21470
22225
  }
21471
22226
  //#endregion
21472
- //#region src/composites/cap-settings/recording-timeline-model.ts
21473
- /** Tie-break precedence for a bucket's dominant kind (first wins on equal count). */
21474
- var KIND_PRECEDENCE = [
21475
- "object",
21476
- "motion",
21477
- "audio"
21478
- ];
21479
- /**
21480
- * Convert server-side density rows into `DensityBucket[]` for the timeline
21481
- * heatmap. Rows with total === 0 are skipped (defensive; server already omits
21482
- * them). Returns a sparse, ascending-by-index array — EventHeatmap positions
21483
- * each bar by `b.startMs` rather than array index, so gaps are fine.
21484
- */
21485
- function densityRowsToBuckets(rows, windowFrom, bucketMs) {
21486
- const buckets = [];
21487
- for (const row of rows) {
21488
- const counts = {
21489
- motion: row.motion,
21490
- object: row.object,
21491
- audio: row.audio
21492
- };
21493
- const total = counts.motion + counts.object + counts.audio;
21494
- if (total === 0) continue;
21495
- const index = Math.floor((row.bucketStart - windowFrom) / bucketMs);
21496
- let dominant = null;
21497
- let best = -1;
21498
- for (const kind of KIND_PRECEDENCE) if (counts[kind] > best) {
21499
- best = counts[kind];
21500
- dominant = kind;
21501
- }
21502
- buckets.push({
21503
- index,
21504
- startMs: row.bucketStart,
21505
- endMs: row.bucketStart + bucketMs,
21506
- counts,
21507
- total,
21508
- dominant
21509
- });
21510
- }
21511
- return buckets.toSorted((a, b) => a.index - b.index);
21512
- }
21513
- /** Distance from `ms` to the interval `[startMs, endMs]` (0 if inside). */
21514
- function distanceToSpan(ms, span) {
21515
- if (ms < span.startMs) return span.startMs - ms;
21516
- if (ms > span.endMs) return ms - span.endMs;
21517
- return 0;
21518
- }
21519
- /**
21520
- * Resolve a playback window for a click at `clickMs` against recorded `spans`.
21521
- * Inside a span → play from the click to that span's end. In a gap / outside →
21522
- * snap to the WHOLE nearest span. No spans → null (nothing to play).
21523
- * `spans` are expected ascending by start (as `mergeAvailabilitySpans` returns).
21524
- */
21525
- function seekTargetForSpans(clickMs, spans) {
21526
- if (spans.length === 0) return null;
21527
- const containing = spans.find((s) => clickMs >= s.startMs && clickMs <= s.endMs);
21528
- if (containing) return {
21529
- fromMs: clickMs,
21530
- toMs: containing.endMs
21531
- };
21532
- let nearest = null;
21533
- let bestDist = Infinity;
21534
- for (const s of spans) {
21535
- const d = distanceToSpan(clickMs, s);
21536
- if (d < bestDist) {
21537
- bestDist = d;
21538
- nearest = s;
21539
- }
21540
- }
21541
- return nearest ? {
21542
- fromMs: nearest.startMs,
21543
- toMs: nearest.endMs
21544
- } : null;
21545
- }
21546
- /**
21547
- * Position `ms` inside the window `[fromMs, toMs]` as a clamped percentage
21548
- * (0–100). A zero/negative-width window yields 0. Callers use this for `left:`.
21549
- */
21550
- function pctForMs(ms, fromMs, toMs) {
21551
- const span = toMs - fromMs;
21552
- if (span <= 0) return 0;
21553
- const pct = (ms - fromMs) / span * 100;
21554
- if (pct < 0) return 0;
21555
- if (pct > 100) return 100;
21556
- return pct;
21557
- }
21558
- /**
21559
- * Build a DayWindow for the given calendar `date` using local time.
21560
- * For any day strictly in the past: `fromMs` = local midnight, `toMs` =
21561
- * next local midnight (exactly 24 h).
21562
- * For today (or any future day that contains `nowMs`): `toMs = nowMs` so the
21563
- * window never extends into the future.
21564
- */
21565
- function dayWindow(date, nowMs) {
21566
- const fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime();
21567
- const nextMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
21568
- return {
21569
- fromMs,
21570
- toMs: Math.max(fromMs, Math.min(nextMidnight, nowMs))
21571
- };
21572
- }
21573
- /** Available zoom levels in ms, from widest (24h) to narrowest (15 min). */
21574
- var ZOOM_LEVELS = [
21575
- 24 * 36e5,
21576
- 12 * 36e5,
21577
- 6 * 36e5,
21578
- 36e5,
21579
- 15 * 6e4
21580
- ];
21581
- /**
21582
- * Produce a `levelMs`-wide window whose RIGHT edge stays anchored at
21583
- * `rightEdgeMs` (clamped within `dayBounds`), rather than centring on the view
21584
- * midpoint. On today's timeline the view's right edge is "now", so zooming this
21585
- * way keeps the most recent footage pinned to the right instead of drifting to
21586
- * a midday range that no longer contains now. Panning then moves back/forward.
21587
- *
21588
- * Falls back to `dayBounds` when `levelMs` meets or exceeds the day span (can't
21589
- * show a wider window than the day holds — e.g. early morning with little
21590
- * elapsed time).
21591
- */
21592
- function zoomWindowAnchoredRight(dayBounds, levelMs, rightEdgeMs) {
21593
- if (levelMs >= dayBounds.toMs - dayBounds.fromMs) return dayBounds;
21594
- const toMs = Math.min(Math.max(rightEdgeMs, dayBounds.fromMs + levelMs), dayBounds.toMs);
21595
- return {
21596
- fromMs: toMs - levelMs,
21597
- toMs
21598
- };
21599
- }
21600
- /**
21601
- * Shift `win` by `deltaMs`, preserving width, clamped within `dayBounds`.
21602
- */
21603
- function panWindow(win, dayBounds, deltaMs) {
21604
- const width = win.toMs - win.fromMs;
21605
- let fromMs = win.fromMs + deltaMs;
21606
- let toMs = win.toMs + deltaMs;
21607
- if (fromMs < dayBounds.fromMs) {
21608
- fromMs = dayBounds.fromMs;
21609
- toMs = fromMs + width;
21610
- }
21611
- if (toMs > dayBounds.toMs) {
21612
- toMs = dayBounds.toMs;
21613
- fromMs = toMs - width;
21614
- }
21615
- return {
21616
- fromMs,
21617
- toMs
21618
- };
21619
- }
21620
- /** Candidate bucket sizes in ms (coarsest first). */
21621
- var BUCKET_CANDIDATES = [
21622
- 15 * 6e4,
21623
- 5 * 6e4,
21624
- 6e4,
21625
- 3e4,
21626
- 15e3,
21627
- 5e3
21628
- ];
21629
- /**
21630
- * Choose a sensible heatmap bucket size for a visible span of `spanMs`,
21631
- * targeting ~96-120 buckets. Always picks from `BUCKET_CANDIDATES`.
21632
- */
21633
- function bucketMsForSpan(spanMs) {
21634
- const targetBucket = spanMs / 100;
21635
- for (const candidate of BUCKET_CANDIDATES) if (candidate <= targetBucket) return candidate;
21636
- return BUCKET_CANDIDATES[BUCKET_CANDIDATES.length - 1] ?? 5e3;
21637
- }
21638
- /**
21639
- * Produce `count` evenly-spaced axis ticks spanning [fromMs, toMs].
21640
- * - `pct` is via pctForMs (first=0, last=100).
21641
- * - `label` is a localised HH:MM clock string.
21642
- */
21643
- function axisTicks(fromMs, toMs, count) {
21644
- if (count <= 1) return [{
21645
- pct: 0,
21646
- label: new Date(fromMs).toLocaleTimeString(void 0, {
21647
- hour: "2-digit",
21648
- minute: "2-digit"
21649
- })
21650
- }];
21651
- const ticks = [];
21652
- for (let i = 0; i < count; i++) {
21653
- const ms = fromMs + i / (count - 1) * (toMs - fromMs);
21654
- const pct = pctForMs(ms, fromMs, toMs);
21655
- const label = new Date(ms).toLocaleTimeString(void 0, {
21656
- hour: "2-digit",
21657
- minute: "2-digit"
21658
- });
21659
- ticks.push({
21660
- pct,
21661
- label
21662
- });
21663
- }
21664
- return ticks;
21665
- }
21666
- //#endregion
21667
22227
  //#region src/composites/cap-settings/CoverageTrack.tsx
21668
22228
  function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScrubStart, onScrubMove, onScrubEnd }) {
21669
22229
  const [hoverInfo, setHoverInfo] = (0, react$1.useState)(null);
@@ -21774,42 +22334,6 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScru
21774
22334
  });
21775
22335
  }
21776
22336
  //#endregion
21777
- //#region src/composites/cap-settings/EventHeatmap.tsx
21778
- var KIND_BG = {
21779
- motion: "bg-emerald-500",
21780
- object: "bg-violet-500",
21781
- audio: "bg-orange-500"
21782
- };
21783
- function EventHeatmap({ windowFrom, windowTo, buckets, selectedIndex, onSelectBucket }) {
21784
- const peak = buckets.reduce((m, b) => Math.max(m, b.total), 0);
21785
- const firstBucket = buckets[0];
21786
- const bucketWidthPct = firstBucket ? pctForMs(firstBucket.endMs, windowFrom, windowTo) - pctForMs(firstBucket.startMs, windowFrom, windowTo) : 0;
21787
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
21788
- className: "relative h-6 rounded bg-background/40",
21789
- children: buckets.map((b) => {
21790
- if (b.total === 0 || b.dominant === null) return null;
21791
- const heightPct = peak > 0 ? Math.max(15, b.total / peak * 100) : 0;
21792
- const left = pctForMs(b.startMs, windowFrom, windowTo);
21793
- const selected = b.index === selectedIndex;
21794
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21795
- type: "button",
21796
- "data-testid": "heatmap-bar",
21797
- title: `${new Date(b.startMs).toLocaleTimeString(void 0, {
21798
- hour: "2-digit",
21799
- minute: "2-digit"
21800
- })} · motion ${b.counts.motion} · object ${b.counts.object}`,
21801
- onClick: () => onSelectBucket(b.index),
21802
- className: `absolute bottom-0 rounded-t-sm transition-opacity ${KIND_BG[b.dominant]} ${selected ? "opacity-100 ring-1 ring-amber-400" : "opacity-80 hover:opacity-100"}`,
21803
- style: {
21804
- left: `${left}%`,
21805
- width: `${Math.max(.6, bucketWidthPct)}%`,
21806
- height: `${heightPct}%`
21807
- }
21808
- }, b.index);
21809
- })
21810
- });
21811
- }
21812
- //#endregion
21813
22337
  //#region src/composites/cap-settings/event-thumb.tsx
21814
22338
  function EventThumb({ mediaUrl, kind }) {
21815
22339
  const [failed, setFailed] = (0, react$1.useState)(false);
@@ -21934,142 +22458,6 @@ function EventBucketStrip({ clips, loading, busyKey, onPlay, enabledKinds, onTog
21934
22458
  });
21935
22459
  }
21936
22460
  //#endregion
21937
- //#region src/composites/cap-settings/TimelineControls.tsx
21938
- /** Format a Date as "YYYY-MM-DD" for <input type="date">. */
21939
- function formatDateInput(date) {
21940
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
21941
- }
21942
- /** Parse "YYYY-MM-DD" from <input type="date"> into a local-midnight Date. */
21943
- function parseDateInput(value) {
21944
- const parts = value.split("-");
21945
- const y = parts[0] !== void 0 ? parseInt(parts[0], 10) : NaN;
21946
- const m = parts[1] !== void 0 ? parseInt(parts[1], 10) : NaN;
21947
- const d = parts[2] !== void 0 ? parseInt(parts[2], 10) : NaN;
21948
- if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
21949
- return new Date(y, m - 1, d);
21950
- }
21951
- /**
21952
- * Date selector + prev/next pan + zoom-in / zoom-out buttons for the recording
21953
- * timeline. Zoom cycles through ZOOM_LEVELS (widest → narrowest), anchored on
21954
- * the view's RIGHT edge — so on today's timeline "now" stays pinned to the right
21955
- * instead of drifting to a midday range. The ◀ / ▶ buttons step the visible
21956
- * window earlier/later within the day (the discoverable equivalent of the
21957
- * horizontal wheel-pan wired in RecordingTimeline), so a zoomed-in day can be
21958
- * navigated back and forth without a mouse wheel.
21959
- */
21960
- function TimelineControls({ day, onDayChange, view, dayBounds, onViewChange }) {
21961
- const handleDateChange = (e) => {
21962
- const parsed = parseDateInput(e.target.value);
21963
- if (parsed !== null) onDayChange(parsed);
21964
- };
21965
- const viewSpan = view.toMs - view.fromMs;
21966
- const currentLevelIdx = (() => {
21967
- let best = 0;
21968
- let bestDiff = Infinity;
21969
- for (let i = 0; i < ZOOM_LEVELS.length; i++) {
21970
- const level = ZOOM_LEVELS[i];
21971
- if (level === void 0) continue;
21972
- const diff = Math.abs(level - viewSpan);
21973
- if (diff < bestDiff) {
21974
- bestDiff = diff;
21975
- best = i;
21976
- }
21977
- }
21978
- return best;
21979
- })();
21980
- const rightEdgeMs = view.toMs;
21981
- const handleZoomIn = () => {
21982
- const nextLevel = ZOOM_LEVELS[Math.min(currentLevelIdx + 1, ZOOM_LEVELS.length - 1)];
21983
- if (nextLevel === void 0) return;
21984
- onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
21985
- };
21986
- const handleZoomOut = () => {
21987
- const nextLevel = ZOOM_LEVELS[Math.max(currentLevelIdx - 1, 0)];
21988
- if (nextLevel === void 0) return;
21989
- onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
21990
- };
21991
- const canZoomIn = currentLevelIdx < ZOOM_LEVELS.length - 1;
21992
- const canZoomOut = currentLevelIdx > 0;
21993
- const panStepMs = Math.max(1, Math.round(viewSpan * .9));
21994
- const canPanPrev = view.fromMs > dayBounds.fromMs;
21995
- const canPanNext = view.toMs < dayBounds.toMs;
21996
- const handlePanPrev = () => {
21997
- onViewChange(panWindow(view, dayBounds, -panStepMs));
21998
- };
21999
- const handlePanNext = () => {
22000
- onViewChange(panWindow(view, dayBounds, panStepMs));
22001
- };
22002
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
22003
- className: "flex items-center gap-2",
22004
- children: [
22005
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
22006
- type: "date",
22007
- value: formatDateInput(day),
22008
- max: formatDateInput(/* @__PURE__ */ new Date()),
22009
- onChange: handleDateChange,
22010
- className: "rounded border border-border bg-background px-2 py-0.5 text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
22011
- "aria-label": "Select recording day"
22012
- }),
22013
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
22014
- className: "ml-auto flex items-center gap-1",
22015
- children: [
22016
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
22017
- type: "button",
22018
- onClick: handlePanPrev,
22019
- disabled: !canPanPrev,
22020
- title: "Earlier (pan back)",
22021
- "aria-label": "Pan earlier",
22022
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
22023
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { size: 14 })
22024
- }),
22025
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
22026
- type: "button",
22027
- onClick: handlePanNext,
22028
- disabled: !canPanNext,
22029
- title: "Later (pan forward)",
22030
- "aria-label": "Pan later",
22031
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
22032
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { size: 14 })
22033
- }),
22034
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
22035
- className: "mx-0.5 h-3 w-px bg-border",
22036
- "aria-hidden": "true"
22037
- }),
22038
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
22039
- type: "button",
22040
- onClick: handleZoomOut,
22041
- disabled: !canZoomOut,
22042
- title: "Zoom out",
22043
- "aria-label": "Zoom out",
22044
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
22045
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { size: 13 })
22046
- }),
22047
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
22048
- type: "button",
22049
- onClick: handleZoomIn,
22050
- disabled: !canZoomIn,
22051
- title: "Zoom in",
22052
- "aria-label": "Zoom in",
22053
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
22054
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { size: 13 })
22055
- })
22056
- ]
22057
- }),
22058
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
22059
- className: "text-[9px] text-foreground-subtle",
22060
- children: zoomLabel(viewSpan)
22061
- })
22062
- ]
22063
- });
22064
- }
22065
- function zoomLabel(spanMs) {
22066
- const h = spanMs / 36e5;
22067
- if (h >= 24) return "24h";
22068
- if (h >= 1) return `${Math.round(h)}h`;
22069
- const m = spanMs / 6e4;
22070
- return `${Math.round(m)}m`;
22071
- }
22072
- //#endregion
22073
22461
  //#region src/composites/cap-settings/RecordingTimeline.tsx
22074
22462
  var ALL_KINDS = [
22075
22463
  "motion",
@@ -22465,15 +22853,6 @@ function RecordingTimeline({ deviceId }) {
22465
22853
  //#endregion
22466
22854
  //#region src/composites/cap-settings/RecordingPanel.tsx
22467
22855
  var DAY_MS$1 = 1440 * 60 * 1e3;
22468
- function formatDuration$1(ms) {
22469
- const totalSec = Math.round(ms / 1e3);
22470
- const h = Math.floor(totalSec / 3600);
22471
- const m = Math.floor(totalSec % 3600 / 60);
22472
- const s = totalSec % 60;
22473
- if (h > 0) return `${h}h ${m}m`;
22474
- if (m > 0) return `${m}m ${s}s`;
22475
- return `${s}s`;
22476
- }
22477
22856
  function RecordingPanel({ deviceId }) {
22478
22857
  const queryClient = (0, _tanstack_react_query.useQueryClient)();
22479
22858
  const statusQuery = useRecordingGetStatus({ deviceId });
@@ -22573,7 +22952,7 @@ function RecordingPanel({ deviceId }) {
22573
22952
  label: "Recorded (24h)",
22574
22953
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Clock, { className: "h-3.5 w-3.5" }),
22575
22954
  footer: `${ranges.length} segment range${ranges.length === 1 ? "" : "s"}`,
22576
- children: availabilityQuery.isLoading ? "…" : formatDuration$1(recordedMs)
22955
+ children: availabilityQuery.isLoading ? "…" : formatDurationMs(recordedMs)
22577
22956
  })
22578
22957
  ]
22579
22958
  }),
@@ -54450,6 +54829,7 @@ exports.usePipelineAnalyticsListRetrainStaging = usePipelineAnalyticsListRetrain
54450
54829
  exports.usePipelineAnalyticsListSummaries = usePipelineAnalyticsListSummaries;
54451
54830
  exports.usePipelineAnalyticsListTrackMedia = usePipelineAnalyticsListTrackMedia;
54452
54831
  exports.usePipelineAnalyticsListTracks = usePipelineAnalyticsListTracks;
54832
+ exports.usePipelineAnalyticsOwnersWithMedia = usePipelineAnalyticsOwnersWithMedia;
54453
54833
  exports.usePipelineAnalyticsPauseForStorageMigration = usePipelineAnalyticsPauseForStorageMigration;
54454
54834
  exports.usePipelineAnalyticsProposeRetrainAnnotations = usePipelineAnalyticsProposeRetrainAnnotations;
54455
54835
  exports.usePipelineAnalyticsPruneEvents = usePipelineAnalyticsPruneEvents;