cctv-stack 0.3.0__py3-none-any.whl

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.
cctv/clip.py ADDED
@@ -0,0 +1,278 @@
1
+ """Deciding *when* to fetch a clip, and whether what came back is good enough.
2
+
3
+ Two separate responsibilities, both pure, both here so that the clip-worker's
4
+ orchestration has nothing subtle left in it:
5
+
6
+ 1. **When.** A clip must not be fetched until the last recording segment covering the
7
+ review has been flushed to disk. Fetching earlier does not fail loudly — it returns
8
+ `200` with a *short* clip, which is the worst kind of bug because the MP4 is valid and
9
+ plays fine. Measured on the real stack: at 5 s after the review ended, 5 of 6 samples
10
+ came back silently truncated; the first full-length result was at 10 s. That is why the
11
+ default grace is 15 s and why [`FetchPlan.not_before`][cctv.clip.FetchPlan.not_before]
12
+ is a value that must be waited for rather than a hint.
13
+
14
+ 2. **Whether it is good enough.** A truncated clip, a clip with a hole, and a perfect clip
15
+ are all valid MP4s that upload successfully. Telling them apart is what
16
+ [INV-14](../../../docs/architecture/invariants.md) requires, and it is why these
17
+ decisions are pure functions with names rather than a chain of `if`s in a worker loop.
18
+
19
+ **A short clip is never discarded.** Retrying improves the outcome; discarding cannot. When
20
+ attempts run out the best result obtained is kept and marked degraded.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Sequence
26
+ from dataclasses import dataclass
27
+ from enum import StrEnum
28
+
29
+ from .timeutil import Instant
30
+
31
+ __all__ = [
32
+ "DEFAULT_COVERAGE_OK",
33
+ "DEFAULT_COVERAGE_POLICY",
34
+ "DEFAULT_COVERAGE_RETRY",
35
+ "DEFAULT_FETCH_ATTEMPTS",
36
+ "DEFAULT_FETCH_TIMEOUT_BASE_S",
37
+ "DEFAULT_FETCH_TIMEOUT_CEILING_S",
38
+ "DEFAULT_LONG_CLIP_WARN_S",
39
+ "DEFAULT_POST_ROLL_S",
40
+ "DEFAULT_PRE_ROLL_S",
41
+ "DEFAULT_SEGMENT_FLUSH_GRACE_S",
42
+ "ClipPlanError",
43
+ "CoveragePolicy",
44
+ "FetchPlan",
45
+ "Verdict",
46
+ "fetch_timeout_s",
47
+ "judge_coverage",
48
+ "pick_best",
49
+ "plan_fetch",
50
+ ]
51
+
52
+ DEFAULT_PRE_ROLL_S = 5.0
53
+ DEFAULT_POST_ROLL_S = 10.0
54
+
55
+ #: Measured, not guessed. The floor for a reliable fetch was 10 s after the review ended;
56
+ #: 5 s raced Frigate's segment flush. See `spike/RESULTS.md`.
57
+ DEFAULT_SEGMENT_FLUSH_GRACE_S = 15.0
58
+
59
+ #: `actual_duration / requested_duration` at or above this is a good clip.
60
+ DEFAULT_COVERAGE_OK = 0.95
61
+
62
+ #: Below this, the clip is short enough that another attempt is worth making. Note the gap
63
+ #: between the two thresholds is the *degraded* band: kept, flagged, not retried forever.
64
+ DEFAULT_COVERAGE_RETRY = 0.80
65
+
66
+ DEFAULT_FETCH_ATTEMPTS = 3
67
+
68
+ #: A fetch is a range read plus an `ffmpeg` cut, so its cost is roughly linear in the
69
+ #: clip's duration. A fixed timeout is therefore not a safety net but a silent failure
70
+ #: mode: at 120 s, every review longer than about two minutes fails, and with a generous
71
+ #: `visit_gap_s` those are precisely the interesting ones.
72
+ DEFAULT_FETCH_TIMEOUT_BASE_S = 120.0
73
+ DEFAULT_FETCH_TIMEOUT_CEILING_S = 1800.0
74
+
75
+ #: Flag only. A long review is stored in full, never truncated and never split.
76
+ DEFAULT_LONG_CLIP_WARN_S = 600.0
77
+
78
+
79
+ class ClipPlanError(ValueError):
80
+ """A fetch that cannot be planned as asked."""
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class FetchPlan:
85
+ """The range to request, and the earliest instant it may be requested.
86
+
87
+ Asymmetric on purpose: `pre_roll_s` and `post_roll_s` differ, so the symmetric
88
+ `?padding=` on Frigate's review endpoint cannot express this — hence the range
89
+ endpoint ([ADR-0015](../../../docs/adr/0015-clip-boundaries-and-padding.md)).
90
+ """
91
+
92
+ review_id: str
93
+ requested_start: Instant
94
+ requested_end: Instant
95
+ #: `end_time + post_roll_s + segment_flush_grace_s`. Waiting for this is what stops the
96
+ #: silent truncation the spike measured.
97
+ not_before: Instant
98
+ timeout_s: float
99
+
100
+ @property
101
+ def requested_s(self) -> float:
102
+ return self.requested_end - self.requested_start
103
+
104
+ def seconds_until_ready(self, now: Instant) -> float:
105
+ """How long to wait before fetching. Negative means it is already safe."""
106
+ return self.not_before - now
107
+
108
+ def is_ready(self, now: Instant) -> bool:
109
+ return now >= self.not_before
110
+
111
+
112
+ def fetch_timeout_s(
113
+ requested_s: float,
114
+ *,
115
+ base_s: float = DEFAULT_FETCH_TIMEOUT_BASE_S,
116
+ ceiling_s: float = DEFAULT_FETCH_TIMEOUT_CEILING_S,
117
+ ) -> float:
118
+ """A timeout proportional to the clip's length, clamped.
119
+
120
+ Proportionality matters because the alternative is worse than it looks: a clip that
121
+ times out *is* retried, so a fixed timeout turns one long review into three doomed
122
+ attempts and then a permanent failure — losing the clip entirely rather than taking
123
+ longer over it. At the original 120 s, every review longer than about two minutes
124
+ failed that way, and with a generous `visit_gap_s` those are the interesting ones.
125
+
126
+ The ceiling exists for a different job: bounding a *hang* rather than a slow-but-working
127
+ fetch. A generous timeout is right for a large clip, but an unbounded one lets a stuck
128
+ connection occupy a fetch slot for as long as the clip is long, which is how a queue
129
+ quietly stops draining.
130
+ """
131
+ if requested_s < 0:
132
+ raise ClipPlanError(f"requested duration cannot be negative, got {requested_s}.")
133
+ return min(base_s + requested_s, ceiling_s)
134
+
135
+
136
+ def plan_fetch(
137
+ review_id: str,
138
+ *,
139
+ start_time: Instant,
140
+ end_time: Instant,
141
+ pre_roll_s: float = DEFAULT_PRE_ROLL_S,
142
+ post_roll_s: float = DEFAULT_POST_ROLL_S,
143
+ segment_flush_grace_s: float = DEFAULT_SEGMENT_FLUSH_GRACE_S,
144
+ timeout_base_s: float = DEFAULT_FETCH_TIMEOUT_BASE_S,
145
+ timeout_ceiling_s: float = DEFAULT_FETCH_TIMEOUT_CEILING_S,
146
+ ) -> FetchPlan:
147
+ """Build the fetch plan for one review.
148
+
149
+ Raises:
150
+ ClipPlanError: the review has no end, ends before it starts, or a roll is negative.
151
+ """
152
+ if not review_id:
153
+ raise ClipPlanError("A fetch plan needs the review id it belongs to.")
154
+ if end_time is None: # type: ignore[comparison-overlap]
155
+ raise ClipPlanError(
156
+ f"Review {review_id!r} has no end_time, so it is still open. Fetching now would "
157
+ f"capture a partial visit, and the clip could never be extended afterwards."
158
+ )
159
+ if end_time < start_time:
160
+ raise ClipPlanError(
161
+ f"Review {review_id!r} ends ({end_time}) before it starts ({start_time})."
162
+ )
163
+ for name, value in (
164
+ ("pre_roll_s", pre_roll_s),
165
+ ("post_roll_s", post_roll_s),
166
+ ("segment_flush_grace_s", segment_flush_grace_s),
167
+ ):
168
+ if value < 0:
169
+ raise ClipPlanError(f"{name} cannot be negative, got {value}.")
170
+
171
+ requested_start = start_time - pre_roll_s
172
+ requested_end = end_time + post_roll_s
173
+
174
+ return FetchPlan(
175
+ review_id=review_id,
176
+ requested_start=requested_start,
177
+ requested_end=requested_end,
178
+ not_before=requested_end + segment_flush_grace_s,
179
+ timeout_s=fetch_timeout_s(
180
+ requested_end - requested_start, base_s=timeout_base_s, ceiling_s=timeout_ceiling_s
181
+ ),
182
+ )
183
+
184
+
185
+ # ---------------------------------------------------------------------------------------
186
+ # Is it good enough?
187
+ # ---------------------------------------------------------------------------------------
188
+
189
+
190
+ class Verdict(StrEnum):
191
+ """What to do with a fetched clip."""
192
+
193
+ #: Accept it. `coverage_ratio` at or above `coverage_ok`.
194
+ ACCEPT = "accept"
195
+ #: Short, and another attempt is affordable. Keep it in hand first.
196
+ RETRY = "retry"
197
+ #: Short, and attempts are exhausted. Store it, mark it degraded, alert.
198
+ ACCEPT_DEGRADED = "accept_degraded"
199
+
200
+
201
+ @dataclass(frozen=True, slots=True)
202
+ class CoveragePolicy:
203
+ ok: float = DEFAULT_COVERAGE_OK
204
+ retry: float = DEFAULT_COVERAGE_RETRY
205
+ max_attempts: int = DEFAULT_FETCH_ATTEMPTS
206
+
207
+ def __post_init__(self) -> None:
208
+ if not 0 < self.retry <= self.ok <= 2.0:
209
+ raise ClipPlanError(
210
+ f"Coverage thresholds must satisfy 0 < retry ({self.retry}) <= ok "
211
+ f"({self.ok}). A `retry` above `ok` would mean a clip that was already "
212
+ f"acceptable still gets re-fetched."
213
+ )
214
+ if self.max_attempts < 1:
215
+ raise ClipPlanError("max_attempts must be at least 1.")
216
+
217
+ @property
218
+ def degraded_band(self) -> tuple[float, float]:
219
+ """The range that is stored and flagged rather than retried further."""
220
+ return (self.retry, self.ok)
221
+
222
+
223
+ #: A module singleton, not a call in a signature. `CoveragePolicy` is frozen, so this
224
+ #: cannot be mutated by a caller that forgets to pass its own.
225
+ DEFAULT_COVERAGE_POLICY = CoveragePolicy()
226
+
227
+
228
+ def judge_coverage(
229
+ coverage_ratio: float | None,
230
+ *,
231
+ attempts_used: int,
232
+ policy: CoveragePolicy = DEFAULT_COVERAGE_POLICY,
233
+ ) -> Verdict:
234
+ """Classify a fetch result.
235
+
236
+ `coverage_ratio` is `actual_duration / requested_duration`, and it is **expected to
237
+ exceed 1.0 sometimes**. `-c copy` can only cut at keyframes, and the spike measured it
238
+ erring *outward* by roughly 1.9 s per 10 s of clip. Over-running is the intended
239
+ behaviour ([INV-14]): overshooting costs storage, undershooting loses evidence. So a
240
+ ratio above 1.0 is a success, not an anomaly to correct.
241
+
242
+ `None` means the duration could not be measured at all, which is treated as a failed
243
+ attempt rather than a perfect one.
244
+ """
245
+ if attempts_used < 1:
246
+ raise ClipPlanError("attempts_used must be at least 1 after a fetch has happened.")
247
+
248
+ if coverage_ratio is None:
249
+ return Verdict.RETRY if attempts_used < policy.max_attempts else Verdict.ACCEPT_DEGRADED
250
+
251
+ if coverage_ratio >= policy.ok:
252
+ return Verdict.ACCEPT
253
+
254
+ if attempts_used < policy.max_attempts:
255
+ return Verdict.RETRY
256
+
257
+ return Verdict.ACCEPT_DEGRADED
258
+
259
+
260
+ def pick_best(candidates: Sequence[object]) -> object | None:
261
+ """The best of several attempts. Never discard a result in favour of nothing.
262
+
263
+ Ranked by coverage first, then by duration. Ties broken by duration because when two
264
+ attempts cover the same fraction of the request, the longer one contains more footage
265
+ — coverage alone can tie while the clips differ materially, which happens when the
266
+ second attempt was cut against different keyframes.
267
+ """
268
+ scored = [c for c in candidates if getattr(c, "coverage_ratio", None) is not None]
269
+ if not scored:
270
+ # Nothing measurable. Return the longest if we can, else the last attempt.
271
+ with_duration = [c for c in candidates if getattr(c, "duration_s", None) is not None]
272
+ if with_duration:
273
+ return max(with_duration, key=lambda c: c.duration_s) # type: ignore[attr-defined]
274
+ return candidates[-1] if candidates else None
275
+ return max(
276
+ scored,
277
+ key=lambda c: (c.coverage_ratio, c.duration_s or 0.0), # type: ignore[attr-defined]
278
+ )