1"""Video recordings from the gateway, placed on a run's timeline.
3The video agent (daq-server/video/agent.mjs) keeps each Save and Record as a folder
4`<YYYY-MM-DD_HHMMSS>_<label>/` holding one video-only `<stream>.mp4` per stream. That
5folder is mounted read-only here (`config.VIDEO_DIR`); this module lists the clips in it,
6says which overlap a run, and resolves a clip to its file. It never writes.
8Timing. Every clip the agent has kept since timing files existed has one beside it,
9`<stream>.json` (agent.mjs, timingFor): pieces of `{fileS, wallMs}`, each saying the file
10at `fileS` seconds was filmed at `wallMs` and runs on at real speed to the next piece. The
11pieces carry the keyframe cut (the first frame is not the press) and any outage (the file
12plays straight through a gap; the wall clock does not). Such a clip is exact to the
13gateway's clock, and the file also carries `clockOffsetMs`, the gateway's clock minus the
14DAQ's as the DAQ backend measured it at the press (`clockUncertaintyMs` either way). The
15sensors are stamped on the DAQ's clock, so the offset is taken off every piece. A file
16without one (an older agent or backend) is taken to share the DAQ's clock.
18A clip with no timing file is placed by its folder name, which the agent writes in the
19gateway machine's local time to the second (`config.VIDEO_TZ`):
21- `_record`: the name is when Record was pressed, so that is the clip's start;
22- anything else (`_save`): the name is when Save was pressed, and the clip is the buffer
23 *before* it, so the clip ends there and starts one duration earlier.
25That is only approximate: the second is truncated, and the agent cuts the file at a
26keyframe, which can move the first frame by a keyframe interval. Every such clip is
27marked `approx`. A clip's duration is read from its own mp4 header (the agent writes
28with +faststart, so the header is near the front), never by decoding.
30Cost: one directory listing plus a `stat` per clip on each call; the mp4 header is read
31once per file and cached on (mtime, size). A folder still being recorded holds no mp4
32yet, and a file ffmpeg has not finished has no readable duration, so neither is listed.
35from __future__
import annotations
43from dataclasses
import dataclass
44from datetime
import datetime
45from pathlib
import Path
46from zoneinfo
import ZoneInfo
51RECORDING_RE = re.compile(
52 r"^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})_([a-z0-9-]{1,32})$", re.IGNORECASE
55STREAM_RE = re.compile(
r"^[A-Za-z0-9_-]{1,64}$")
58@dataclass(frozen=True)
67 pieces: tuple[tuple[float, float], ...] = ()
70 clock_offset: float |
None =
None
71 clock_uncertainty: float |
None =
None
72 frame: float |
None =
None
75 def end(self) -> float:
78 file_s, wall = self.
pieces[-1]
79 return wall + (self.
duration - file_s)
90 "pieces": [{
"fileS": f,
"wall": w}
for f, w
in self.
pieces]
91 or [{
"fileS": 0.0,
"wall": self.
start}],
102 """(type, payload offset, payload end) for each box in f[start:end]."""
104 while pos + 8 <= end:
109 size, kind = struct.unpack(
">I4s", head)
115 size = struct.unpack(
">Q", ext)[0]
121 yield kind, pos + hdr,
min(pos + size, end)
126 """(timescale, duration) from an mvhd/mdhd payload at `at` (both share the layout)."""
136 _ct, _mt, scale, dur = struct.unpack(
">QQIQ", raw)
141 _ct, _mt, scale, dur = struct.unpack(
">IIII", raw)
145def _child(f, a: int, b: int, kind: bytes):
146 for k, c, d
in _boxes(f, a, b):
153 """Seconds per frame of the first video track: its duration over its sample count
154 (mdhd, stsz). The kept files are video only, constant rate, so this is the step one
155 arrow press moves. None if the header does not say."""
157 with open(path,
"rb")
as f:
158 total = os.fstat(f.fileno()).st_size
159 moov =
_child(f, 0, total, b
"moov")
162 for k, a, b
in _boxes(f, *moov):
165 mdia =
_child(f, a, b, b
"mdia")
168 hdlr =
_child(f, *mdia, b
"hdlr")
172 if f.read(4) != b
"vide":
174 mdhd =
_child(f, *mdia, b
"mdhd")
175 minf =
_child(f, *mdia, b
"minf")
176 stbl =
_child(f, *minf, b
"stbl")
if minf
else None
177 stsz =
_child(f, *stbl, b
"stsz")
if stbl
else None
179 if not (times
and stsz):
185 (count,) = struct.unpack(
">I", raw)
187 return (dur / scale) / count
if scale
and count
and dur
else None
194 """Duration in seconds from the movie header (moov/mvhd), or None if the file has
195 no complete header — not an mp4, or still being written."""
197 with open(path,
"rb")
as f:
198 total = os.fstat(f.fileno()).st_size
199 for kind, a, b
in _boxes(f, 0, total):
202 mvhd =
_child(f, a, b, b
"mvhd")
207 return dur / scale
if scale
else None
218@dataclass(frozen=True)
221 pieces: tuple[tuple[float, float], ...]
222 clock_offset: float |
None
223 clock_uncertainty: float |
None
227 """A clip's timing file, on the DAQ's clock, or None if there is none or it is not one
228 this viewer understands — then the clip falls back to its name."""
230 t = json.loads(path.read_text(encoding=
"utf-8"))
231 if t.get(
"version") != TIMING_VERSION:
233 dur = float(t[
"durationS"])
234 off = t.get(
"clockOffsetMs")
235 unc = t.get(
"clockUncertaintyMs")
236 off = float(off) / 1000.0
if off
is not None else None
237 unc = float(unc) / 1000.0
if unc
is not None else None
238 if off
is not None and not math.isfinite(off):
242 (float(p[
"fileS"]), float(p[
"wallMs"]) / 1000.0 - shift)
for p
in t[
"pieces"]
244 except (OSError, ValueError, KeyError, TypeError, AttributeError):
246 if not pieces
or dur <= 0
or pieces[0][0] != 0:
248 if any(b[0] <= a[0]
for a, b
in zip(pieces, pieces[1:]))
or pieces[-1][0] >= dur:
250 return Timing(dur, pieces, off, unc)
255_dur_cache: dict[str, tuple[int, int, float, float |
None]] = {}
256_timing_cache: dict[str, tuple[int, int, object]] = {}
257_lock = threading.Lock()
260def _header_cached(path: Path, st: os.stat_result) -> tuple[float, float |
None] |
None:
261 """(duration, seconds per frame) from the file's header, read once per version."""
264 hit = _dur_cache.get(key)
265 if hit
and hit[0] == st.st_mtime_ns
and hit[1] == st.st_size:
266 return hit[2], hit[3]
268 if d
is None or d <= 0:
272 _dur_cache[key] = (st.st_mtime_ns, st.st_size, d, fr)
283 hit = _timing_cache.get(key)
284 if hit
and hit[0] == st.st_mtime_ns
and hit[1] == st.st_size:
288 _timing_cache[key] = (st.st_mtime_ns, st.st_size, t)
293 """Epoch seconds of a recording folder's name, read in the gateway's time zone."""
294 m = RECORDING_RE.match(name)
297 y, mo, d, h, mi, s = (int(x)
for x
in m.groups()[:6])
299 return datetime(y, mo, d, h, mi, s, tzinfo=ZoneInfo(tz)).timestamp()
305 return config.VIDEO_DIR
is not None and config.VIDEO_DIR.is_dir()
309 """Every finished clip in the recordings folder."""
312 root = config.VIDEO_DIR
313 assert root
is not None
314 clips: list[Clip] = []
316 folders = list(os.scandir(root))
319 for folder
in folders:
320 m = RECORDING_RE.match(folder.name)
321 if not m
or not folder.is_dir():
326 kind = m.group(7).lower()
328 entries = list(os.scandir(folder.path))
332 if not e.name.endswith(
".mp4"):
335 if not STREAM_RE.match(stream):
346 if timing
is not None:
349 folder.name, kind, stream, timing.pieces[0][1], timing.duration,
False,
350 timing.pieces, timing.clock_offset, timing.clock_uncertainty, frame,
354 start = named
if kind ==
"record" else named - dur
355 clips.append(
Clip(folder.name, kind, stream, start, dur, approx=
True, frame=frame))
356 clips.sort(key=
lambda c: (c.start, c.stream))
361 """Clips any part of which falls inside [t0, t1]."""
362 return [c
for c
in list_clips()
if c.start <= t1
and c.end >= t0]
365def clip_file(recording: str, stream: str) -> Path |
None:
366 """The file for a clip this module would list, else None. Nothing from the request
367 reaches the filesystem until both names have passed the listing's own patterns."""
368 if not available()
or not RECORDING_RE.match(recording)
or not STREAM_RE.match(stream):
371 if c.recording == recording
and c.stream == stream:
372 assert config.VIDEO_DIR
is not None
373 return config.VIDEO_DIR / recording / f
"{stream}.mp4"
tuple[int, int]|None _full_box_times(f, int at)
tuple[float, float|None]|None _header_cached(Path path, os.stat_result st)
_boxes(f, int start, int end)
Path|None clip_file(str recording, str stream)
float|None folder_time(str name, str tz)
Timing|None read_timing(Path path)
float|None mp4_frame_seconds(Path path)
float|None mp4_duration(Path path)
_timing_cached(Path path)
list[Clip] clips_overlapping(float t0, float t1)
_child(f, int a, int b, bytes kind)