Files
movie_sync/apply_sync.py
T

216 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Apply sync: mux file A video + file A audio + delayed file B audio into output.
Usage:
python apply_sync.py --config config.toml --lag 0.042
python apply_sync.py --a video_a.mkv --b video_b.mkv --lag -0.015 --out output/synced.mkv
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tomllib
def probe_audio_streams(path: str) -> list[dict]:
"""Probe all audio streams in a file."""
if shutil.which("ffprobe") is None:
return []
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a",
"-show_entries",
"stream=index,codec_name,channels,sample_rate,bit_rate,bits_per_sample,"
"duration,start_time,start_pts,duration_ts,"
"stream_disposition=forced,default:"
"stream_tags=language,title",
"-of", "json", path],
capture_output=True, text=True, timeout=10
)
if r.returncode != 0:
return []
data = json.loads(r.stdout)
streams = []
for s in data.get("streams", []):
tags = {k.lower(): v for k, v in (s.get("tags") or {}).items()}
dispo = s.get("disposition", {})
sr = s.get("sample_rate")
lang_raw = tags.get("language", "und")
streams.append({
"index": s.get("index", 0),
"codec": s.get("codec_name", ""),
"channels": s.get("channels", 0),
"sample_rate": int(sr) if sr else 0,
"language": lang_raw if lang_raw else "und",
"title": tags.get("title", ""),
"is_default": dispo.get("default", 0) == 1,
"is_forced": dispo.get("forced", 0) == 1,
"start_time": float(s.get("start_time") or 0),
"start_pts": int(s.get("start_pts") or 0),
"duration_ts": int(s.get("duration_ts") or 0),
"duration": float(s.get("duration") or 0),
})
return streams
except Exception:
return []
def _codec_args(codec: str, out_idx: int) -> list[str]:
"""Return ffmpeg codec args for a given audio codec string."""
lossless = {"flac", "alac", "pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "pcm_f64le"}
if codec in lossless:
return [f"-c:a:{out_idx}", codec]
# lossy codecs: use original where possible
codec_map = {
"aac": "aac", "mp3": "libmp3lame", "opus": "libopus",
"vorbis": "libvorbis", "ac3": "ac3", "eac3": "eac3",
}
enc = codec_map.get(codec, "aac")
args = [f"-c:a:{out_idx}", enc]
if enc != codec or codec not in lossless:
args += [f"-b:a:{out_idx}", "192k"]
return args
def build_ffmpeg_cmd(file_a: str, file_b: str, lag_sec: float, out_path: str) -> list[str]:
"""Build ffmpeg command to mux A video + all A audio + all delayed B audio streams."""
a_streams = probe_audio_streams(file_a)
b_streams = probe_audio_streams(file_b)
lag_ms = lag_sec * 1000
# Check if B has a single stream with unspecified language
set_german = (len(b_streams) == 1 and b_streams[0]["language"] in ("und", "unknown", ""))
# Build filter_complex for B audio streams
# Positive lag (B behind A): adelay adds silence at start
# Negative lag (B ahead of A): atrim cuts the start, asetpts resets timestamps
filters = []
for i, s in enumerate(b_streams):
if lag_ms >= 0:
filters.append(f"[1:a:{i}]adelay={lag_ms:.0f}|{lag_ms:.0f}:all=1[d{i}]")
else:
trim_sec = -lag_sec
filters.append(f"[1:a:{i}]atrim=start={trim_sec:.6f},asetpts=N/SR/TB[d{i}]")
cmd = ["ffmpeg", "-y", "-i", file_a, "-i", file_b]
if filters:
cmd += ["-filter_complex", ";".join(filters)]
# Map: video from A, ALL audio from A, all delayed B audio
cmd += ["-map", "0:v", "-map", "0:a"]
for i in range(len(b_streams)):
cmd += ["-map", f"[d{i}]"]
# Codecs: copy video + A's audio, re-encode B's filtered audio with same codec
n_a = len(a_streams)
cmd += ["-c:v", "copy"]
for i in range(n_a):
cmd += [f"-c:a:{i}", "copy"]
for i, s in enumerate(b_streams):
cmd += _codec_args(s["codec"], n_a + i)
# Language metadata for B audio streams (they start after A's streams)
for i, s in enumerate(b_streams):
out_audio_idx = n_a + i
if set_german and i == 0:
cmd += [f"-metadata:s:a:{out_audio_idx}", "language=ger"]
elif s["language"] not in ("und", "unknown", ""):
cmd += [f"-metadata:s:a:{out_audio_idx}", f"language={s['language']}"]
if s["title"]:
cmd += [f"-metadata:s:a:{out_audio_idx}", f"title={s['title']}"]
if s["is_forced"]:
cmd += [f"-disposition:a:{out_audio_idx}", "forced"]
cmd.append(out_path)
return cmd
def main():
ap = argparse.ArgumentParser(description="Apply sync delay to mux audio streams")
ap.add_argument("--config", default=None, help="config.toml path")
ap.add_argument("--a", default=None, help="Video file A (base)")
ap.add_argument("--b", default=None, help="Video file B (delayed audio source)")
ap.add_argument("--lag", type=float, required=True, help="Lag in seconds (B relative to A, positive = B delayed)")
ap.add_argument("--out", default=None, help="Output file path (overrides config)")
ap.add_argument("--progress", action="store_true",
help="Stream ffmpeg -progress key=value lines to stdout for GUI parsing")
args = ap.parse_args()
file_a = args.a
file_b = args.b
out_path = args.out
if args.config:
with open(args.config, "rb") as f:
cfg = tomllib.load(f)
ext = cfg.get("extract", {})
sync = cfg.get("sync", {})
file_a = file_a or ext.get("file_a")
file_b = file_b or ext.get("file_b")
if not out_path:
out_dir = sync.get("out_dir", "output")
stem = os.path.splitext(os.path.basename(file_a))[0]
out_path = os.path.join(out_dir, f"{stem}_synced.mkv")
if not file_a or not file_b:
print("ERROR: need --a/--b or --config with file_a/file_b", file=sys.stderr)
sys.exit(1)
if not os.path.exists(file_a):
print(f"ERROR: file A not found: {file_a}", file=sys.stderr); sys.exit(1)
if not os.path.exists(file_b):
print(f"ERROR: file B not found: {file_b}", file=sys.stderr); sys.exit(1)
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
b_streams = probe_audio_streams(file_b)
print(f"File A: {file_a}")
print(f"File B: {file_b}")
print(f"B audio streams: {len(b_streams)}")
for i, s in enumerate(b_streams):
lang = s['language']
title = f' "{s["title"]}"' if s["title"] else ""
print(f" [{i}] {lang}{title} {s['codec'].upper()} {s['channels']}ch")
print(f"Lag: {args.lag:+.6f}s ({args.lag*1000:+.0f}ms)")
print(f"Output: {out_path}")
cmd = build_ffmpeg_cmd(file_a, file_b, args.lag, out_path)
print(f"\nRunning: {' '.join(cmd)}", file=sys.stderr, flush=True)
if args.progress:
# Machine-readable progress on stdout: frame=, out_time_ms=, progress=end.
# -nostats suppresses the \r human stats that GUIs cannot parse line-wise.
# Output options must precede the output path (last element).
cmd = cmd[:-1] + ["-progress", "pipe:1", "-nostats", cmd[-1]]
# stderr stays attached to the terminal: ffmpeg's final summary line
# (video:/audio: sizes) must remain visible, only stats are silenced.
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
universal_newlines=True, bufsize=1)
assert proc.stdout is not None
for line in proc.stdout:
line = line.strip()
if not line:
continue
# Forward only progress protocol lines; keep stdout parseable.
if "=" in line:
print(line, flush=True)
rc = proc.wait()
if rc != 0:
print(f"ERROR: ffmpeg failed with code {rc}", file=sys.stderr)
sys.exit(rc)
else:
r = subprocess.run(cmd)
if r.returncode != 0:
print(f"ERROR: ffmpeg failed with code {r.returncode}", file=sys.stderr)
sys.exit(r.returncode)
print(f"\nSynced output: {out_path}")
sys.exit(0)
if __name__ == "__main__":
main()