diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-17 15:50:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-17 15:50:55 +0200 |
| commit | 3dcce17f9c616c8528a93bc625eb462d7fffb8f9 (patch) | |
| tree | 3fcd64d9e0b5bef9e917052f8269907325dbc350 /packages/meshbay-node/tests/test_stream_seek_reports_the_keyframe.py | |
| parent | d472725167c1d335996059d24b7b74f3730768fb (diff) | |
| download | meshbay-3dcce17f9c616c8528a93bc625eb462d7fffb8f9.tar.gz | |
fix(node): a seek reports where the picture begins, not where it was asked
Copied video can only start on a keyframe, so `-ss t` with `-c:v copy`
delivers the keyframe at or before `t`. The node reported `t` anyway and the
client builds `SourceBuffer.timestampOffset` out of that number, so everything
downstream believed the picture stood a few seconds further along than it did.
That was a wrong label while only the scrubber read it — it is recorded as
such, low priority, in the design document. Subtitles made it a wrong answer:
their cues carry the source's own absolute timestamps, so the mismatch put
every line on screen before it was spoken. Reported from real use on an H264
title, where seeking to 600 s, 2650 s and 5000 s lands on keyframes 0.82 s,
1.56 s and 4.64 s earlier.
The copy path now resolves the request to that keyframe, seeks to it, and
reports it. The bytes delivered do not change — ffmpeg lands on the same frame
either way — only the number that names them. The look-up reads the thirty
seconds before the request and cost 0.12–0.51 s on that title, which is the
price already costed in §15.3 and never paid. Re-encoded video is untouched:
it can begin exactly where it is asked to, and does.
Two details worth their lines. The keyframe is passed back to ffmpeg at six
decimals, because rounding a keyframe's own timestamp down puts it before the
frame it names and selects the previous one — the same fault again, smaller.
And the ffprobe window ends past the request, since an interval whose end is
the request never emits a keyframe sitting exactly on it: the resume position
is the one place a viewer asks for the same instant twice, and it would have
been answered a whole GOP early.
The test decodes the first frame served and matches it against the source
frame at the position announced, rather than comparing `start` to an expected
number — both sides of that comparison would be reading the same ffprobe and
would agree by construction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
Diffstat (limited to 'packages/meshbay-node/tests/test_stream_seek_reports_the_keyframe.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_stream_seek_reports_the_keyframe.py | 181 |
1 files changed, 181 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_stream_seek_reports_the_keyframe.py b/packages/meshbay-node/tests/test_stream_seek_reports_the_keyframe.py new file mode 100644 index 0000000..acd2ebb --- /dev/null +++ b/packages/meshbay-node/tests/test_stream_seek_reports_the_keyframe.py @@ -0,0 +1,181 @@ +""" +A seek on a copied stream reports where the picture actually begins. + +Copied video has to start on a keyframe, so `-ss t` with `-c:v copy` delivers +the keyframe at or before `t` — up to a whole GOP earlier. The node reported +`t` regardless, and the client sets `SourceBuffer.timestampOffset` from that +number, so everything downstream believed the picture stood a few seconds +further along than it did. + +That was a wrong label while only the scrubber read it. It became a wrong +*answer* when subtitles arrived: their cues carry the source's own absolute +timestamps, so the mismatch put every line on screen a GOP before it was +spoken. Measured on a real H264 title, seeking to 600 s, 2650 s and 5000 s +landed on keyframes 0.82 s, 1.56 s and 4.64 s earlier. + +**The assertion is on the decoded picture, not on the number.** A test that +only compared `stream_init["start"]` against an expected keyframe would agree +with the implementation by construction — both would be reading the same +ffprobe. The first frame delivered is decoded and matched against the source +frame at the position the node claims, which is a statement about what was +served rather than about what was computed. +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.crypto import generate_gek +from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import ( + WebRTCPeerSession, + _keyframe_at_or_before, +) + +from conftest import needs_subprocess, one_root + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") +pytestmark = [ + pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed"), + needs_subprocess, +] + +# 25 fps with a keyframe every 250 frames — one every 10 s, which is what a +# real WEB-DL looks like and what makes the gap large enough to see. +_GOP_SECONDS = 10 +_SEEK_TO = 15.0 # between the keyframes at 10 s and 20 s +_EXPECTED_KEYFRAME = 10.0 + + +def _make_h264_clip(path: Path) -> None: + """30 s of H264 the streaming path will copy rather than re-encode. + + The picture has to differ from one second to the next, or a frame from the + keyframe and a frame from the requested position would compare equal and + the test would pass against the bug it exists for. + """ + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=30", + "-f", "lavfi", "-i", "sine=duration=30", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-g", str(_GOP_SECONDS * 25), "-keyint_min", str(_GOP_SECONDS * 25), + "-sc_threshold", "0", + "-c:a", "aac", "-shortest", str(path)], + check=True, capture_output=True) + + +def _session(video_path: Path, gek: bytes): + import blake3 + file_bytes = video_path.read_bytes() + file_id = blake3.blake3(file_bytes).hexdigest() + + sk_node = Ed25519PrivateKey.generate() + index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek) + from meshbay_common.protocol import IndexEntry + index.add_entry(IndexEntry( + id=file_id, name=video_path.name, path=video_path.parent.name, + size=len(file_bytes), type="video", added_at=0)) + + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "roots": one_root(video_path.parent), + "index": index, + "gek": gek, + "sk_node": sk_node, + "max_concurrent_streams": 4, + } + session._group_id = None + session._user_id = "tester" + session._stream_stopped = False + session._stream_keepalives = 0 + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session, file_id + + +def _reassemble(sent: list[dict], gek: bytes, file_id: str) -> bytes: + file_hash = bytes.fromhex(file_id) + out = b"" + for m in sorted((m for m in sent if m.get("type") == "stream_data"), + key=lambda m: m["segment_index"]): + key = chunk_key_aes(gek, file_hash, m["segment_index"]) + out += decrypt_chunk_aes(key, m["nonce"], m["ct"]) + return out + + +def _frame_md5(path: Path, at: float | None = None) -> str: + """One decoded frame as raw pixels, hashed. `at` None means the first.""" + args = ["ffmpeg", "-hide_banner", "-loglevel", "error"] + if at is not None: + args += ["-ss", f"{at:.6f}"] + args += ["-i", str(path), "-frames:v", "1", "-f", "rawvideo", + "-pix_fmt", "rgb24", "-"] + proc = subprocess.run(args, check=True, capture_output=True) + import hashlib + return hashlib.md5(proc.stdout).hexdigest() + + +@pytest.mark.asyncio +async def test_the_lookup_finds_the_keyframe_the_seek_will_land_on(tmp_path): + clip = tmp_path / "clip.mp4" + _make_h264_clip(clip) + + assert await _keyframe_at_or_before(clip, _SEEK_TO) == pytest.approx( + _EXPECTED_KEYFRAME, abs=0.05) + # A position that *is* a keyframe answers itself, not the one before. + assert await _keyframe_at_or_before(clip, 20.0) == pytest.approx(20.0, abs=0.05) + # Before the first one there is nothing earlier to find. + assert await _keyframe_at_or_before(clip, 0) == 0.0 + + +@pytest.mark.asyncio +async def test_a_seek_reports_where_the_picture_begins_not_where_it_was_asked(tmp_path): + clip = tmp_path / "clip.mp4" + _make_h264_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek) + + await session._stream_video_inner( + {"file_id": file_id, "start": _SEEK_TO, "credits": 0}) + + errors = [m for m in session.sent if m.get("type") == "error"] + assert not errors, errors + init = next(m for m in session.sent if m.get("type") == "stream_init") + + assert init["start"] == pytest.approx(_EXPECTED_KEYFRAME, abs=0.05), ( + f"the node announced {init['start']}, but a copied stream cannot " + f"begin anywhere but the keyframe at {_EXPECTED_KEYFRAME}") + assert init["start"] < _SEEK_TO, ( + "this fixture must have a keyframe gap to report, or the test cannot " + "tell the fix from the fault") + + +@pytest.mark.asyncio +async def test_the_announced_position_is_the_picture_that_was_served(tmp_path): + """Decoded and compared against the source — see this module's docstring.""" + clip = tmp_path / "clip.mp4" + _make_h264_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek) + + await session._stream_video_inner( + {"file_id": file_id, "start": _SEEK_TO, "credits": 0}) + + init = next(m for m in session.sent if m.get("type") == "stream_init") + served = tmp_path / "served.mp4" + served.write_bytes(_reassemble(session.sent, gek, file_id)) + + assert _frame_md5(served) == _frame_md5(clip, at=init["start"]), ( + "the first frame delivered is not the source frame at the position " + "the node announced, so `start` still does not name what was served") + # And it is emphatically not the frame at the position asked for, or the + # comparison above would hold for the old behaviour too. + assert _frame_md5(served) != _frame_md5(clip, at=_SEEK_TO), ( + "the fixture's picture does not change across the keyframe gap, so " + "this comparison proves nothing") |