"""FFmpeg utilities for video processing.""" import json import subprocess from pathlib import Path import cv2 from .config import settings from .models import CropKeyframe, VideoMetadata def get_display_rotation(video_path: Path) -> int: """Return the display rotation angle in degrees from video stream metadata. iPhone/Android videos captured in portrait orientation are stored as landscape pixels with a rotation side-data entry (e.g. -90°) that media players apply at display time. OpenCV ignores this metadata and returns raw landscape frames. This function reads the rotation so callers can apply cv2.rotate() to get display-correct frames. Checks both side_data_list (modern MOV/MP4) and the legacy tags.rotate field. Returns 0, 90, -90, 180, or 270. Returns 0 if no rotation metadata found. """ cmd = [ str(settings.ffprobe_path), "-v", "error", "-select_streams", "v:0", "-show_streams", "-of", "json", str(video_path), ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return 0 try: data = json.loads(result.stdout) streams = data.get("streams", []) if not streams: return 0 stream = streams[0] for sd in stream.get("side_data_list", []): if "rotation" in sd: return int(sd["rotation"]) rotate = stream.get("tags", {}).get("rotate") if rotate: return int(rotate) except (json.JSONDecodeError, KeyError, IndexError, ValueError, TypeError): pass return 0 def get_cv2_rotation_code(video_path: Path) -> int | None: """Return the cv2.ROTATE_* constant needed to correct OpenCV frames to display orientation. OpenCV ignores rotation metadata and always returns raw stored pixels. This function reads the display rotation from metadata and maps it to the cv2 constant that, when passed to cv2.rotate(), produces display- correct frames. Convention: rotation=-90 (most common iPhone portrait) means the raw stored frame must be rotated 90° clockwise to appear right-side up. Returns None if no rotation correction is needed. """ rotation = get_display_rotation(video_path) return { -90: cv2.ROTATE_90_CLOCKWISE, 90: cv2.ROTATE_90_COUNTERCLOCKWISE, 180: cv2.ROTATE_180, -180: cv2.ROTATE_180, 270: cv2.ROTATE_90_CLOCKWISE, # 270° CCW == 90° CW -270: cv2.ROTATE_90_COUNTERCLOCKWISE, # 270° CW == 90° CCW }.get(rotation) def probe_video(video_path: Path) -> VideoMetadata: """ Extract video metadata using ffprobe. Returns display-correct dimensions: for videos with ±90° rotation metadata (e.g. iPhone portrait MOVs stored as landscape pixels), width and height are swapped so callers see the dimensions as they appear on screen. Args: video_path: Path to the video file Returns: VideoMetadata object with video properties Raises: subprocess.CalledProcessError: If ffprobe fails ValueError: If metadata cannot be parsed """ cmd = [ str(settings.ffprobe_path), "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration,r_frame_rate,codec_name", "-of", "json", str(video_path), ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) data = json.loads(result.stdout) if not data.get("streams"): raise ValueError(f"No video stream found in {video_path}") stream = data["streams"][0] # Parse frame rate (r_frame_rate is in format "30000/1001") fps_parts = stream["r_frame_rate"].split("/") fps = float(fps_parts[0]) / float(fps_parts[1]) width = int(stream["width"]) height = int(stream["height"]) # Swap width/height for videos that are stored rotated 90° or 270° # (e.g. iPhone portrait videos stored as landscape + rotation metadata). rotation = get_display_rotation(video_path) if abs(rotation) in (90, 270): width, height = height, width # Duration might be in stream or format, try to get it duration_str = stream.get("duration") if not duration_str: # Try to get duration from format cmd_format = [ str(settings.ffprobe_path), "-v", "error", "-show_entries", "format=duration", "-of", "json", str(video_path), ] result_format = subprocess.run(cmd_format, capture_output=True, text=True, check=True) format_data = json.loads(result_format.stdout) duration_str = format_data["format"]["duration"] duration = float(duration_str) return VideoMetadata( width=width, height=height, duration=duration, fps=fps, codec=stream["codec_name"], aspect_ratio=width / height, ) def downsample_video( input_path: Path, output_path: Path, max_height: int | None = None, max_fps: int | None = None, ) -> Path: """ Intelligently downsample video to balance quality vs token cost. Strategy: - If video > max_height: scale to max_height (preserves quality while reducing tokens) - Reduce FPS only if > max_fps (e.g., 60fps → 24fps) - Keep original FPS if already at or below max_fps Args: input_path: Path to input video output_path: Path for downsampled output max_height: Maximum height (default from settings) max_fps: Maximum FPS (default from settings) Returns: Path to downsampled video Raises: subprocess.CalledProcessError: If ffmpeg fails """ if max_height is None: max_height = settings.max_resolution_height if max_fps is None: max_fps = settings.max_fps # Probe video to get current dimensions and FPS metadata = probe_video(input_path) # Build filter chain filters = [] # Scale if needed (maintain aspect ratio) if metadata.height > max_height: filters.append(f"scale=-2:{max_height}") # Combine filters vf_arg = ",".join(filters) if filters else None # Build FFmpeg command cmd = [str(settings.ffmpeg_path), "-i", str(input_path)] if vf_arg: cmd.extend(["-vf", vf_arg]) # Set FPS if needed if metadata.fps > max_fps: cmd.extend(["-r", str(max_fps)]) # Output settings cmd.extend( [ "-c:v", "libx264", "-preset", "ultrafast", # Gemini-submission only; quality irrelevant "-c:a", "copy", # Keep audio unchanged "-y", str(output_path), ] ) subprocess.run(cmd, check=True, capture_output=True) return output_path def build_nested_if_expression( segments: list[tuple[float, float, str]], default_value: int | float ) -> str: """ Build nested if() expression for smooth interpolation between keyframes. Generates FFmpeg expression format: if(lt(t,t2), interp, if(lt(t,t3), interp2, ...)) Args: segments: List of (t1, t2, interpolation_expr) tuples default_value: Fallback value after all segments Returns: Nested if() expression string """ if not segments: return str(int(default_value)) expr = str(int(default_value)) # Final fallback value # Build from last to first (reverse order for nesting) for _t1, t2, interp in reversed(segments): expr = f"if(lt(t,{t2}),{interp},{expr})" return expr def build_crop_expression(keyframes: list[CropKeyframe]) -> str: """ Build dynamic FFmpeg crop filter that smoothly transitions between keyframes. Uses linear interpolation with FFmpeg's expression evaluation to create smooth camera-following effects. The crop position changes over time based on saliency-detected keyframes. For N keyframes, generates N-1 linear interpolation segments that smoothly transition the crop window position. Args: keyframes: List of CropKeyframe objects with time and position data Returns: FFmpeg crop filter string with dynamic expressions Raises: ValueError: If keyframes list is empty Example: With keyframes at t=0, 5, 10: crop=1080:1920:if(lt(t,5),100+(200-100)*((t-0)/(5-0)),if(lt(t,10),...)):... """ if not keyframes: raise ValueError("No keyframes provided") # Handle single keyframe - use static crop if len(keyframes) == 1: kf = keyframes[0] return f"crop=w={kf.width}:h={kf.height}:x={kf.x}:y={kf.y}" # Sort keyframes by time to ensure correct ordering sorted_kf = sorted(keyframes, key=lambda k: k.time) # Adjust times to be relative to first keyframe (for FFmpeg -ss seek) # When using -ss, FFmpeg's 't' variable starts from 0 time_offset = sorted_kf[0].time # Build interpolation expressions for x and y coordinates x_expr_parts: list[tuple[float, float, str]] = [] y_expr_parts: list[tuple[float, float, str]] = [] for i in range(len(sorted_kf) - 1): kf1 = sorted_kf[i] kf2 = sorted_kf[i + 1] # Convert to segment-relative times (subtract offset) # Round to 2 decimal places to avoid FFmpeg expression parsing issues t1 = round(kf1.time - time_offset, 2) t2 = round(kf2.time - time_offset, 2) x1, x2 = kf1.x, kf2.x y1, y2 = kf1.y, kf2.y # Linear interpolation formula: # value = start + (end - start) * ((t - t1) / (t2 - t1)) progress = f"((t-{t1})/({t2}-{t1}))" x_interp = f"({x1}+({x2}-{x1})*{progress})" y_interp = f"({y1}+({y2}-{y1})*{progress})" x_expr_parts.append((t1, t2, x_interp)) y_expr_parts.append((t1, t2, y_interp)) # Build nested if expressions for smooth transitions x_expr = build_nested_if_expression(x_expr_parts, sorted_kf[-1].x) y_expr = build_nested_if_expression(y_expr_parts, sorted_kf[-1].y) # Escape commas only (needed for FFmpeg filtergraph parser) x_expr_escaped = x_expr.replace(",", "\\,") y_expr_escaped = y_expr.replace(",", "\\,") # Width and height are constant for each segment w, h = sorted_kf[0].width, sorted_kf[0].height return f"crop=w={w}:h={h}:x={x_expr_escaped}:y={y_expr_escaped}" def check_codec_compatibility(video_path: Path) -> tuple[bool, str, str]: """ Check if video is already H.264/AAC compatible. Args: video_path: Path to video file Returns: Tuple of (is_compatible, video_codec, audio_codec) """ # Check video codec cmd = [ str(settings.ffprobe_path), "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "default=noprint_wrappers=1:nokey=1", str(video_path), ] try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) video_codec = result.stdout.strip() except subprocess.CalledProcessError: video_codec = "unknown" # Check audio codec cmd[3] = "a:0" try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) audio_codec = result.stdout.strip() except subprocess.CalledProcessError: audio_codec = "unknown" is_compatible = video_codec == "h264" and audio_codec == "aac" return is_compatible, video_codec, audio_codec def transcode_for_web( input_path: Path, output_path: Path, crf: int = 23, audio_bitrate: str = "128k", ) -> Path: """ Transcode video to H.264/AAC for web browser compatibility (Safari). Args: input_path: Input video file output_path: Output file path crf: Constant Rate Factor (18-28, lower=better quality) audio_bitrate: Audio bitrate (e.g., "128k") Returns: Path to transcoded video Raises: RuntimeError: If transcoding fails """ output_path.parent.mkdir(parents=True, exist_ok=True) cmd = [ str(settings.ffmpeg_path), "-i", str(input_path), "-c:v", "libx264", "-preset", "medium", "-crf", str(crf), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", audio_bitrate, "-ar", "44100", "-movflags", "+faststart", "-y", str(output_path), ] try: subprocess.run(cmd, check=True, capture_output=True, text=True) return output_path except subprocess.CalledProcessError as e: raise RuntimeError(f"FFmpeg transcoding failed: {e.stderr}") # Codecs that OpenCV's bundled FFmpeg cannot decode on Linux (lacks libdav1d/libaom). # The system ffmpeg binary installed via apt *does* support these, so we transcode # once with the system binary and hand a plain H.264 file to OpenCV. _CV2_UNSUPPORTED_CODECS = {"av1"} def transcode_for_cv2(video_path: Path) -> tuple[Path, bool]: """Return an OpenCV-compatible path for *video_path*. If the video uses a codec that OpenCV's bundled FFmpeg cannot decode (e.g. AV1), it is transcoded to a temporary H.264-only file using the system ffmpeg binary (which has libdav1d/libaom support). The caller must delete the temp file when ``needs_cleanup`` is ``True``. Returns: (path_to_use, needs_cleanup) """ metadata = probe_video(video_path) if metadata.codec not in _CV2_UNSUPPORTED_CODECS: return video_path, False import sys tmp_path = video_path.parent / f".cv2compat_{video_path.stem}.mp4" print( f" AV1 video detected — transcoding to H.264 for OpenCV compatibility: " f"{tmp_path.name}", file=sys.stderr, ) cmd = [ str(settings.ffmpeg_path), "-i", str(video_path), "-c:v", "libx264", "-preset", "ultrafast", "-crf", "23", "-an", # no audio — OpenCV only needs the video stream "-y", str(tmp_path), ] try: subprocess.run(cmd, check=True, capture_output=True, text=True) except subprocess.CalledProcessError as e: raise RuntimeError( f"Failed to transcode AV1 video for OpenCV: {e.stderr}" ) return tmp_path, True