feat: add video xcorr lab with gray proxy support

This commit is contained in:
Honney OC committed 2026-09-16 15:51:34 +00:00
1 parent 2119ea36ff
commit ba6c6c8d21
4 files changed
+1199 -1

No files matched your search

+23 -1
View File
@@ -14,7 +14,7 @@ else
PIP_INSTALL = $(PIP) install PIP_INSTALL = $(PIP) install
endif endif
.PHONY: default install install-cpu install-rocm install-cuda extract extract-loud picker ast sync verify clean help .PHONY: default install install-cpu install-rocm install-cuda extract extract-loud proxy picker ast vst sync verify clean help
default: ast default: ast
@@ -26,8 +26,10 @@ help:
@echo " make install-cuda # force CUDA torch" @echo " make install-cuda # force CUDA torch"
@echo " make extract # run extract.py (uses config.toml if present)" @echo " make extract # run extract.py (uses config.toml if present)"
@echo " make extract-loud # extract with loud-only gating (keep explosions/transients)" @echo " make extract-loud # extract with loud-only gating (keep explosions/transients)"
@echo " make proxy # build gray analysis proxies from extracted masters (local only)"
@echo " make picker # visual picker: select start/duration before extraction" @echo " make picker # visual picker: select start/duration before extraction"
@echo " make ast # run sync_audio_test.py (audio_sync_test, PyQtGraph GUI)" @echo " make ast # run sync_audio_test.py (audio_sync_test, PyQtGraph GUI)"
@echo " make vst # run sync_video_test.py (video xcorr lab, PyQtGraph GUI)"
@echo " make sync LAG=0.042 # run apply_sync.py to mux synced output (LAG in seconds)" @echo " make sync LAG=0.042 # run apply_sync.py to mux synced output (LAG in seconds)"
@echo " make verify LAG=0.042 # verify sync: video + dual waveform player" @echo " make verify LAG=0.042 # verify sync: video + dual waveform player"
@echo " make clean" @echo " make clean"
@@ -87,6 +89,9 @@ extract:
extract-loud: extract-loud:
@if [ -x $(PY) ]; then $(PY) extract.py loud; else python3 extract.py loud; fi @if [ -x $(PY) ]; then $(PY) extract.py loud; else python3 extract.py loud; fi
proxy:
@if [ -x $(PY) ]; then $(PY) extract.py proxy; else python3 extract.py proxy; fi
picker: picker:
@if [ -x $(PY) ]; then QT_QPA_PLATFORM=xcb QT_STYLE_OVERRIDE=Fusion $(PY) picker.py --config $(CONFIG); else QT_QPA_PLATFORM=xcb QT_STYLE_OVERRIDE=Fusion python3 picker.py --config $(CONFIG); fi @if [ -x $(PY) ]; then QT_QPA_PLATFORM=xcb QT_STYLE_OVERRIDE=Fusion $(PY) picker.py --config $(CONFIG); else QT_QPA_PLATFORM=xcb QT_STYLE_OVERRIDE=Fusion python3 picker.py --config $(CONFIG); fi
@@ -112,5 +117,22 @@ ast:
fi fi
@if [ -x $(PY) ]; then LC_NUMERIC=C PYTHONPATH=. QT_STYLE_OVERRIDE=Fusion $(PY) sync_audio_test.py --config $(CONFIG); else LC_NUMERIC=C PYTHONPATH=. QT_STYLE_OVERRIDE=Fusion python3 sync_audio_test.py --config $(CONFIG); fi @if [ -x $(PY) ]; then LC_NUMERIC=C PYTHONPATH=. QT_STYLE_OVERRIDE=Fusion $(PY) sync_audio_test.py --config $(CONFIG); else LC_NUMERIC=C PYTHONPATH=. QT_STYLE_OVERRIDE=Fusion python3 sync_audio_test.py --config $(CONFIG); fi
vst:
@VIDEO_A=$$(python3 -c "import tomllib; c=tomllib.load(open('config.toml','rb')); print(c['extract'].get('file_a',''))" 2>/dev/null); \
VIDEO_B=$$(python3 -c "import tomllib; c=tomllib.load(open('config.toml','rb')); print(c['extract'].get('file_b',''))" 2>/dev/null); \
OUT=$$(python3 -c "import tomllib; c=tomllib.load(open('config.toml','rb')); print(c['extract'].get('out_dir','extracted'))" 2>/dev/null); \
STEM_A=$$(basename "$$VIDEO_A" | sed 's/\.[^.]*$$//'); \
STEM_B=$$(basename "$$VIDEO_B" | sed 's/\.[^.]*$$//'); \
if [ ! -f "$$OUT/$${STEM_A}_video.mkv" ] || [ ! -f "$$OUT/$${STEM_B}_video.mkv" ]; then \
echo "=> extracted masters not found, running make extract first..."; \
$(MAKE) extract; \
elif [ ! -f "$$OUT/$${STEM_A}_proxy.mkv" ] || [ ! -f "$$OUT/$${STEM_B}_proxy.mkv" ]; then \
echo "=> masters exist but proxies missing, running make proxy (local only)..."; \
$(MAKE) proxy; \
else \
echo "=> extracted videos exist, skipping extract/proxy"; \
fi
@if [ -x $(PY) ]; then LC_NUMERIC=C PYTHONPATH=. QT_STYLE_OVERRIDE=Fusion $(PY) sync_video_test.py --config $(CONFIG); else LC_NUMERIC=C PYTHONPATH=. QT_STYLE_OVERRIDE=Fusion python3 sync_video_test.py --config $(CONFIG); fi
clean: clean:
rm -rf $(VENV) __pycache__ .pytest_cache rm -rf $(VENV) __pycache__ .pytest_cache
+15
View File
@@ -12,6 +12,10 @@ start = 0.0
out_dir = "extracted" out_dir = "extracted"
target_fps = "24000/1001" # Blu-ray film 23.976 target_fps = "24000/1001" # Blu-ray film 23.976
target_fps_eps = 0.02 target_fps_eps = 0.02
# Lossless gray analysis proxy ({stem}_proxy.mkv), built from the LOCAL master
# (never re-reads the originals). Used by sync_video_test.py for fast decode.
proxy = true
proxy_width = 192
[audio] [audio]
# Target for re-encode / load. Must be identical for both files. # Target for re-encode / load. Must be identical for both files.
@@ -35,3 +39,14 @@ volume = 70 # the default volume in ast
method = "phat" # "phat" (GCC-PHAT, robust) or "standard" (raw) method = "phat" # "phat" (GCC-PHAT, robust) or "standard" (raw)
use_gpu = "auto" # "auto" | "cpu" | "cuda" (ROCm/CUDA both via torch.cuda) use_gpu = "auto" # "auto" | "cpu" | "cuda" (ROCm/CUDA both via torch.cuda)
out_dir = "output" # synced output folder out_dir = "output" # synced output folder
[video]
# Settings for sync_video_test.py (reads ONLY files inside extract.out_dir).
grid = "32x18" # correlation grid WxH (16x9, 24x14, 32x18, 48x27, 64x36)
bars = true # auto-crop static letterbox/pillarbox bars before scaling
max_lag = 0.0 # search half-window in seconds (0 = full range)
prefer_proxy = true # use {stem}_proxy.mkv when present, else {stem}_video.mkv
decode_width = 192 # decode scale width when reading the full master
# Explicit extracted files (optional, relative to repo root or absolute):
# file_a_mkv = "extracted/movie_b_proxy.mkv"
# file_b_mkv = "extracted/movie_a_proxy.mkv"
+58
View File
@@ -9,6 +9,9 @@ Requires: ffmpeg installed and on PATH.
Usage: Usage:
python extract.py # normal extraction python extract.py # normal extraction
python extract.py loud # loud-only mode: gate out quiet audio, keep only transients python extract.py loud # loud-only mode: gate out quiet audio, keep only transients
python extract.py proxy # proxy-only mode: build lossless gray analysis proxies
# from ALREADY extracted master videos (local only,
# never touches the original sources)
""" """
import subprocess import subprocess
@@ -22,6 +25,11 @@ LOUD_MODE = "loud" in sys.argv
if LOUD_MODE: if LOUD_MODE:
sys.argv.remove("loud") sys.argv.remove("loud")
# ─── Proxy-only mode: build proxies from already extracted masters (no source access) ───
PROXY_ONLY = "proxy" in sys.argv
if PROXY_ONLY:
sys.argv.remove("proxy")
# ─── Input / config (config.toml is base config, fallback to hardcoded) ─── # ─── Input / config (config.toml is base config, fallback to hardcoded) ───
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.toml") CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.toml")
_cfg_inputs = None _cfg_inputs = None
@@ -35,6 +43,8 @@ _cfg_target_lufs = -14
_cfg_target_tp = -1.0 _cfg_target_tp = -1.0
_cfg_target_lra = 11 _cfg_target_lra = 11
_cfg_audio_stream = None # None = prompt, int = index, "skip" = no audio extraction _cfg_audio_stream = None # None = prompt, int = index, "skip" = no audio extraction
_cfg_proxy = True # build lossless gray analysis proxy from extracted master
_cfg_proxy_width = 192
try: try:
try: try:
import tomllib # py3.11+ import tomllib # py3.11+
@@ -56,6 +66,13 @@ try:
_cfg_eps = float(_ex["target_fps_eps"]) _cfg_eps = float(_ex["target_fps_eps"])
except Exception: except Exception:
pass pass
if _ex.get("proxy") is not None:
_cfg_proxy = bool(_ex["proxy"])
if _ex.get("proxy_width") is not None:
try:
_cfg_proxy_width = int(_ex["proxy_width"])
except Exception:
pass
_au = _cfg.get("audio", {}) _au = _cfg.get("audio", {})
if _au.get("normalize") is not None: if _au.get("normalize") is not None:
_cfg_normalize = bool(_au["normalize"]) _cfg_normalize = bool(_au["normalize"])
@@ -114,6 +131,7 @@ OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), _cfg_out_d
# GPU only makes sense for preview proxies, not for measurement. CPU encode # GPU only makes sense for preview proxies, not for measurement. CPU encode
# of 90 s clips is fast enough and bit-exact. # of 90 s clips is fast enough and bit-exact.
VIDEO_CODEC_BASE = ["-c:v", "ffv1", "-level", "3", "-pix_fmt", "yuv444p"] VIDEO_CODEC_BASE = ["-c:v", "ffv1", "-level", "3", "-pix_fmt", "yuv444p"]
PROXY_CODEC = ["-c:v", "ffv1", "-level", "3", "-pix_fmt", "gray8"]
AUDIO_CODEC = ["-c:a", "pcm_f32le", "-ar", "48000", "-ac", "1"] AUDIO_CODEC = ["-c:a", "pcm_f32le", "-ar", "48000", "-ac", "1"]
# target fps may be overridden via config.toml # target fps may be overridden via config.toml
TARGET_FPS = _cfg_target_fps # default "24000/1001" TARGET_FPS = _cfg_target_fps # default "24000/1001"
@@ -166,6 +184,31 @@ def probe_fps(path: str) -> float | None:
def fps_matches(a: float | None, b: float, eps: float = FPS_EPS) -> bool: def fps_matches(a: float | None, b: float, eps: float = FPS_EPS) -> bool:
return a is not None and abs(a - b) < eps return a is not None and abs(a - b) < eps
def make_proxy(master_path: str, proxy_out: str, width: int | None = None) -> bool:
"""Build a lossless gray analysis proxy from an ALREADY extracted master.
Reads only the local master mkv (never the original source), downscales to
`width` px gray8 FFV1. Used by sync_video_test.py for fast decode."""
if not os.path.exists(master_path):
print(f"[proxy] SKIP: master not found: {master_path}", file=sys.stderr)
return False
w = int(width) if width else _cfg_proxy_width
cmd = [
"ffmpeg", "-y",
"-i", master_path,
"-an",
"-vf", f"format=gray,scale={w}:-2:flags=neighbor",
*PROXY_CODEC,
proxy_out,
]
print(f"[proxy] {os.path.basename(master_path)} → {os.path.basename(proxy_out)} ({w}px gray FFV1)…")
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f"[proxy] FAILED:\n{r.stderr}", file=sys.stderr)
return False
print(f"[proxy] done ✔")
return True
def probe_audio_streams(path: str) -> list[dict]: def probe_audio_streams(path: str) -> list[dict]:
"""Probe all audio streams in a file. Returns list of dicts matching ~/.bin/ff style.""" """Probe all audio streams in a file. Returns list of dicts matching ~/.bin/ff style."""
if shutil.which("ffprobe") is None: if shutil.which("ffprobe") is None:
@@ -439,6 +482,17 @@ _stems = _derive_stems(INPUT_FILES)
# ─── Prepare output folder ─── # ─── Prepare output folder ───
os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(OUT_DIR, exist_ok=True)
# ─── Proxy-only mode: build proxies from already extracted masters, never open sources ───
if PROXY_ONLY:
_ok = 0
for _stem in _stems:
_master = os.path.join(OUT_DIR, f"{_stem}_video.mkv")
_proxy = os.path.join(OUT_DIR, f"{_stem}_proxy.mkv")
if make_proxy(_master, _proxy):
_ok += 1
print(f"[proxy] {_ok}/{len(_stems)} proxies built in {OUT_DIR}")
sys.exit(0 if _ok == len(_stems) else 1)
# ─── Validate start offset against file durations ─── # ─── Validate start offset against file durations ───
for _check_idx, _check_file in enumerate(INPUT_FILES): for _check_idx, _check_file in enumerate(INPUT_FILES):
try: try:
@@ -512,6 +566,10 @@ for _file_idx, INPUT_FILE in enumerate(INPUT_FILES):
sys.exit(f"ffmpeg (video) failed:\n{r.stderr}") sys.exit(f"ffmpeg (video) failed:\n{r.stderr}")
print(f"[video: {stem}] done ✔") print(f"[video: {stem}] done ✔")
# ─── Analysis proxy: derived from the LOCAL master just written (never the source) ───
if _cfg_proxy:
make_proxy(video_out, os.path.join(OUT_DIR, f"{stem}_proxy.mkv"))
# ─── Extract audio-only (duration s, no video, CPU PCM re-encode) ─── # ─── Extract audio-only (duration s, no video, CPU PCM re-encode) ───
if audio_stream is None: if audio_stream is None:
print(f"[audio: {stem}] skipped (no stream selected)") print(f"[audio: {stem}] skipped (no stream selected)")
+1103
View File
File diff suppressed because it is too large. Load diff