"""FFmpeg/ffprobe helpers for batch uploads and browser-ready MP4 (infrastructure only).""" from __future__ import annotations import shutil import subprocess from pathlib import Path from loguru import logger VISUALIZATION_MAX_WIDTH = 1920 def visualization_ffmpeg_scale_filter() -> str: return f"scale='min({VISUALIZATION_MAX_WIDTH},iw)':-2" def browser_transcode_tmp_path(output_path: Path) -> Path: return output_path.with_name(f"{output_path.stem}.part{output_path.suffix}") def is_readable_mp4(path: Path) -> bool: ffprobe = shutil.which("ffprobe") if ffprobe is None or not path.is_file() or path.stat().st_size < 4096: return False proc = subprocess.run( [ ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path), ], check=False, text=True, capture_output=True, ) return proc.returncode == 0 and bool((proc.stdout or "").strip()) def ffprobe_fields(path: Path, entries: str) -> dict[str, str]: ffprobe = shutil.which("ffprobe") if ffprobe is None or not path.is_file(): return {} proc = subprocess.run( [ ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", entries, "-of", "default=noprint_wrappers=1", str(path), ], check=False, text=True, capture_output=True, ) if proc.returncode != 0: return {} fields: dict[str, str] = {} for line in proc.stdout.splitlines(): if "=" not in line: continue key, value = line.split("=", 1) fields[key.strip().lower()] = value.strip().lower() return fields def ffprobe_container_format(path: Path) -> str: ffprobe = shutil.which("ffprobe") if ffprobe is None or not path.is_file(): return "" proc = subprocess.run( [ ffprobe, "-v", "error", "-show_entries", "format=format_name", "-of", "default=noprint_wrappers=1:nokey=1", str(path), ], check=False, text=True, capture_output=True, ) if proc.returncode != 0: return "" return (proc.stdout or "").strip().lower() def is_browser_compatible_mp4(path: Path) -> bool: fields = ffprobe_fields(path, "stream=codec_name,pix_fmt") return fields.get("codec_name") == "h264" and fields.get("pix_fmt") in {"yuv420p", "yuvj420p"} def batch_input_needs_normalize(path: Path) -> bool: if not is_readable_mp4(path): return True if not is_browser_compatible_mp4(path): return True container = ffprobe_container_format(path) if container and "mpeg" in container: return True fields = ffprobe_fields(path, "stream=codec_name,width,height") try: width = int(fields.get("width") or "0") except ValueError: width = 0 return width > 1920 def normalize_batch_input_video(source_path: Path, output_path: Path) -> bool: ffmpeg = shutil.which("ffmpeg") if ffmpeg is None or not source_path.is_file(): return False if not is_readable_mp4(source_path): logger.warning("skip batch input normalize: unreadable source {}", source_path) return False output_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = browser_transcode_tmp_path(output_path) if tmp_path.exists(): tmp_path.unlink() logger.info("ffmpeg batch input normalize starting: {} -> {}", source_path, output_path) proc = subprocess.run( [ ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-fflags", "+genpts", "-i", str(source_path), "-map", "0:v:0", "-an", "-f", "mp4", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "23", "-vf", "scale='min(1920,iw)':-2", "-movflags", "+faststart", str(tmp_path), ], check=False, text=True, capture_output=True, ) if proc.returncode != 0: stderr = (proc.stderr or "").strip() logger.warning("ffmpeg batch input normalize failed: {}", stderr[-3000:]) if tmp_path.exists(): tmp_path.unlink() return False if not tmp_path.is_file() or tmp_path.stat().st_size <= 0: logger.warning("ffmpeg batch input normalize produced empty file: {}", tmp_path) if tmp_path.exists(): tmp_path.unlink() return False tmp_path.replace(output_path) if not is_browser_compatible_mp4(output_path): logger.warning("ffmpeg batch input normalize output not h264/yuv420p: {}", output_path) output_path.unlink(missing_ok=True) return False logger.info( "ffmpeg batch input normalize complete: {} ({} bytes)", output_path, output_path.stat().st_size, ) return True def ensure_batch_pipeline_input_video(*, source_path: Path, dest_path: Path) -> None: import shutil as sh dest_path.parent.mkdir(parents=True, exist_ok=True) if dest_path.is_file() and dest_path.stat().st_size > 0 and not batch_input_needs_normalize(dest_path): return if batch_input_needs_normalize(source_path): if normalize_batch_input_video(source_path, dest_path): return logger.warning( "batch input normalize failed, falling back to raw copy: {} -> {}", source_path, dest_path, ) if not dest_path.is_file(): sh.copy2(source_path, dest_path) def transcode_visualization_for_browser(source_path: Path, output_path: Path) -> bool: ffmpeg = shutil.which("ffmpeg") if ffmpeg is None or not source_path.is_file(): return False if not is_readable_mp4(source_path): logger.warning("skip visualization transcode: unreadable source {}", source_path) return False output_path.parent.mkdir(parents=True, exist_ok=True) tmp_path = browser_transcode_tmp_path(output_path) if tmp_path.exists(): tmp_path.unlink() logger.info("ffmpeg visualization transcode starting: {} -> {}", source_path, output_path) proc = subprocess.run( [ ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", str(source_path), "-map", "0:v:0", "-an", "-f", "mp4", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "ultrafast", "-crf", "23", "-vf", visualization_ffmpeg_scale_filter(), "-movflags", "+faststart", str(tmp_path), ], check=False, text=True, capture_output=True, ) if proc.returncode != 0: stderr = (proc.stderr or "").strip() logger.warning("ffmpeg visualization transcode failed: {}", stderr[-3000:]) if tmp_path.exists(): tmp_path.unlink() return False if not tmp_path.is_file() or tmp_path.stat().st_size <= 0: logger.warning("ffmpeg visualization transcode produced empty file: {}", tmp_path) if tmp_path.exists(): tmp_path.unlink() return False tmp_path.replace(output_path) if not is_browser_compatible_mp4(output_path): logger.warning("ffmpeg output is not browser-compatible h264/yuv420p: {}", output_path) output_path.unlink(missing_ok=True) return False logger.info( "ffmpeg visualization transcode complete: {} ({} bytes)", output_path, output_path.stat().st_size, ) return True