feat: add video xcorr lab with gray proxy support
This commit is contained in:
1 parent
2119ea36ff
commit
ba6c6c8d21
4 files changed
+1199
-1
No files matched your search
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user