@camstack/addon-pipeline 1.2.275 → 1.2.279

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.
@@ -13763,6 +13763,35 @@ var PlacementService = class {
13763
13763
  });
13764
13764
  }
13765
13765
  };
13766
+ /** How long each kind stays quiet after it has been listed. Absent = no bound. */
13767
+ var QUIET_BY_KIND = {
13768
+ audio: 5 * 6e4,
13769
+ motion: 2 * 6e4
13770
+ };
13771
+ /**
13772
+ * Thin a run of same-kind rows to one per cooldown.
13773
+ *
13774
+ * The DECISION is made oldest-first, because a cooldown read off an unsorted
13775
+ * list keeps an arbitrary subset. The ANSWER keeps the caller's own order:
13776
+ * this provider lists newest-first, and quietly reversing it here would have
13777
+ * turned a density filter into a reordering — caught by the spec that pins
13778
+ * `clips[0].kind`, which is the only reason it is not shipping.
13779
+ */
13780
+ function applyQuietKinds(clips) {
13781
+ const lastEmitted = /* @__PURE__ */ new Map();
13782
+ const dropped = /* @__PURE__ */ new Set();
13783
+ for (const clip of [...clips].toSorted((a, b) => a.timeRange.startMs - b.timeRange.startMs)) {
13784
+ const quietMs = QUIET_BY_KIND[clip.kind];
13785
+ if (quietMs === void 0) continue;
13786
+ const previous = lastEmitted.get(clip.kind);
13787
+ if (previous !== void 0 && clip.timeRange.startMs - previous < quietMs) {
13788
+ dropped.add(clip.id);
13789
+ continue;
13790
+ }
13791
+ lastEmitted.set(clip.kind, clip.timeRange.startMs);
13792
+ }
13793
+ return clips.filter((clip) => !dropped.has(clip.id));
13794
+ }
13766
13795
  //#endregion
13767
13796
  //#region src/recorder/addon/videoclip-visits.ts
13768
13797
  /**
@@ -13798,6 +13827,86 @@ function clusterFootageVisits(ranges, gapMs = require_dist.VISIT_MERGE_GAP_MS) {
13798
13827
  visits.push(finishVisit(pieces, endMs));
13799
13828
  return visits;
13800
13829
  }
13830
+ /**
13831
+ * A visit per BURST OF EVENTS, clamped to the footage that exists behind it.
13832
+ *
13833
+ * ## Why this replaced clustering the availability
13834
+ *
13835
+ * {@link VISIT_MERGE_GAP_MS}'s own docblock states the claim: 30 s "glues
13836
+ * those intra-visit holes without joining visits hours apart". It was applied
13837
+ * to the wrong input. {@link clusterFootageVisits} clusters AVAILABILITY, and
13838
+ * availability only has gaps on an EVENTS-MODE camera, where the writer opens
13839
+ * and closes around activity. A camera that records continuously has none —
13840
+ * so the visit became the recording session.
13841
+ *
13842
+ * Measured on 592, 2026-09-21, 24 h: seven rows covering the whole day, one of
13843
+ * them 7 h 58 m.
13844
+ *
13845
+ * ## And the grain is the PADDING, not a constant
13846
+ *
13847
+ * `VISIT_MERGE_GAP_MS` (30 s) was arbitrary here and sat on a cliff. 592's
13848
+ * worst hour holds 492 events — 150 object, 98 motion, **244 audio** — whose
13849
+ * LARGEST gap is 25 s, so nothing ever reached 30 and the hour was one row.
13850
+ * Measured on that hour:
13851
+ *
13852
+ * | rule | clips | longest |
13853
+ * | --- | --- | --- |
13854
+ * | 30 s | 1 | 66 min |
13855
+ * | `preMs + postMs` (15 s) | 105 | 3.5 min |
13856
+ *
13857
+ * So two clips merge exactly when they would otherwise OVERLAP. Nothing to
13858
+ * tune, nothing to disagree with the pad.
13859
+ *
13860
+ * ## What the footage is still for
13861
+ *
13862
+ * It CLAMPS, it does not define. A visit keeps only the pieces it actually
13863
+ * covers, because {@link stillInstantMs} takes the midpoint of the longest
13864
+ * piece — handed the whole session's range it would put the still hours from
13865
+ * the event. A burst with no footage behind it is dropped: an event the
13866
+ * recorder never kept is not a clip, and listing it is a row that opens onto
13867
+ * nothing.
13868
+ */
13869
+ function clusterEventVisits(events, ranges, pad) {
13870
+ const gapMs = pad.preMs + pad.postMs;
13871
+ if (events.length === 0 || ranges.length === 0) return [];
13872
+ const times = [...events].map((e) => e.timestamp).toSorted((a, b) => a - b);
13873
+ const sortedRanges = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
13874
+ const bursts = [];
13875
+ let first = times[0];
13876
+ let last = times[0];
13877
+ for (let i = 1; i < times.length; i++) {
13878
+ const t = times[i];
13879
+ if (t - last <= gapMs) {
13880
+ last = t;
13881
+ continue;
13882
+ }
13883
+ bursts.push({
13884
+ startMs: first - pad.preMs,
13885
+ endMs: last + pad.postMs
13886
+ });
13887
+ first = t;
13888
+ last = t;
13889
+ }
13890
+ bursts.push({
13891
+ startMs: first - pad.preMs,
13892
+ endMs: last + pad.postMs
13893
+ });
13894
+ const visits = [];
13895
+ for (const burst of bursts) {
13896
+ const pieces = [];
13897
+ for (const range of sortedRanges) {
13898
+ const startMs = Math.max(range.startMs, burst.startMs);
13899
+ const endMs = Math.min(range.endMs, burst.endMs);
13900
+ if (endMs > startMs) pieces.push({
13901
+ startMs,
13902
+ endMs
13903
+ });
13904
+ }
13905
+ if (pieces.length === 0) continue;
13906
+ visits.push(finishVisit(pieces, pieces[pieces.length - 1].endMs));
13907
+ }
13908
+ return visits;
13909
+ }
13801
13910
  function finishVisit(pieces, endMs) {
13802
13911
  const holes = [];
13803
13912
  for (let i = 1; i < pieces.length; i++) {
@@ -13972,13 +14081,17 @@ function eventsToClips(events, pad, vouched) {
13972
14081
  ...events.audio.map((e) => make(e, "audio"))
13973
14082
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
13974
14083
  }
13975
- /** Every id in the three buckets, in the order the composers tag them. */
13976
- function allEventIds(events) {
14084
+ /** Every event in the three buckets, in the order the composers tag them. */
14085
+ function allEvents(events) {
13977
14086
  return [
13978
14087
  ...events.motion,
13979
14088
  ...events.object,
13980
14089
  ...events.audio
13981
- ].map((e) => e.id);
14090
+ ];
14091
+ }
14092
+ /** Every id in the three buckets, in the order the composers tag them. */
14093
+ function allEventIds(events) {
14094
+ return allEvents(events).map((e) => e.id);
13982
14095
  }
13983
14096
  /**
13984
14097
  * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
@@ -14040,7 +14153,7 @@ function intersectsWindow(clip, since, until) {
14040
14153
  * scan on an indexed column buys a list that stops changing under the cursor.
14041
14154
  */
14042
14155
  function leftOverhangMs(pad) {
14043
- return require_dist.VISIT_MERGE_GAP_MS + pad.preMs;
14156
+ return pad.preMs + pad.postMs + pad.preMs;
14044
14157
  }
14045
14158
  /** No events at all — what the degraded branch annotates its visits with. */
14046
14159
  var NO_EVENTS = {
@@ -14095,18 +14208,19 @@ function createVideoclipsProvider(deps) {
14095
14208
  }
14096
14209
  let clips = eventsToClips(events, deps.defaultPad, vouched);
14097
14210
  if (deps.fetchAvailability) try {
14098
- clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
14211
+ const ranges = await deps.fetchAvailability({
14099
14212
  deviceId,
14100
14213
  since: readSince,
14101
14214
  until
14102
- }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
14215
+ });
14216
+ clips = visitsToClips(clusterEventVisits(allEvents(events), ranges, deps.defaultPad), events, deps.defaultPad, vouched);
14103
14217
  } catch (err) {
14104
14218
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
14105
14219
  deviceId,
14106
14220
  error: err instanceof Error ? err.message : String(err)
14107
14221
  });
14108
14222
  }
14109
- const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
14223
+ const inWindow = applyQuietKinds(clips).filter((c) => intersectsWindow(c, since, until));
14110
14224
  const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
14111
14225
  reportUnvouchedClips(deps, deviceId, answer);
14112
14226
  return answer;
@@ -13756,6 +13756,35 @@ var PlacementService = class {
13756
13756
  });
13757
13757
  }
13758
13758
  };
13759
+ /** How long each kind stays quiet after it has been listed. Absent = no bound. */
13760
+ var QUIET_BY_KIND = {
13761
+ audio: 5 * 6e4,
13762
+ motion: 2 * 6e4
13763
+ };
13764
+ /**
13765
+ * Thin a run of same-kind rows to one per cooldown.
13766
+ *
13767
+ * The DECISION is made oldest-first, because a cooldown read off an unsorted
13768
+ * list keeps an arbitrary subset. The ANSWER keeps the caller's own order:
13769
+ * this provider lists newest-first, and quietly reversing it here would have
13770
+ * turned a density filter into a reordering — caught by the spec that pins
13771
+ * `clips[0].kind`, which is the only reason it is not shipping.
13772
+ */
13773
+ function applyQuietKinds(clips) {
13774
+ const lastEmitted = /* @__PURE__ */ new Map();
13775
+ const dropped = /* @__PURE__ */ new Set();
13776
+ for (const clip of [...clips].toSorted((a, b) => a.timeRange.startMs - b.timeRange.startMs)) {
13777
+ const quietMs = QUIET_BY_KIND[clip.kind];
13778
+ if (quietMs === void 0) continue;
13779
+ const previous = lastEmitted.get(clip.kind);
13780
+ if (previous !== void 0 && clip.timeRange.startMs - previous < quietMs) {
13781
+ dropped.add(clip.id);
13782
+ continue;
13783
+ }
13784
+ lastEmitted.set(clip.kind, clip.timeRange.startMs);
13785
+ }
13786
+ return clips.filter((clip) => !dropped.has(clip.id));
13787
+ }
13759
13788
  //#endregion
13760
13789
  //#region src/recorder/addon/videoclip-visits.ts
13761
13790
  /**
@@ -13791,6 +13820,86 @@ function clusterFootageVisits(ranges, gapMs = VISIT_MERGE_GAP_MS) {
13791
13820
  visits.push(finishVisit(pieces, endMs));
13792
13821
  return visits;
13793
13822
  }
13823
+ /**
13824
+ * A visit per BURST OF EVENTS, clamped to the footage that exists behind it.
13825
+ *
13826
+ * ## Why this replaced clustering the availability
13827
+ *
13828
+ * {@link VISIT_MERGE_GAP_MS}'s own docblock states the claim: 30 s "glues
13829
+ * those intra-visit holes without joining visits hours apart". It was applied
13830
+ * to the wrong input. {@link clusterFootageVisits} clusters AVAILABILITY, and
13831
+ * availability only has gaps on an EVENTS-MODE camera, where the writer opens
13832
+ * and closes around activity. A camera that records continuously has none —
13833
+ * so the visit became the recording session.
13834
+ *
13835
+ * Measured on 592, 2026-09-21, 24 h: seven rows covering the whole day, one of
13836
+ * them 7 h 58 m.
13837
+ *
13838
+ * ## And the grain is the PADDING, not a constant
13839
+ *
13840
+ * `VISIT_MERGE_GAP_MS` (30 s) was arbitrary here and sat on a cliff. 592's
13841
+ * worst hour holds 492 events — 150 object, 98 motion, **244 audio** — whose
13842
+ * LARGEST gap is 25 s, so nothing ever reached 30 and the hour was one row.
13843
+ * Measured on that hour:
13844
+ *
13845
+ * | rule | clips | longest |
13846
+ * | --- | --- | --- |
13847
+ * | 30 s | 1 | 66 min |
13848
+ * | `preMs + postMs` (15 s) | 105 | 3.5 min |
13849
+ *
13850
+ * So two clips merge exactly when they would otherwise OVERLAP. Nothing to
13851
+ * tune, nothing to disagree with the pad.
13852
+ *
13853
+ * ## What the footage is still for
13854
+ *
13855
+ * It CLAMPS, it does not define. A visit keeps only the pieces it actually
13856
+ * covers, because {@link stillInstantMs} takes the midpoint of the longest
13857
+ * piece — handed the whole session's range it would put the still hours from
13858
+ * the event. A burst with no footage behind it is dropped: an event the
13859
+ * recorder never kept is not a clip, and listing it is a row that opens onto
13860
+ * nothing.
13861
+ */
13862
+ function clusterEventVisits(events, ranges, pad) {
13863
+ const gapMs = pad.preMs + pad.postMs;
13864
+ if (events.length === 0 || ranges.length === 0) return [];
13865
+ const times = [...events].map((e) => e.timestamp).toSorted((a, b) => a - b);
13866
+ const sortedRanges = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
13867
+ const bursts = [];
13868
+ let first = times[0];
13869
+ let last = times[0];
13870
+ for (let i = 1; i < times.length; i++) {
13871
+ const t = times[i];
13872
+ if (t - last <= gapMs) {
13873
+ last = t;
13874
+ continue;
13875
+ }
13876
+ bursts.push({
13877
+ startMs: first - pad.preMs,
13878
+ endMs: last + pad.postMs
13879
+ });
13880
+ first = t;
13881
+ last = t;
13882
+ }
13883
+ bursts.push({
13884
+ startMs: first - pad.preMs,
13885
+ endMs: last + pad.postMs
13886
+ });
13887
+ const visits = [];
13888
+ for (const burst of bursts) {
13889
+ const pieces = [];
13890
+ for (const range of sortedRanges) {
13891
+ const startMs = Math.max(range.startMs, burst.startMs);
13892
+ const endMs = Math.min(range.endMs, burst.endMs);
13893
+ if (endMs > startMs) pieces.push({
13894
+ startMs,
13895
+ endMs
13896
+ });
13897
+ }
13898
+ if (pieces.length === 0) continue;
13899
+ visits.push(finishVisit(pieces, pieces[pieces.length - 1].endMs));
13900
+ }
13901
+ return visits;
13902
+ }
13794
13903
  function finishVisit(pieces, endMs) {
13795
13904
  const holes = [];
13796
13905
  for (let i = 1; i < pieces.length; i++) {
@@ -13965,13 +14074,17 @@ function eventsToClips(events, pad, vouched) {
13965
14074
  ...events.audio.map((e) => make(e, "audio"))
13966
14075
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
13967
14076
  }
13968
- /** Every id in the three buckets, in the order the composers tag them. */
13969
- function allEventIds(events) {
14077
+ /** Every event in the three buckets, in the order the composers tag them. */
14078
+ function allEvents(events) {
13970
14079
  return [
13971
14080
  ...events.motion,
13972
14081
  ...events.object,
13973
14082
  ...events.audio
13974
- ].map((e) => e.id);
14083
+ ];
14084
+ }
14085
+ /** Every id in the three buckets, in the order the composers tag them. */
14086
+ function allEventIds(events) {
14087
+ return allEvents(events).map((e) => e.id);
13975
14088
  }
13976
14089
  /**
13977
14090
  * Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
@@ -14033,7 +14146,7 @@ function intersectsWindow(clip, since, until) {
14033
14146
  * scan on an indexed column buys a list that stops changing under the cursor.
14034
14147
  */
14035
14148
  function leftOverhangMs(pad) {
14036
- return VISIT_MERGE_GAP_MS + pad.preMs;
14149
+ return pad.preMs + pad.postMs + pad.preMs;
14037
14150
  }
14038
14151
  /** No events at all — what the degraded branch annotates its visits with. */
14039
14152
  var NO_EVENTS = {
@@ -14088,18 +14201,19 @@ function createVideoclipsProvider(deps) {
14088
14201
  }
14089
14202
  let clips = eventsToClips(events, deps.defaultPad, vouched);
14090
14203
  if (deps.fetchAvailability) try {
14091
- clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
14204
+ const ranges = await deps.fetchAvailability({
14092
14205
  deviceId,
14093
14206
  since: readSince,
14094
14207
  until
14095
- }), VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
14208
+ });
14209
+ clips = visitsToClips(clusterEventVisits(allEvents(events), ranges, deps.defaultPad), events, deps.defaultPad, vouched);
14096
14210
  } catch (err) {
14097
14211
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
14098
14212
  deviceId,
14099
14213
  error: err instanceof Error ? err.message : String(err)
14100
14214
  });
14101
14215
  }
14102
- const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
14216
+ const inWindow = applyQuietKinds(clips).filter((c) => intersectsWindow(c, since, until));
14103
14217
  const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
14104
14218
  reportUnvouchedClips(deps, deviceId, answer);
14105
14219
  return answer;
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.194",
36
+ version: "1.2.195",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.194",
84
+ version: "1.2.195",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrNGbvkl.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D0A4Ra4h.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.275",
3
+ "version": "1.2.279",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",