Files

146 lines
5.7 KiB
Python

"""Offset estimation from where the video and audio tracks start and end,
as an optional alternative to marking a sync event by hand.
Premise: a recorder that stops both tracks at the same real-world moment,
but whose audio started capturing late (or early) while still being
written as starting at t=0, produces a file where the audio runs out
before (or after) the video by exactly the sync error. So
delay_ms = (video_end - audio_end) * 1000
using the same sign convention as sync_logic.compute_offset (positive:
audio is ahead and gets delayed; negative: video gets delayed).
This is only an *estimate*: a file whose audio genuinely stopped early
(a mic cutting out before the camera did) looks identical but has nothing
to do with sync. The start-based figure is reported alongside it as a
cross-check -- it's what carries the offset when the container encodes it
as a stream start_time instead, and a disagreement between the two is
worth surfacing.
"""
import subprocess
from dataclasses import dataclass
from typing import List, Optional, Tuple
# How far before the end of the file to start reading packets. ffprobe
# seeks there instead of scanning the whole file, which keeps this fast on
# multi-hour recordings. If a track ends earlier than this window (so no
# packets for it turn up), the whole track is scanned instead.
TAIL_WINDOW_SEC = 30.0
# Start- and end-based estimates are compared against this. AAC encoder
# delay alone typically puts the start ~20-45ms off, so anything much
# tighter would warn on perfectly ordinary files.
DISAGREEMENT_TOLERANCE_MS = 50.0
class StreamEdgesError(RuntimeError):
pass
@dataclass(frozen=True)
class StreamEdges:
video_start: float
video_end: float
audio_start: float
audio_end: float
@property
def end_delay_ms(self) -> float:
return (self.video_end - self.audio_end) * 1000.0
@property
def start_delay_ms(self) -> float:
return (self.video_start - self.audio_start) * 1000.0
@property
def estimates_disagree(self) -> bool:
return abs(self.end_delay_ms - self.start_delay_ms) > DISAGREEMENT_TOLERANCE_MS
def _ffprobe(args: List[str]) -> str:
result = subprocess.run(["ffprobe", "-v", "error", *args], capture_output=True, text=True)
if result.returncode != 0:
raise StreamEdgesError(f"ffprobe failed: {result.stderr.strip()}")
return result.stdout
def _parse_float(value: str) -> Optional[float]:
try:
return float(value)
except ValueError:
return None # "N/A"
def _format_duration(input_path: str) -> Optional[float]:
out = _ffprobe(["-show_entries", "format=duration", "-of", "csv=p=0", input_path])
return _parse_float(out.strip())
def _stream_start(input_path: str, stream_specifier: str) -> float:
out = _ffprobe([
"-select_streams", stream_specifier,
"-show_entries", "stream=start_time", "-of", "csv=p=0", input_path,
])
start = _parse_float(out.strip())
return start if start is not None else 0.0
def _packets(input_path: str, stream_specifier: str, read_from: Optional[float]) -> List[Tuple[float, Optional[float]]]:
args = ["-select_streams", stream_specifier]
if read_from is not None:
args += ["-read_intervals", f"{read_from:.3f}%"]
args += ["-show_entries", "packet=pts_time,duration_time", "-of", "csv=p=0", input_path]
packets = []
for line in _ffprobe(args).splitlines():
fields = line.strip().rstrip(",").split(",")
pts = _parse_float(fields[0]) if fields and fields[0] else None
if pts is None:
continue
duration = _parse_float(fields[1]) if len(fields) > 1 else None
packets.append((pts, duration))
return packets
def _stream_end(input_path: str, stream_specifier: str, file_duration: Optional[float]) -> float:
"""End of the last packet (pts + duration), not the container's stored
duration -- that's frequently missing (e.g. per-stream in MKV) or
rounded, and this needs to be accurate to well under a frame."""
read_from = None
if file_duration is not None and file_duration > TAIL_WINDOW_SEC:
read_from = file_duration - TAIL_WINDOW_SEC
packets = _packets(input_path, stream_specifier, read_from)
if not packets and read_from is not None:
packets = _packets(input_path, stream_specifier, None)
if not packets:
raise StreamEdgesError(f"no packets found for stream {stream_specifier}")
# Max rather than last: video with B-frames isn't stored in pts order.
ends = [pts + duration for pts, duration in packets if duration is not None]
if ends:
return max(ends)
# No per-packet durations (some containers): fall back to the gap
# between the last two packets as the final packet's duration.
pts_sorted = sorted(pts for pts, _ in packets)
step = pts_sorted[-1] - pts_sorted[-2] if len(pts_sorted) > 1 else 0.0
return pts_sorted[-1] + step
def probe_stream_edges(
input_path: str,
video_stream_index: Optional[int] = None,
audio_stream_index: Optional[int] = None,
) -> StreamEdges:
"""Stream indices are ffmpeg global indices (mpv's `ff-index`), so the
estimate uses the same tracks the player is showing -- see
clap_detection.py for why that matters on multi-track files."""
video_spec = f"{video_stream_index}" if video_stream_index is not None else "v:0"
audio_spec = f"{audio_stream_index}" if audio_stream_index is not None else "a:0"
file_duration = _format_duration(input_path)
return StreamEdges(
video_start=_stream_start(input_path, video_spec),
video_end=_stream_end(input_path, video_spec, file_duration),
audio_start=_stream_start(input_path, audio_spec),
audio_end=_stream_end(input_path, audio_spec, file_duration),
)