"""Video I/O via FFmpeg: probe, extract frames, trim, reassemble with audio.""" import os import json import shutil import tempfile import subprocess from pathlib import Path from typing import Optional, Generator, Tuple from dataclasses import dataclass @dataclass class VideoInfo: width: int height: int fps: float total_frames: int duration: float has_audio: bool codec: str filepath: str class VideoProcessor: def __init__(self, config): self.config = config self._verify_ffmpeg() def _verify_ffmpeg(self): try: r = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=5) if r.returncode != 0: raise RuntimeError("FFmpeg not working") except FileNotFoundError: raise RuntimeError("FFmpeg not found. Install it (packages.txt: ffmpeg).") def get_video_info(self, video_path: str) -> VideoInfo: cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", video_path] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: raise ValueError(f"Cannot read video: {video_path}") probe = json.loads(r.stdout) vstream, has_audio = None, False for s in probe.get("streams", []): if s["codec_type"] == "video" and vstream is None: vstream = s elif s["codec_type"] == "audio": has_audio = True if not vstream: raise ValueError("No video stream found") fps_parts = vstream.get("r_frame_rate", "30/1").split("/") fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 and float(fps_parts[1]) else 30.0 nb = int(vstream.get("nb_frames", 0)) dur = float(probe.get("format", {}).get("duration", 0) or 0) if nb == 0 and dur > 0: nb = int(dur * fps) return VideoInfo(int(vstream["width"]), int(vstream["height"]), fps, nb, dur, has_audio, vstream.get("codec_name", "unknown"), video_path) def trim(self, video_path: str, start: float, length: float) -> str: start = max(0.0, float(start)); length = max(0.1, float(length)) out_dir = tempfile.mkdtemp(prefix="trim_") out_path = os.path.join(out_dir, f"trimmed_{Path(video_path).stem}.mp4") cmd = ["ffmpeg", "-y", "-ss", str(start), "-i", video_path, "-t", str(length), "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "aac", "-movflags", "+faststart", out_path] p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode != 0 or not os.path.exists(out_path): raise RuntimeError((p.stderr or "trim failed")[-400:]) return out_path def _tmp(self, name): d = os.path.join(self.config.video.temp_dir, name) os.makedirs(d, exist_ok=True) return d def extract_frames_to_dir(self, video_path: str, output_dir: str) -> Tuple[str, VideoInfo]: os.makedirs(output_dir, exist_ok=True) info = self.get_video_info(video_path) ext = self.config.video.frame_format cmd = ["ffmpeg", "-y", "-i", video_path] if ext == "png": cmd += ["-compression_level", "3"] else: cmd += ["-qscale:v", "2"] cmd += [os.path.join(output_dir, f"%06d.{ext}")] p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) _, err = p.communicate() if p.returncode != 0: raise RuntimeError(f"Frame extraction failed: {err.decode()[-400:]}") files = sorted(f for f in os.listdir(output_dir) if f.endswith(ext)) info.total_frames = len(files) return output_dir, info def assemble_video(self, frames_dir: str, output_path: str, fps: float, original_video: Optional[str] = None) -> str: ext = self.config.video.frame_format temp_video = output_path + ".temp.mp4" cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", os.path.join(frames_dir, f"%06d.{ext}"), "-c:v", self.config.video.output_codec, "-crf", str(self.config.video.output_crf), "-preset", self.config.video.output_preset, "-pix_fmt", self.config.video.pixel_format, "-movflags", "+faststart", temp_video if original_video else output_path] p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) _, err = p.communicate() if p.returncode != 0: raise RuntimeError(f"Encoding failed: {err.decode()[-400:]}") if original_video: info = self.get_video_info(original_video) if info.has_audio: mux = ["ffmpeg", "-y", "-i", temp_video, "-i", original_video, "-c:v", "copy", "-c:a", "aac", "-map", "0:v:0", "-map", "1:a:0?", "-shortest", output_path] pp = subprocess.Popen(mux, stdout=subprocess.PIPE, stderr=subprocess.PIPE) _, e2 = pp.communicate() if pp.returncode != 0: shutil.move(temp_video, output_path) else: os.remove(temp_video) else: shutil.move(temp_video, output_path) return output_path def cleanup_temp(self, temp_dir: str): if os.path.exists(temp_dir): shutil.rmtree(temp_dir, ignore_errors=True)