STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_video.py
Go to the documentation of this file.
1"""Video clips on a run's timeline: listing, matching, and serving.
2
3Run: cd webviewer && .venv/bin/python -m pytest backend/test_video.py -q
4"""
5
6import json
7import struct
8from datetime import datetime
9from zoneinfo import ZoneInfo
10
11import pytest
12from fastapi.testclient import TestClient
13
14from . import config, export_cache, main, video
15
16TZ = "America/Los_Angeles"
17
18
19def box(kind: bytes, payload: bytes) -> bytes:
20 return struct.pack(">I4s", 8 + len(payload), kind) + payload
21
22
23def mp4(seconds: float, version: int = 0, scale: int = 1000, tail: bytes = b"") -> bytes:
24 """A file with just enough mp4 for a duration: ftyp, moov/mvhd, then some 'mdat'."""
25 if version == 1:
26 mvhd = bytes([1, 0, 0, 0]) + struct.pack(">QQIQ", 0, 0, scale, int(seconds * scale))
27 else:
28 mvhd = bytes([0, 0, 0, 0]) + struct.pack(">IIII", 0, 0, scale, int(seconds * scale))
29 mvhd += bytes(80) # rate, volume, matrix, ... (unread)
30 return box(b"ftyp", b"isom" + bytes(4)) + box(b"moov", box(b"mvhd", mvhd)) + box(b"mdat", tail or bytes(64))
31
32
33def epoch(y, mo, d, h, mi, s) -> float:
34 return datetime(y, mo, d, h, mi, s, tzinfo=ZoneInfo(TZ)).timestamp()
35
36
37@pytest.fixture
38def rec_dir(tmp_path, monkeypatch):
39 monkeypatch.setattr(config, "VIDEO_DIR", tmp_path)
40 monkeypatch.setattr(config, "VIDEO_TZ", TZ)
41 video._dur_cache.clear()
42 video._timing_cache.clear()
43 return tmp_path
44
45
46def put(rec_dir, folder: str, stream: str, seconds: float) -> None:
47 (rec_dir / folder).mkdir(exist_ok=True)
48 (rec_dir / folder / f"{stream}.mp4").write_bytes(mp4(seconds))
49
50
51# ── mp4 header ────────────────────────────────────────────────────────────────
52
53
55 (tmp_path / "a.mp4").write_bytes(mp4(12.5))
56 (tmp_path / "b.mp4").write_bytes(mp4(7.25, version=1, scale=90000))
57 assert video.mp4_duration(tmp_path / "a.mp4") == pytest.approx(12.5)
58 assert video.mp4_duration(tmp_path / "b.mp4") == pytest.approx(7.25)
59
60
62 whole = mp4(10)
63 (tmp_path / "cut.mp4").write_bytes(whole[:30]) # ffmpeg still writing
64 (tmp_path / "text.mp4").write_bytes(b"not a movie at all")
65 (tmp_path / "nomoov.mp4").write_bytes(box(b"ftyp", b"isom" + bytes(4)) + box(b"mdat", bytes(32)))
66 for name in ("cut.mp4", "text.mp4", "nomoov.mp4"):
67 assert video.mp4_duration(tmp_path / name) is None
68
69
70def trak(handler: bytes, scale: int, dur: int, samples: int) -> bytes:
71 mdhd = bytes([0, 0, 0, 0]) + struct.pack(">IIII", 0, 0, scale, dur) + bytes(4)
72 hdlr = bytes(4) + bytes(4) + handler + bytes(12) + b"\0"
73 stsz = bytes(4) + struct.pack(">II", 0, samples)
74 stbl = box(b"stbl", box(b"stsz", stsz))
75 return box(b"trak", box(b"mdia", box(b"mdhd", mdhd) + box(b"hdlr", hdlr) + box(b"minf", stbl)))
76
77
79 mvhd = bytes([0, 0, 0, 0]) + struct.pack(">IIII", 0, 0, 1000, 10000) + bytes(80)
80 # An audio track first, so the video one has to be found, not assumed.
81 moov = box(b"moov", box(b"mvhd", mvhd) + trak(b"soun", 8000, 80000, 500) + trak(b"vide", 90000, 900000, 250))
82 (tmp_path / "v.mp4").write_bytes(box(b"ftyp", b"isom" + bytes(4)) + moov)
83 assert video.mp4_frame_seconds(tmp_path / "v.mp4") == pytest.approx(1 / 25)
84 # No video track, or no header at all: nothing to step by.
85 (tmp_path / "a.mp4").write_bytes(box(b"moov", box(b"mvhd", mvhd) + trak(b"soun", 8000, 80000, 500)))
86 assert video.mp4_frame_seconds(tmp_path / "a.mp4") is None
87 (tmp_path / "x.mp4").write_bytes(mp4(10))
88 assert video.mp4_frame_seconds(tmp_path / "x.mp4") is None
89
90
91# ── Timing from the folder name ──────────────────────────────────────────────
92
93
95 put(rec_dir, "2026-11-07_140322_record", "cam1", 30)
96 put(rec_dir, "2026-11-07_141000_save", "cam1", 300)
97 by_kind = {c.kind: c for c in video.list_clips()}
98 assert by_kind["record"].start == epoch(2026, 11, 7, 14, 3, 22)
99 assert by_kind["record"].end == epoch(2026, 11, 7, 14, 3, 52)
100 # A Save keeps the buffer *before* the press.
101 assert by_kind["save"].end == epoch(2026, 11, 7, 14, 10, 0)
102 assert by_kind["save"].start == epoch(2026, 11, 7, 14, 5, 0)
103 assert all(c.approx for c in by_kind.values())
104
105
107 put(rec_dir, "2026-07-01_120000_record", "cam1", 5)
108 (clip,) = video.list_clips()
109 # 12:00 PDT is 19:00 UTC.
110 assert clip.start == datetime(2026, 7, 1, 19, 0, 0, tzinfo=ZoneInfo("UTC")).timestamp()
111
112
114 put(rec_dir, "2026-11-07_140322_record", "cam1", 30)
115 (rec_dir / "2026-11-07_140322_record" / ".cam2").mkdir() # work dir mid-recording
116 (rec_dir / "2026-11-07_140322_record" / "half.mp4").write_bytes(mp4(10)[:20])
117 (rec_dir / "2026-11-07_140322_record" / "bad.name.mp4").write_bytes(mp4(10))
118 (rec_dir / "random-folder").mkdir()
119 (rec_dir / "random-folder" / "cam1.mp4").write_bytes(mp4(10))
120 assert [(c.recording, c.stream) for c in video.list_clips()] == [
121 ("2026-11-07_140322_record", "cam1")
122 ]
123
124
125def test_no_mount_means_no_clips(monkeypatch, tmp_path):
126 monkeypatch.setattr(config, "VIDEO_DIR", None)
127 assert not video.available() and video.list_clips() == []
128 monkeypatch.setattr(config, "VIDEO_DIR", tmp_path / "missing")
129 assert not video.available() and video.list_clips() == []
130
131
132# ── Timing files ─────────────────────────────────────────────────────────────
133
134
135def put_timing(rec_dir, folder, stream, timing) -> None:
136 (rec_dir / folder / f"{stream}.json").write_text(json.dumps(timing))
137
138
140 folder = "2026-11-07_140322_record"
141 put(rec_dir, folder, "cam1", 30)
142 t0 = epoch(2026, 11, 7, 14, 3, 21) + 0.6 # keyframe before the press
143 put_timing(rec_dir, folder, "cam1", {
144 "version": 1, "durationS": 30, "latencyMs": 0,
145 # 20 s of footage, a 32 s outage, then 10 s more
146 "pieces": [{"fileS": 0, "wallMs": t0 * 1000}, {"fileS": 20, "wallMs": (t0 + 52) * 1000}],
147 })
148 (clip,) = video.list_clips()
149 assert not clip.approx
150 assert clip.start == pytest.approx(t0, abs=1e-3)
151 assert clip.end == pytest.approx(t0 + 62, abs=1e-3) # not t0 + 30: the gap is real time
152 assert clip.as_json()["pieces"] == [
153 {"fileS": 0.0, "wall": pytest.approx(t0, abs=1e-3)}, {"fileS": 20.0, "wall": pytest.approx(t0 + 52, abs=1e-3)}
154 ]
155
156
158 folder = "2026-11-07_140322_record"
159 put(rec_dir, folder, "cam1", 30)
160 g0 = epoch(2026, 11, 7, 14, 3, 21) # first frame, by the gateway's clock
161 put_timing(rec_dir, folder, "cam1", {
162 "version": 1, "durationS": 30,
163 # The gateway runs 2.5 s ahead of the DAQ: its 14:03:21 is the sensors' 14:03:18.5.
164 "clockOffsetMs": 2500, "clockUncertaintyMs": 3,
165 "pieces": [{"fileS": 0, "wallMs": g0 * 1000}, {"fileS": 10, "wallMs": (g0 + 10) * 1000}],
166 })
167 (clip,) = video.list_clips()
168 assert clip.start == pytest.approx(g0 - 2.5, abs=1e-3)
169 assert clip.pieces[1][1] == pytest.approx(g0 + 7.5, abs=1e-3)
170 assert clip.end == pytest.approx(g0 + 27.5, abs=1e-3)
171 j = clip.as_json()
172 assert (j["clockOffset"], j["clockUncertainty"]) == (2.5, 0.003)
173
174
176 folder = "2026-11-07_140322_record"
177 put(rec_dir, folder, "cam1", 30)
178 g0 = epoch(2026, 11, 7, 14, 3, 21)
179 put_timing(rec_dir, folder, "cam1", {"version": 1, "durationS": 30, "pieces": [{"fileS": 0, "wallMs": g0 * 1000}]})
180 (clip,) = video.list_clips()
181 assert clip.start == pytest.approx(g0, abs=1e-3) and clip.clock_offset is None
182
183
184@pytest.mark.parametrize("bad", [
185 "not json",
186 {"version": 2, "durationS": 30, "pieces": [{"fileS": 0, "wallMs": 1}]},
187 {"version": 1, "durationS": 30, "pieces": []},
188 {"version": 1, "durationS": 30, "pieces": [{"fileS": 5, "wallMs": 1}]},
189 {"version": 1, "durationS": 30, "pieces": [{"fileS": 0, "wallMs": 1}, {"fileS": 0, "wallMs": 2}]},
190 {"version": 1, "durationS": 30, "pieces": [{"fileS": 0, "wallMs": 1}, {"fileS": 31, "wallMs": 2}]},
191 {"version": 1, "pieces": [{"fileS": 0, "wallMs": 1}]},
192])
194 folder = "2026-11-07_140322_record"
195 put(rec_dir, folder, "cam1", 30)
196 (rec_dir / folder / "cam1.json").write_text(bad if isinstance(bad, str) else json.dumps(bad))
197 (clip,) = video.list_clips()
198 assert clip.approx and clip.start == epoch(2026, 11, 7, 14, 3, 22)
199
200
202 put(rec_dir, "2026-11-07_140322_record", "cam1", 30)
203 (clip,) = video.list_clips()
204 assert clip.as_json()["pieces"] == [{"fileS": 0.0, "wall": clip.start}]
205
206
207# ── Matching a run ───────────────────────────────────────────────────────────
208
209
211 put(rec_dir, "2026-11-07_140000_record", "before", 10) # 14:00:00-14:00:10
212 put(rec_dir, "2026-11-07_140055_record", "straddle", 10) # 14:00:55-14:01:05
213 put(rec_dir, "2026-11-07_140130_record", "inside", 10)
214 put(rec_dir, "2026-11-07_140500_save", "after-save", 60) # 14:04:00-14:05:00
215 put(rec_dir, "2026-11-07_150000_record", "later", 10)
216 t0, t1 = epoch(2026, 11, 7, 14, 1, 0), epoch(2026, 11, 7, 14, 4, 30)
217 got = sorted(c.stream for c in video.clips_overlapping(t0, t1))
218 assert got == ["after-save", "inside", "straddle"]
219
220
221# ── HTTP ─────────────────────────────────────────────────────────────────────
222
223
224@pytest.fixture
225def client(rec_dir, monkeypatch):
226 run = "daq_20261107_140000"
227 t0, t1 = epoch(2026, 11, 7, 14, 1, 0), epoch(2026, 11, 7, 14, 4, 30)
228 monkeypatch.setattr(export_cache, "is_cached", lambda r: r == run)
229 monkeypatch.setattr(
230 export_cache,
231 "get_index",
232 lambda r: {"t_min": t0, "t_max": t1, "sensor_t_min": t0 + 0.5, "sensor_t_max": t1 - 0.5},
233 )
234 return TestClient(main.app), run
235
236
238 c, run = client
239 put(rec_dir, "2026-11-07_140130_record", "cam1", 10)
240 put(rec_dir, "2026-11-07_150000_record", "cam1", 10)
241 r = c.get(f"/api/runs/{run}/video")
242 assert r.status_code == 200
243 body = r.json()
244 assert body["available"] is True
245 assert [(x["recording"], x["stream"]) for x in body["clips"]] == [("2026-11-07_140130_record", "cam1")]
246 assert body["clips"][0]["end"] - body["clips"][0]["start"] == pytest.approx(10)
247
248
250 c, run = client
251 monkeypatch.setattr(config, "VIDEO_DIR", None)
252 assert c.get(f"/api/runs/{run}/video").json() == {"available": False, "clips": []}
253
254
256 c, _ = client
257 assert c.get("/api/runs/daq_20261107_150000/video").status_code == 409
258
259
260def test_clip_served_with_range(client, rec_dir):
261 c, _ = client
262 folder = "2026-11-07_140130_record"
263 (rec_dir / folder).mkdir()
264 data = mp4(10, tail=bytes(range(256)) * 40)
265 (rec_dir / folder / "cam1.mp4").write_bytes(data)
266 r = c.get(f"/api/video/{folder}/cam1.mp4", headers={"Range": "bytes=100-199"})
267 assert r.status_code == 206
268 assert r.content == data[100:200]
269 assert r.headers["content-type"] == "video/mp4"
270 assert c.get(f"/api/video/{folder}/cam1.mp4").content == data
271
272
274 # Cloudflare caches .mp4 by extension, and on a miss it answers a Range by pulling
275 # the whole file first: a seek near the end of a fresh clip waited ~25 s. `private`
276 # sends every Range straight here, on the full response and the partial one alike.
277 c, _ = client
278 put(rec_dir, "2026-11-07_140130_record", "cam1", 10)
279 url = "/api/video/2026-11-07_140130_record/cam1.mp4"
280 for r in (c.get(url), c.get(url, headers={"Range": "bytes=0-9"})):
281 directives = [d.strip() for d in r.headers["cache-control"].split(",")]
282 assert "private" in directives
283
284
285@pytest.mark.parametrize(
286 "path",
287 [
288 "/api/video/2026-11-07_140130_record/cam2.mp4", # not a listed clip
289 "/api/video/not-a-recording/cam1.mp4",
290 "/api/video/2026-11-07_140130_record/..%2F..%2Fsecret.mp4",
291 "/api/video/..%2Fetc/passwd.mp4",
292 ],
293)
294def test_clip_refuses_anything_not_listed(client, rec_dir, path):
295 c, _ = client
296 put(rec_dir, "2026-11-07_140130_record", "cam1", 10)
297 (rec_dir / "secret.mp4").write_bytes(mp4(10))
298 assert c.get(path).status_code == 404
a class to store JSON values
Definition json.hpp:19401
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
test_gateway_clock_offset_is_taken_off_so_clips_sit_on_the_daq_clock(rec_dir)
test_duration_none_for_unfinished_or_foreign_files(tmp_path)
Definition test_video.py:61
float epoch(y, mo, d, h, mi, s)
Definition test_video.py:33
test_listing_skips_what_is_not_a_finished_clip(rec_dir)
test_frame_period_from_the_video_track(tmp_path)
Definition test_video.py:78
test_timing_file_places_the_clip_exactly_and_spans_its_gap(rec_dir)
rec_dir(tmp_path, monkeypatch)
Definition test_video.py:38
bytes mp4(float seconds, int version=0, int scale=1000, bytes tail=b"")
Definition test_video.py:23
None put_timing(rec_dir, folder, stream, timing)
test_run_video_unindexed_is_409(client)
test_no_measured_offset_means_the_clocks_are_taken_as_equal(rec_dir)
None put(rec_dir, str folder, str stream, float seconds)
Definition test_video.py:46
test_clip_is_not_stored_by_a_shared_cache(client, rec_dir)
bytes trak(bytes handler, int scale, int dur, int samples)
Definition test_video.py:70
test_approximate_clip_serves_one_piece(rec_dir)
test_overlap_keeps_partial_clips_and_drops_outside_ones(rec_dir)
test_duration_from_mvhd_v0_and_v1(tmp_path)
Definition test_video.py:54
client(rec_dir, monkeypatch)
test_clip_served_with_range(client, rec_dir)
test_run_video_lists_overlapping_clips(client, rec_dir)
test_no_mount_means_no_clips(monkeypatch, tmp_path)
bytes box(bytes kind, bytes payload)
Definition test_video.py:19
test_clip_refuses_anything_not_listed(client, rec_dir, path)
test_record_starts_at_its_name_save_ends_at_its_name(rec_dir)
Definition test_video.py:94
test_folder_name_is_read_in_the_gateway_zone_not_utc(rec_dir)
test_a_timing_file_that_does_not_parse_falls_back_to_the_name(rec_dir, bad)
test_run_video_without_mount_says_unavailable(client, monkeypatch)