"""Video ingest: probe files in ``data/raw`` and extract audio (spec M1, lane A). For each file in ``config.RAW_DIR``: ffprobe it, insert/update the ``videos`` row via ``db`` helpers, and extract a mono 16 kHz WAV into ``config.AUDIO_DIR`` (one per video, keyed by ``video_id``) — that WAV is exactly what ``audio_sync.run_sync`` loads. ``run_ingest`` is idempotent: re-running after the synthetic fixture (which already registered the videos) reuses the existing rows and just re-extracts audio, so the demo path ``synthetic -> ingest -> sync`` works without unique-constraint collisions. """ from __future__ import annotations import json import logging import shutil import subprocess from pathlib import Path from festival4d import config, db log = logging.getLogger("festival4d.ingest") # Container extensions we treat as ingestible video (audio is pulled from any of them). _VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".mkv", ".avi", ".webm"} def _require(tool: str) -> None: if shutil.which(tool) is None: raise RuntimeError( f"{tool} not found on PATH — required for ingest (macOS: `brew install ffmpeg`)" ) def audio_wav_path(video_id: int) -> Path: """Canonical path of a video's extracted mono WAV. Shared convention between ingest (writer) and :mod:`festival4d.audio_sync` (reader), so neither side has to guess filenames. """ return config.AUDIO_DIR / f"{video_id}.wav" def _parse_fps(rate: str | None) -> float | None: """Parse an ffprobe frame-rate string (``"30/1"`` or ``"29.97"``) to fps.""" if not rate or rate in ("0/0", "N/A"): return None if "/" in rate: num, den = rate.split("/", 1) den = float(den) return float(num) / den if den else None return float(rate) def probe_video(path: Path) -> dict: """ffprobe a video; return ``{duration_s, fps, width, height}`` (lane A / M1).""" _require("ffprobe") out = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,avg_frame_rate,r_frame_rate:format=duration", "-of", "json", str(path)], check=True, capture_output=True, text=True, ).stdout data = json.loads(out) streams = data.get("streams") or [] if not streams: raise ValueError(f"{path.name}: no video stream found") stream = streams[0] # avg_frame_rate is the true average; r_frame_rate is a fallback for odd containers. fps = _parse_fps(stream.get("avg_frame_rate")) or _parse_fps(stream.get("r_frame_rate")) or 0.0 duration = float(data.get("format", {}).get("duration") or 0.0) return { "duration_s": duration, "fps": float(fps), "width": int(stream["width"]), "height": int(stream["height"]), } def extract_audio(path: Path, out_wav: Path, sample_rate: int = 16_000) -> Path: """Extract a mono ``sample_rate`` 16-bit PCM WAV from ``path`` via ffmpeg (lane A / M1).""" _require("ffmpeg") out_wav.parent.mkdir(parents=True, exist_ok=True) subprocess.run( ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(path), "-vn", # drop video "-ac", "1", # mono "-ar", str(sample_rate), # resample "-c:a", "pcm_s16le", # uncompressed PCM (lossless for sync) str(out_wav)], check=True, ) return out_wav def extract_audio_hq(path: Path, out_m4a: Path) -> Path: """Extract a listening-quality stereo AAC track (48 kHz, 192 kbps) from ``path``. Feeds the frontend's WebAudio master clock (``GET /api/audio/{id}``). AAC-in-m4a is re-encoded (not stream-copied) so any source codec — including webm/Opus from browser capture — decodes in every browser's ``decodeAudioData``. Written atomically (tmp + rename) so a request racing the first extraction never reads a half-written file. """ _require("ffmpeg") out_m4a.parent.mkdir(parents=True, exist_ok=True) tmp = out_m4a.with_suffix(".tmp.m4a") subprocess.run( ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(path), "-vn", "-ac", "2", "-ar", "48000", "-c:a", "aac", "-b:a", "192k", str(tmp)], check=True, ) tmp.replace(out_m4a) return out_m4a def run_ingest() -> list[dict]: """Ingest every video in ``config.RAW_DIR``: probe, register in DB, extract audio. Entrypoint for ``python -m festival4d ingest``. Returns a per-video summary list. """ db.init_engine() db.init_db() files = sorted( p for p in config.RAW_DIR.glob("*") if p.suffix.lower() in _VIDEO_EXTS ) if not files: log.warning("ingest: no video files in %s (drop clips there first)", config.RAW_DIR) return [] existing = {v.filename: v for v in db.get_videos()} summary: list[dict] = [] for path in files: info = probe_video(path) video = existing.get(path.name) reused = video is not None if video is None: video = db.add_video( filename=path.name, duration_s=info["duration_s"], fps=info["fps"], width=info["width"], height=info["height"], ) wav = audio_wav_path(video.id) extract_audio(path, wav) summary.append({ "video_id": video.id, "filename": path.name, "duration_s": info["duration_s"], "fps": info["fps"], "width": info["width"], "height": info["height"], "audio_wav": str(wav), "reused_db_row": reused, }) log.info("ingest: %s -> video_id=%d (%.1fs, %.2f fps, %dx%d)%s", path.name, video.id, info["duration_s"], info["fps"], info["width"], info["height"], " [reused row]" if reused else "") log.info("ingest: %d video(s) ready; audio in %s", len(summary), config.AUDIO_DIR) return summary