STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
video.py
Go to the documentation of this file.
1"""Video recordings from the gateway, placed on a run's timeline.
2
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.
7
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.
17
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`):
20
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.
24
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.
29
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.
33"""
34
35from __future__ import annotations
36
37import json
38import math
39import os
40import re
41import struct
42import threading
43from dataclasses import dataclass
44from datetime import datetime
45from pathlib import Path
46from zoneinfo import ZoneInfo
47
48from . import config
49
50# The agent's RECORDING_NAME_RE, verbatim.
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
53)
54# A stream id as a file name: no dots, no separators, so it can never leave the folder.
55STREAM_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
56
57
58@dataclass(frozen=True)
59class Clip:
60 recording: str # folder name
61 kind: str # "record", "save", or another label
62 stream: str
63 start: float # epoch seconds, first frame
64 duration: float # seconds
65 approx: bool # timing from the folder name only
66 # (file seconds, wall epoch seconds) per stretch; one piece for an approximate clip.
67 pieces: tuple[tuple[float, float], ...] = ()
68 # Gateway clock minus DAQ clock (seconds), already taken off `start` and `pieces`;
69 # None when not measured. Reported so the page can say what was corrected.
70 clock_offset: float | None = None
71 clock_uncertainty: float | None = None
72 frame: float | None = None # seconds per frame, from the file's header
73
74 @property
75 def end(self) -> float:
76 if not self.pieces:
77 return self.start + self.duration
78 file_s, wall = self.pieces[-1]
79 return wall + (self.duration - file_s)
80
81 def as_json(self) -> dict:
82 return {
83 "recording": self.recording,
84 "kind": self.kind,
85 "stream": self.stream,
86 "start": self.start,
87 "end": self.end,
88 "duration": self.duration,
89 "approx": self.approx,
90 "pieces": [{"fileS": f, "wall": w} for f, w in self.pieces]
91 or [{"fileS": 0.0, "wall": self.start}],
92 "clockOffset": self.clock_offset,
93 "clockUncertainty": self.clock_uncertainty,
94 "frameS": self.frame,
95 }
96
97
98# ── mp4 duration ─────────────────────────────────────────────────────────────
99
100
101def _boxes(f, start: int, end: int):
102 """(type, payload offset, payload end) for each box in f[start:end]."""
103 pos = start
104 while pos + 8 <= end:
105 f.seek(pos)
106 head = f.read(8)
107 if len(head) < 8:
108 return
109 size, kind = struct.unpack(">I4s", head)
110 hdr = 8
111 if size == 1:
112 ext = f.read(8)
113 if len(ext) < 8:
114 return
115 size = struct.unpack(">Q", ext)[0]
116 hdr = 16
117 elif size == 0:
118 size = end - pos
119 if size < hdr:
120 return
121 yield kind, pos + hdr, min(pos + size, end)
122 pos += size
123
124
125def _full_box_times(f, at: int) -> tuple[int, int] | None:
126 """(timescale, duration) from an mvhd/mdhd payload at `at` (both share the layout)."""
127 f.seek(at)
128 version = f.read(1)
129 if not version:
130 return None
131 f.read(3) # flags
132 if version[0] == 1:
133 raw = f.read(28)
134 if len(raw) < 28:
135 return None
136 _ct, _mt, scale, dur = struct.unpack(">QQIQ", raw)
137 else:
138 raw = f.read(16)
139 if len(raw) < 16:
140 return None
141 _ct, _mt, scale, dur = struct.unpack(">IIII", raw)
142 return scale, dur
143
144
145def _child(f, a: int, b: int, kind: bytes):
146 for k, c, d in _boxes(f, a, b):
147 if k == kind:
148 return c, d
149 return None
150
151
152def mp4_frame_seconds(path: Path) -> float | None:
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."""
156 try:
157 with open(path, "rb") as f:
158 total = os.fstat(f.fileno()).st_size
159 moov = _child(f, 0, total, b"moov")
160 if not moov:
161 return None
162 for k, a, b in _boxes(f, *moov):
163 if k != b"trak":
164 continue
165 mdia = _child(f, a, b, b"mdia")
166 if not mdia:
167 continue
168 hdlr = _child(f, *mdia, b"hdlr")
169 if not hdlr:
170 continue
171 f.seek(hdlr[0] + 8) # version/flags, pre_defined
172 if f.read(4) != b"vide":
173 continue
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
178 times = _full_box_times(f, mdhd[0]) if mdhd else None
179 if not (times and stsz):
180 return None
181 f.seek(stsz[0] + 8) # version/flags, sample_size
182 raw = f.read(4)
183 if len(raw) < 4:
184 return None
185 (count,) = struct.unpack(">I", raw)
186 scale, dur = times
187 return (dur / scale) / count if scale and count and dur else None
188 except OSError:
189 return None
190 return None
191
192
193def mp4_duration(path: Path) -> float | 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."""
196 try:
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):
200 if kind != b"moov":
201 continue
202 mvhd = _child(f, a, b, b"mvhd")
203 times = _full_box_times(f, mvhd[0]) if mvhd else None
204 if not times:
205 return None
206 scale, dur = times
207 return dur / scale if scale else None
208 except OSError:
209 return None
210 return None
211
212
213# ── Timing file ──────────────────────────────────────────────────────────────
214
215TIMING_VERSION = 1
216
217
218@dataclass(frozen=True)
219class Timing:
220 duration: float
221 pieces: tuple[tuple[float, float], ...] # (file s, DAQ-clock epoch s)
222 clock_offset: float | None
223 clock_uncertainty: float | None
224
225
226def read_timing(path: Path) -> Timing | 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."""
229 try:
230 t = json.loads(path.read_text(encoding="utf-8"))
231 if t.get("version") != TIMING_VERSION:
232 return None
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):
239 return None
240 shift = off or 0.0
241 pieces = tuple(
242 (float(p["fileS"]), float(p["wallMs"]) / 1000.0 - shift) for p in t["pieces"]
243 )
244 except (OSError, ValueError, KeyError, TypeError, AttributeError):
245 return None
246 if not pieces or dur <= 0 or pieces[0][0] != 0:
247 return None
248 if any(b[0] <= a[0] for a, b in zip(pieces, pieces[1:])) or pieces[-1][0] >= dur:
249 return None
250 return Timing(dur, pieces, off, unc)
251
252
253# ── Listing ──────────────────────────────────────────────────────────────────
254
255_dur_cache: dict[str, tuple[int, int, float, float | None]] = {}
256_timing_cache: dict[str, tuple[int, int, object]] = {}
257_lock = threading.Lock()
258
259
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."""
262 key = str(path)
263 with _lock:
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]
267 d = mp4_duration(path)
268 if d is None or d <= 0:
269 return None
270 fr = mp4_frame_seconds(path)
271 with _lock:
272 _dur_cache[key] = (st.st_mtime_ns, st.st_size, d, fr)
273 return d, fr
274
275
276def _timing_cached(path: Path):
277 try:
278 st = path.stat()
279 except OSError:
280 return None
281 key = str(path)
282 with _lock:
283 hit = _timing_cache.get(key)
284 if hit and hit[0] == st.st_mtime_ns and hit[1] == st.st_size:
285 return hit[2]
286 t = read_timing(path)
287 with _lock:
288 _timing_cache[key] = (st.st_mtime_ns, st.st_size, t)
289 return t
290
291
292def folder_time(name: str, tz: str) -> float | None:
293 """Epoch seconds of a recording folder's name, read in the gateway's time zone."""
294 m = RECORDING_RE.match(name)
295 if not m:
296 return None
297 y, mo, d, h, mi, s = (int(x) for x in m.groups()[:6])
298 try:
299 return datetime(y, mo, d, h, mi, s, tzinfo=ZoneInfo(tz)).timestamp()
300 except ValueError:
301 return None
302
303
304def available() -> bool:
305 return config.VIDEO_DIR is not None and config.VIDEO_DIR.is_dir()
306
307
308def list_clips() -> list[Clip]:
309 """Every finished clip in the recordings folder."""
310 if not available():
311 return []
312 root = config.VIDEO_DIR
313 assert root is not None
314 clips: list[Clip] = []
315 try:
316 folders = list(os.scandir(root))
317 except OSError:
318 return []
319 for folder in folders:
320 m = RECORDING_RE.match(folder.name)
321 if not m or not folder.is_dir():
322 continue
323 named = folder_time(folder.name, config.VIDEO_TZ)
324 if named is None:
325 continue
326 kind = m.group(7).lower()
327 try:
328 entries = list(os.scandir(folder.path))
329 except OSError:
330 continue
331 for e in entries:
332 if not e.name.endswith(".mp4"):
333 continue
334 stream = e.name[:-4]
335 if not STREAM_RE.match(stream):
336 continue
337 try:
338 st = e.stat()
339 except OSError:
340 continue
341 header = _header_cached(Path(e.path), st)
342 if header is None:
343 continue
344 dur, frame = header
345 timing = _timing_cached(Path(e.path[:-4] + ".json"))
346 if timing is not None:
347 clips.append(
348 Clip(
349 folder.name, kind, stream, timing.pieces[0][1], timing.duration, False,
350 timing.pieces, timing.clock_offset, timing.clock_uncertainty, frame,
351 )
352 )
353 continue
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))
357 return clips
358
359
360def clips_overlapping(t0: float, t1: float) -> list[Clip]:
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]
363
364
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):
369 return None
370 for c in list_clips():
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"
374 return None
#define min(a, b)
Definition Arduino.h:42
float clock_uncertainty
Definition video.py:71
dict as_json(self)
Definition video.py:81
float clock_offset
Definition video.py:70
tuple[int, int]|None _full_box_times(f, int at)
Definition video.py:125
tuple[float, float|None]|None _header_cached(Path path, os.stat_result st)
Definition video.py:260
_boxes(f, int start, int end)
Definition video.py:101
Path|None clip_file(str recording, str stream)
Definition video.py:365
list[Clip] list_clips()
Definition video.py:308
bool available()
Definition video.py:304
float|None folder_time(str name, str tz)
Definition video.py:292
Timing|None read_timing(Path path)
Definition video.py:226
float|None mp4_frame_seconds(Path path)
Definition video.py:152
float|None mp4_duration(Path path)
Definition video.py:193
_timing_cached(Path path)
Definition video.py:276
list[Clip] clips_overlapping(float t0, float t1)
Definition video.py:360
_child(f, int a, int b, bytes kind)
Definition video.py:145