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/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/dispatch.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Getting review messages off the network thread and into a worker.
|
|
2
|
+
|
|
3
|
+
The MQTT callback must not do the work. Materialising a clip waits out the flush grace,
|
|
4
|
+
fetches, probes and writes — tens of seconds — and doing that on the network loop's thread
|
|
5
|
+
stops it servicing the socket, so messages are *missed*. That failure is invisible: the
|
|
6
|
+
broker does not know we were busy, the queue does not exist, and the only symptom is clips
|
|
7
|
+
that never appear.
|
|
8
|
+
|
|
9
|
+
So the callback parses, hands over, and returns. A bounded queue and a worker thread do the
|
|
10
|
+
rest.
|
|
11
|
+
|
|
12
|
+
## Dropping under load is correct, and blocking is not
|
|
13
|
+
|
|
14
|
+
MQTT delivery here is at-most-once (see below), so **the sweep is the guarantee and this is
|
|
15
|
+
the latency optimisation**. Given that, a full queue must drop and count rather than block:
|
|
16
|
+
blocking the callback costs messages *and* stalls every other topic, whereas dropping costs
|
|
17
|
+
only latency — the sweep will find the review on its next pass.
|
|
18
|
+
|
|
19
|
+
## The pre-filter is queue hygiene, not a second decision
|
|
20
|
+
|
|
21
|
+
Most traffic on `frigate/reviews` is `update` messages about reviews that are still **open**,
|
|
22
|
+
and an open review can never become a clip. Queueing them would let a chatty camera fill a
|
|
23
|
+
bounded queue and cause *closed* reviews to be dropped — losing exactly the messages that
|
|
24
|
+
mattered, to make room for ones that could not be acted on.
|
|
25
|
+
|
|
26
|
+
So open reviews are dropped at the door. This is not a duplicate of
|
|
27
|
+
[`reviews.decide`][cctv.reviews.decide] — it is the one condition that has to be applied
|
|
28
|
+
before the queue exists, and `decide` still applies it (and every other filter) in the
|
|
29
|
+
worker, because the sweep path has no door to filter at.
|
|
30
|
+
|
|
31
|
+
## Why not QoS 1
|
|
32
|
+
|
|
33
|
+
Subscribing at QoS 1 does not upgrade a publisher sending at QoS 0, and a broker restart
|
|
34
|
+
still loses in-flight messages. The completeness guarantee has to come from somewhere that
|
|
35
|
+
does not depend on delivery at all, which is the sweep. Claiming reliability here would
|
|
36
|
+
only move the lie somewhere harder to find.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import contextlib
|
|
42
|
+
import queue
|
|
43
|
+
import threading
|
|
44
|
+
from collections.abc import Callable, Mapping
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from .reviews import (
|
|
49
|
+
ReviewCandidate,
|
|
50
|
+
ReviewParseError,
|
|
51
|
+
candidate_from_message,
|
|
52
|
+
parse_review,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"DROPPED",
|
|
57
|
+
"OPEN",
|
|
58
|
+
"QUEUED",
|
|
59
|
+
"UNPARSABLE",
|
|
60
|
+
"DispatchStats",
|
|
61
|
+
"ReviewDispatcher",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
QUEUED = "queued"
|
|
65
|
+
DROPPED = "dropped"
|
|
66
|
+
UNPARSABLE = "unparsable"
|
|
67
|
+
OPEN = "open"
|
|
68
|
+
|
|
69
|
+
#: Deep enough to ride out a burst of closed reviews, shallow enough that a stall is
|
|
70
|
+
#: noticed as a count rather than as unbounded memory.
|
|
71
|
+
DEFAULT_MAX_PENDING = 1000
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class DispatchStats:
|
|
76
|
+
"""Counters for the health check.
|
|
77
|
+
|
|
78
|
+
`dropped` and `unparsable` existing as *numbers* is the point: a system that silently
|
|
79
|
+
discards a tenth of its input and one that is simply idle look identical from the
|
|
80
|
+
outside.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
received: int = 0
|
|
84
|
+
queued: int = 0
|
|
85
|
+
handle: int = 0
|
|
86
|
+
open_reviews: int = 0
|
|
87
|
+
unparsable: int = 0
|
|
88
|
+
dropped: int = 0
|
|
89
|
+
handler_errors: int = 0
|
|
90
|
+
last_error: str | None = None
|
|
91
|
+
_reasons: dict[str, int] = field(default_factory=dict, repr=False)
|
|
92
|
+
|
|
93
|
+
def note(self, reason: str) -> None:
|
|
94
|
+
self._reasons[reason] = self._reasons.get(reason, 0) + 1
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def reasons(self) -> Mapping[str, int]:
|
|
98
|
+
return dict(self._reasons)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def lost(self) -> int:
|
|
102
|
+
"""Messages that will only be recovered by the sweep."""
|
|
103
|
+
return self.dropped + self.unparsable
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ReviewDispatcher:
|
|
107
|
+
"""Bounded hand-off between the MQTT callback and the materialiser.
|
|
108
|
+
|
|
109
|
+
`handler` receives a closed [`ReviewCandidate`][cctv.reviews.ReviewCandidate] and may
|
|
110
|
+
take as long as it likes: it runs on the worker, never on the callback.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
handler: Callable[[ReviewCandidate], Any],
|
|
117
|
+
max_pending: int = DEFAULT_MAX_PENDING,
|
|
118
|
+
on_open: Callable[[ReviewCandidate], Any] | None = None,
|
|
119
|
+
clock: Callable[[], float] | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
if max_pending < 1:
|
|
122
|
+
raise ValueError("max_pending must be at least 1.")
|
|
123
|
+
self.handler = handler
|
|
124
|
+
self.max_pending = max_pending
|
|
125
|
+
self.on_open = on_open
|
|
126
|
+
self.stats = DispatchStats()
|
|
127
|
+
|
|
128
|
+
self._queue: queue.Queue[ReviewCandidate] = queue.Queue(maxsize=max_pending)
|
|
129
|
+
self._stop = threading.Event()
|
|
130
|
+
self._thread: threading.Thread | None = None
|
|
131
|
+
|
|
132
|
+
# -----------------------------------------------------------------------------------
|
|
133
|
+
# Called from the network thread. Must not block, and must not raise.
|
|
134
|
+
# -----------------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def dispatch(self, message: Mapping[str, Any]) -> str:
|
|
137
|
+
"""Parse and enqueue one `frigate/reviews` message.
|
|
138
|
+
|
|
139
|
+
Returns what happened, so a caller can count it. **Never raises**: an exception
|
|
140
|
+
escaping an MQTT callback is swallowed by the network loop, so a single bad message
|
|
141
|
+
would otherwise stop every future message from being seen.
|
|
142
|
+
"""
|
|
143
|
+
self.stats.received += 1
|
|
144
|
+
|
|
145
|
+
candidate = self._parse(message)
|
|
146
|
+
if candidate is None:
|
|
147
|
+
# Counted, not just noted. `lost` is derived from this, and a discard that is
|
|
148
|
+
# only in the reason map would make the health check report nothing discarded.
|
|
149
|
+
self.stats.unparsable += 1
|
|
150
|
+
return UNPARSABLE
|
|
151
|
+
|
|
152
|
+
if candidate.is_open:
|
|
153
|
+
# The majority of traffic, and none of it can ever be a clip. See the module
|
|
154
|
+
# docstring: queueing it would evict the messages that matter.
|
|
155
|
+
self.stats.open_reviews += 1
|
|
156
|
+
self.stats.note("review still open")
|
|
157
|
+
if self.on_open is not None:
|
|
158
|
+
_swallow(self.on_open, candidate)
|
|
159
|
+
return OPEN
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
self._queue.put_nowait(candidate)
|
|
163
|
+
except queue.Full:
|
|
164
|
+
self.stats.dropped += 1
|
|
165
|
+
self.stats.note("queue full")
|
|
166
|
+
return DROPPED
|
|
167
|
+
|
|
168
|
+
self.stats.queued += 1
|
|
169
|
+
return QUEUED
|
|
170
|
+
|
|
171
|
+
def _parse(self, message: Mapping[str, Any]) -> ReviewCandidate | None:
|
|
172
|
+
"""Parse without trusting the shape.
|
|
173
|
+
|
|
174
|
+
`after` is read on **every** message type. The restart experiment showed Frigate
|
|
175
|
+
closes a review without publishing `end` at all, so a trigger keyed on the message
|
|
176
|
+
type would lose that clip silently.
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(message, Mapping):
|
|
179
|
+
self.stats.note("message is not an object")
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
if "after" in message:
|
|
183
|
+
return candidate_from_message(message)
|
|
184
|
+
# Accept a bare review object too: the sweep reads that shape from the API, and
|
|
185
|
+
# a test or a replay tool will hand it over in the same form.
|
|
186
|
+
return parse_review(message)
|
|
187
|
+
except ReviewParseError as exc:
|
|
188
|
+
self.stats.note(str(exc)[:120])
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
# -----------------------------------------------------------------------------------
|
|
192
|
+
# The worker
|
|
193
|
+
# -----------------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def work_once(self, *, timeout: float | None = None) -> ReviewCandidate | None:
|
|
196
|
+
"""Take one candidate and handle it. Returns what was handled, if anything.
|
|
197
|
+
|
|
198
|
+
Synchronous so tests need neither a broker nor a thread.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
candidate = self._queue.get(timeout=timeout)
|
|
202
|
+
except queue.Empty:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self.handler(candidate)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
# A handler failure is counted, not fatal. The review is still in Frigate, so
|
|
209
|
+
# the sweep will offer it again — which is the whole reason this can be
|
|
210
|
+
# forgiving.
|
|
211
|
+
self.stats.handler_errors += 1
|
|
212
|
+
self.stats.last_error = f"{type(exc).__name__}: {exc}"[:200]
|
|
213
|
+
self.stats.note("handler error")
|
|
214
|
+
else:
|
|
215
|
+
self.stats.handle += 1
|
|
216
|
+
finally:
|
|
217
|
+
self._queue.task_done()
|
|
218
|
+
|
|
219
|
+
return candidate
|
|
220
|
+
|
|
221
|
+
def drain(self, *, limit: int | None = None) -> int:
|
|
222
|
+
"""Handle everything currently queued. Returns how many were handled."""
|
|
223
|
+
handled = 0
|
|
224
|
+
while limit is None or handled < limit:
|
|
225
|
+
if self.work_once(timeout=0) is None:
|
|
226
|
+
break
|
|
227
|
+
handled += 1
|
|
228
|
+
return handled
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def pending(self) -> int:
|
|
232
|
+
return self._queue.qsize()
|
|
233
|
+
|
|
234
|
+
# -----------------------------------------------------------------------------------
|
|
235
|
+
# Thread lifecycle
|
|
236
|
+
# -----------------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def start(self) -> None:
|
|
239
|
+
if self._thread is not None:
|
|
240
|
+
raise RuntimeError("Dispatcher already started.")
|
|
241
|
+
self._stop.clear()
|
|
242
|
+
self._thread = threading.Thread(target=self._run, name="review-dispatcher", daemon=True)
|
|
243
|
+
self._thread.start()
|
|
244
|
+
|
|
245
|
+
def _run(self) -> None:
|
|
246
|
+
while not self._stop.is_set():
|
|
247
|
+
self.work_once(timeout=0.5)
|
|
248
|
+
|
|
249
|
+
def stop(self, *, timeout: float = 5.0) -> None:
|
|
250
|
+
self._stop.set()
|
|
251
|
+
if self._thread is not None:
|
|
252
|
+
self._thread.join(timeout=timeout)
|
|
253
|
+
self._thread = None
|
|
254
|
+
|
|
255
|
+
def __enter__(self) -> ReviewDispatcher:
|
|
256
|
+
self.start()
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
260
|
+
self.stop()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _swallow(callback: Callable[[ReviewCandidate], Any], candidate: ReviewCandidate) -> None:
|
|
264
|
+
"""Run an optional observer, ignoring whatever it does.
|
|
265
|
+
|
|
266
|
+
Used for the `on_open` hook, which exists so a caller can log or count open reviews
|
|
267
|
+
without that being able to break the callback.
|
|
268
|
+
"""
|
|
269
|
+
with contextlib.suppress(Exception):
|
|
270
|
+
callback(candidate)
|
cctv/ffprobe.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Reading what `ffprobe` says about a clip.
|
|
2
|
+
|
|
3
|
+
The output of this module is the evidence for [INV-14](../../../docs/architecture/invariants.md).
|
|
4
|
+
Without it, "are the clips right?" is an opinion; with it, it is a query against the
|
|
5
|
+
`clips` table's quality columns.
|
|
6
|
+
|
|
7
|
+
## `max_packet_gap_s` is advisory, and nothing may gate on it
|
|
8
|
+
|
|
9
|
+
The natural way to detect a hole in a clip is the largest gap between packet timestamps.
|
|
10
|
+
On this stack that detector **does not work**. Measured on a healthy 30-second clip cut
|
|
11
|
+
with `-c copy`: the largest gap was **3.55 s**, against a 2.55 s keyframe interval. The
|
|
12
|
+
likely cause is PTS discontinuities at the joins between recording segments, which
|
|
13
|
+
`-c copy` reproduces faithfully because it is not re-encoding anything.
|
|
14
|
+
|
|
15
|
+
So a threshold that looks reasonable — say 2.0 s — flags nearly every healthy clip. The
|
|
16
|
+
field is still recorded, because the data is real and a future detector may want it, but
|
|
17
|
+
**truncation is detected by coverage, not by gaps.**
|
|
18
|
+
|
|
19
|
+
See [ADR-0015](../../../docs/adr/0015-clip-boundaries-and-padding.md) for the three
|
|
20
|
+
candidate replacements, the preferred one being to read Frigate's own `recordings` table
|
|
21
|
+
rather than inferring anything from the media.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import subprocess
|
|
29
|
+
from collections.abc import Mapping, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from itertools import pairwise
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .clip import DEFAULT_COVERAGE_OK
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ClipFacts",
|
|
39
|
+
"ProbeError",
|
|
40
|
+
"build_probe_command",
|
|
41
|
+
"coverage_ratio",
|
|
42
|
+
"looks_truncated",
|
|
43
|
+
"max_packet_gap",
|
|
44
|
+
"parse_probe",
|
|
45
|
+
"run_probe",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
#: The binary, overridable because a container's ffmpeg is rarely on `PATH`. Frigate
|
|
49
|
+
#: itself vendors several builds under `/usr/lib/ffmpeg/<version>/bin/`, and our own image
|
|
50
|
+
#: will have its own arrangement — so the path is configuration, not a constant.
|
|
51
|
+
FFPROBE_ENV = "FFPROBE_BIN"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProbeError(RuntimeError):
|
|
55
|
+
"""`ffprobe` could not read the file, or is not installed."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ClipFacts:
|
|
60
|
+
"""What we learned about a clip by inspecting it rather than trusting the fetch."""
|
|
61
|
+
|
|
62
|
+
duration_s: float | None
|
|
63
|
+
video_codec: str | None
|
|
64
|
+
audio_codec: str | None
|
|
65
|
+
has_audio: bool
|
|
66
|
+
format_name: str | None
|
|
67
|
+
packet_count: int
|
|
68
|
+
#: Advisory only — see the module docstring. Never gate on this.
|
|
69
|
+
max_packet_gap_s: float | None
|
|
70
|
+
keyframe_interval_s: float | None
|
|
71
|
+
|
|
72
|
+
def coverage_against(self, requested_s: float) -> float | None:
|
|
73
|
+
return coverage_ratio(self.duration_s, requested_s)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_probe_command(path: str) -> list[str]:
|
|
77
|
+
"""The `ffprobe` invocation, as a list so nothing is ever shell-interpreted.
|
|
78
|
+
|
|
79
|
+
`-v error` keeps stderr useful: warnings about non-monotonic DTS would otherwise
|
|
80
|
+
drown the actual error on a genuinely broken file.
|
|
81
|
+
"""
|
|
82
|
+
return [
|
|
83
|
+
"ffprobe",
|
|
84
|
+
"-v",
|
|
85
|
+
"error",
|
|
86
|
+
"-show_format",
|
|
87
|
+
"-show_streams",
|
|
88
|
+
"-show_packets",
|
|
89
|
+
"-of",
|
|
90
|
+
"json",
|
|
91
|
+
path,
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_probe(
|
|
96
|
+
path: str | Path,
|
|
97
|
+
*,
|
|
98
|
+
ffprobe_bin: str | None = None,
|
|
99
|
+
timeout_s: float = 60.0,
|
|
100
|
+
) -> ClipFacts:
|
|
101
|
+
"""Run `ffprobe` on a finished file and return the facts.
|
|
102
|
+
|
|
103
|
+
Separate from [`parse_probe`][cctv.ffprobe.parse_probe] so the parsing — where the
|
|
104
|
+
interesting logic is — stays testable without a subprocess, and so every way the
|
|
105
|
+
subprocess can fail is handled in one place:
|
|
106
|
+
|
|
107
|
+
* the binary is missing (`FileNotFoundError`) — which is the normal case in a container
|
|
108
|
+
unless the image installs it, hence `FFPROBE_BIN`
|
|
109
|
+
* it hangs
|
|
110
|
+
* it exits non-zero on a file that is not really a video
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ProbeError: for all of the above. A clip we cannot measure is a clip we cannot
|
|
114
|
+
trust, so this is never swallowed.
|
|
115
|
+
"""
|
|
116
|
+
binary = ffprobe_bin or os.environ.get(FFPROBE_ENV) or "ffprobe"
|
|
117
|
+
command = build_probe_command(str(path))
|
|
118
|
+
command[0] = binary
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(command, capture_output=True, timeout=timeout_s, check=False)
|
|
122
|
+
except FileNotFoundError as exc:
|
|
123
|
+
raise ProbeError(
|
|
124
|
+
f"{binary!r} is not installed or not on PATH. Set {FFPROBE_ENV} to its full "
|
|
125
|
+
f"path: a container image rarely puts ffmpeg on PATH, and Frigate itself "
|
|
126
|
+
f"vendors its builds under /usr/lib/ffmpeg/<version>/bin/."
|
|
127
|
+
) from exc
|
|
128
|
+
except subprocess.TimeoutExpired as exc:
|
|
129
|
+
raise ProbeError(f"{binary} did not finish within {timeout_s}s for {path}.") from exc
|
|
130
|
+
|
|
131
|
+
if result.returncode != 0:
|
|
132
|
+
stderr = result.stderr.decode("utf-8", errors="replace").strip()[:400]
|
|
133
|
+
raise ProbeError(f"{binary} exited {result.returncode} for {path}: {stderr}")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
payload = json.loads(result.stdout.decode("utf-8"))
|
|
137
|
+
except json.JSONDecodeError as exc:
|
|
138
|
+
raise ProbeError(f"{binary} produced output that is not JSON for {path}: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
return parse_probe(payload)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_probe(payload: Mapping[str, Any]) -> ClipFacts:
|
|
144
|
+
"""Turn `ffprobe -of json` output into facts.
|
|
145
|
+
|
|
146
|
+
Deliberately tolerant of missing fields. `ffprobe` omits keys rather than emitting
|
|
147
|
+
nulls, and a clip with no audio stream has no audio fields at all — which must be
|
|
148
|
+
distinguishable from "we failed to read the file", so a missing duration stays `None`
|
|
149
|
+
rather than becoming `0.0`.
|
|
150
|
+
"""
|
|
151
|
+
fmt = payload.get("format") or {}
|
|
152
|
+
streams = payload.get("streams") or []
|
|
153
|
+
packets = payload.get("packets") or []
|
|
154
|
+
|
|
155
|
+
video = _first_stream(streams, "video")
|
|
156
|
+
audio = _first_stream(streams, "audio")
|
|
157
|
+
|
|
158
|
+
return ClipFacts(
|
|
159
|
+
duration_s=_as_float(fmt.get("duration")),
|
|
160
|
+
video_codec=video.get("codec_name") if video else None,
|
|
161
|
+
audio_codec=audio.get("codec_name") if audio else None,
|
|
162
|
+
has_audio=audio is not None,
|
|
163
|
+
format_name=fmt.get("format_name"),
|
|
164
|
+
packet_count=len(packets),
|
|
165
|
+
max_packet_gap_s=max_packet_gap(_packet_times(packets)),
|
|
166
|
+
keyframe_interval_s=_keyframe_interval(streams),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def coverage_ratio(actual_s: float | None, requested_s: float) -> float | None:
|
|
171
|
+
"""`actual / requested`. Above 1.0 is normal, not an error — see `cctv.clip`."""
|
|
172
|
+
if actual_s is None or requested_s <= 0:
|
|
173
|
+
return None
|
|
174
|
+
return actual_s / requested_s
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def looks_truncated(
|
|
178
|
+
facts: ClipFacts, requested_s: float, *, ok: float = DEFAULT_COVERAGE_OK
|
|
179
|
+
) -> bool:
|
|
180
|
+
"""Whether the clip is short enough to be worth another attempt.
|
|
181
|
+
|
|
182
|
+
Coverage-based by construction. **Do not add a gap check here** — the module docstring
|
|
183
|
+
explains why the gap detector flags healthy clips on this stack.
|
|
184
|
+
"""
|
|
185
|
+
ratio = facts.coverage_against(requested_s)
|
|
186
|
+
if ratio is None:
|
|
187
|
+
return True # unmeasurable is not the same as fine
|
|
188
|
+
return ratio < ok
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def max_packet_gap(times: Sequence[float]) -> float | None:
|
|
192
|
+
"""Largest gap between consecutive packet timestamps, in seconds.
|
|
193
|
+
|
|
194
|
+
Advisory. Returns `None` for fewer than two packets, because a single packet has no
|
|
195
|
+
gaps and reporting `0.0` would look like a passing check.
|
|
196
|
+
"""
|
|
197
|
+
if len(times) < 2:
|
|
198
|
+
return None
|
|
199
|
+
# `pairwise`, not `zip(times, times[1:])` — the latter needs `strict=False`, and
|
|
200
|
+
# `strict=True` on it raises because the second sequence is one element shorter. That
|
|
201
|
+
# off-by-one has been written twice here; `pairwise` cannot express it.
|
|
202
|
+
return max(b - a for a, b in pairwise(sorted(times)))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _first_stream(streams: Sequence[Mapping[str, Any]], kind: str) -> Mapping[str, Any] | None:
|
|
206
|
+
for stream in streams:
|
|
207
|
+
if stream.get("codec_type") == kind:
|
|
208
|
+
return stream
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _packet_times(packets: Sequence[Mapping[str, Any]]) -> list[float]:
|
|
213
|
+
times: list[float] = []
|
|
214
|
+
for packet in packets:
|
|
215
|
+
value = _as_float(packet.get("pts_time"))
|
|
216
|
+
if value is None:
|
|
217
|
+
value = _as_float(packet.get("dts_time"))
|
|
218
|
+
if value is not None:
|
|
219
|
+
times.append(value)
|
|
220
|
+
return times
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _as_float(value: object) -> float | None:
|
|
224
|
+
if value is None:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
return float(value) # type: ignore[arg-type]
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
# ffprobe emits "N/A" for an unreadable value.
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _keyframe_interval(streams: Sequence[Mapping[str, Any]]) -> float | None:
|
|
234
|
+
"""Mean spacing between video frames, used only to sanity-check a gap reading."""
|
|
235
|
+
video = _first_stream(streams, "video")
|
|
236
|
+
if not video:
|
|
237
|
+
return None
|
|
238
|
+
rate = video.get("avg_frame_rate") or video.get("r_frame_rate")
|
|
239
|
+
if not isinstance(rate, str) or "/" not in rate:
|
|
240
|
+
return None
|
|
241
|
+
numerator, _, denominator = rate.partition("/")
|
|
242
|
+
try:
|
|
243
|
+
frames = float(numerator) / float(denominator)
|
|
244
|
+
except (ValueError, ZeroDivisionError):
|
|
245
|
+
return None
|
|
246
|
+
return None if frames <= 0 else 1.0 / frames
|