146 lines
5.7 KiB
Python
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),
|
|
)
|