diff options
Diffstat (limited to 'packages/meshbay-node/src')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 86 |
1 files changed, 84 insertions, 2 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 2863679..8ffcbed 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -292,6 +292,12 @@ AUDIO_TRANSCODE_TIMEOUT_SECS = 120 # pathological container rather than against the work itself, and it is short # next to the audio one because nothing here decodes a media stream. SUBTITLE_EXTRACT_TIMEOUT_SECS = 60 +# How far back to look for the keyframe a copied seek will land on. Measured +# on a real H264 title: the answer came back in 0.12–0.51 s, and the largest +# gap between keyframes was under 10 s. Thirty seconds is three times that and +# still one short read rather than a scan of the file. +KEYFRAME_LOOKBACK_SECS = 30 +KEYFRAME_LOOKUP_TIMEOUT_SECS = 10 # A subtitle file is text; a film's is ~96 KB. Anything past this is not a # subtitle track, it is an ffmpeg that found something else to write, and it # would sit in the media cache for ever. @@ -6172,10 +6178,34 @@ class WebRTCPeerSession: # library's HEVC files looked like the only ones that worked: video # that is re-encoded *can* start exactly at `start`, so accurate # seeking is right there and stays on. + # + # **`start` below is rewritten to the keyframe on the copy path**, so + # from here down it is where the picture actually begins and not where + # the viewer dragged to. That distinction was invisible while only the + # scrubber read the number; it stopped being invisible when subtitles + # did, since their cues carry the source's absolute times and a GOP's + # worth of disagreement puts a line on screen before it is spoken. seek_args: list[str] = [] if start > 0: - seek_args = ["-ss", f"{start:.3f}"] if transcode_video else [ - "-noaccurate_seek", "-ss", f"{start:.3f}"] + if transcode_video: + seek_args = ["-ss", f"{start:.3f}"] + else: + # Copied video begins on a keyframe whatever is asked for, so + # ask for the keyframe itself and report *that* as `start`. + # The bytes are the ones ffmpeg would have delivered anyway — + # it lands on the same frame either way — but the client now + # sets `timestampOffset` to where the picture really begins + # rather than to where it was asked to begin, which is what + # puts a subtitle cue over the line that is being spoken. + # + # Six decimals because ffprobe reports six: rounding the + # keyframe's own timestamp *down* would put it before the + # frame it names and select the previous keyframe instead, + # which is this same fault again, smaller. + keyframe = await _keyframe_at_or_before(file_path, start) + if keyframe is not None: + start = keyframe + seek_args = ["-noaccurate_seek", "-ss", f"{start:.6f}"] map_args = ["-map", "0:v:0"] if transcode_video: log.info("stream: re-encoding %s (%s) to H264", entry.name, @@ -6493,6 +6523,58 @@ async def _transcode_audio_to_aac(file_path: Path) -> bytes: tmp_path.unlink(missing_ok=True) +async def _keyframe_at_or_before(file_path: Path, t: float) -> float | None: + """Where a copied stream seeking to `t` will actually begin. + + Copied video has to start on a keyframe, so `-ss t` on a `-c:v copy` + stream delivers the keyframe at or before `t` — up to a whole GOP earlier. + The node used to report `t` anyway, and the client sets its + `SourceBuffer.timestampOffset` from that number: everything downstream + therefore believed the picture was a few seconds further along than it + was. Harmless while only the scrubber read it; not harmless once subtitles + do, because their cues carry the source's own absolute times and appeared + a GOP early — measured on a real H264 title at 0.8 s, 1.6 s and 4.6 s + depending on where the viewer dragged. + + Returns None when the lookup finds nothing, and the caller then keeps the + old behaviour: a label that is wrong by a few seconds is worth a great + deal less than a stream that does not start. + """ + if t <= 0: + return 0.0 + window_start = max(0.0, t - KEYFRAME_LOOKBACK_SECS) + try: + proc = await asyncio.create_subprocess_exec( + platform.ffprobe_cmd(), "-v", "error", + "-select_streams", "v:0", "-skip_frame", "nokey", + "-show_entries", "frame=pts_time", + # The window ends past `t`, and the `pts <= t` filter below is what + # actually bounds the answer: ffprobe's interval end is exclusive + # enough that a keyframe sitting exactly on `t` is never emitted, + # and the lookup then names the previous one — a whole GOP earlier + # than where the viewer asked to be, for the one position they are + # most likely to ask for twice (a resume lands on it). + "-read_intervals", f"{window_start:.3f}%{t + 1.0:.3f}", + "-of", "csv=p=0", str(file_path), + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await asyncio.wait_for( + proc.communicate(), timeout=KEYFRAME_LOOKUP_TIMEOUT_SECS) + except (asyncio.TimeoutError, OSError) as e: + log.warning("stream: keyframe lookup failed at %.1fs: %r", t, e) + return None + best: float | None = None + for line in stdout.decode(errors="replace").splitlines(): + try: + pts = float(line.strip().rstrip(",")) + except ValueError: + continue + # `<= t` and the largest such: the frame the seek will land on. + if pts <= t and (best is None or pts > best): + best = pts + return best + + async def _extract_subtitle_to_webvtt(file_path: Path, ordinal: int) -> bytes: """ One subtitle track out of a container, whole, as WebVTT. |