from __future__ import annotations import json import os import re import shutil import subprocess from dataclasses import dataclass from pathlib import Path from typing import Optional, Set from PIL import Image from .models import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, MediaInfo, MediaKind # Keep Pillow from warning on very large, legitimate photos. Image.MAX_IMAGE_PIXELS = 300_000_000 class MediaError(Exception): """A media file could not be read or processed.""" @dataclass(frozen=True) class FFmpegTools: ffmpeg: Optional[Path] ffprobe: Optional[Path] @property def can_encode_video(self) -> bool: return self.ffmpeg is not None and self.ffprobe is not None def _creation_flags() -> int: if os.name == "nt": return getattr(subprocess, "CREATE_NO_WINDOW", 0) return 0 def find_ffmpeg_tools() -> FFmpegTools: """Locate ffmpeg/ffprobe, preferring a bundled bin folder, then PATH.""" override = os.environ.get("BOLT_FFMPEG_DIR") candidates = [] if override: candidates.append(Path(override)) app_root = Path(__file__).resolve().parent.parent candidates.extend([app_root / "bin", app_root]) exe = ".exe" if os.name == "nt" else "" def locate(name: str) -> Optional[Path]: for directory in candidates: candidate = directory / f"{name}{exe}" if candidate.is_file(): return candidate resolved = shutil.which(name) return Path(resolved) if resolved else None return FFmpegTools(ffmpeg=locate("ffmpeg"), ffprobe=locate("ffprobe")) def classify_path(path: Path) -> Optional[MediaKind]: suffix = path.suffix.lower() if suffix in IMAGE_EXTENSIONS: return MediaKind.IMAGE if suffix in VIDEO_EXTENSIONS: return MediaKind.VIDEO return None def human_size(num_bytes: int) -> str: value = float(max(0, num_bytes)) units = ("B", "KB", "MB", "GB", "TB") for unit in units: if value < 1024 or unit == units[-1]: if unit == "B": return f"{int(value)} {unit}" if value >= 100: return f"{value:.0f} {unit}" if value >= 10: return f"{value:.1f} {unit}" return f"{value:.2f} {unit}" value /= 1024 return f"{value:.1f} TB" def format_duration(seconds: float) -> str: if not seconds or seconds <= 0: return "—" total = int(round(seconds)) hours, rem = divmod(total, 3600) minutes, secs = divmod(rem, 60) if hours: return f"{hours}:{minutes:02d}:{secs:02d}" return f"{minutes}:{secs:02d}" def _display_rotation(stream: dict) -> int: rotation = 0 for side_data in stream.get("side_data_list", []) or []: if "rotation" in side_data: try: rotation = int(round(float(side_data["rotation"]))) except (TypeError, ValueError): pass if rotation == 0: tags = stream.get("tags", {}) or {} if "rotate" in tags: try: rotation = int(round(float(tags["rotate"]))) except (TypeError, ValueError): pass return rotation % 360 def _probe_video(path: Path, size: int, tools: FFmpegTools) -> MediaInfo: if tools.ffprobe is None: raise MediaError("ffprobe was not found. Install FFmpeg and make sure it is on PATH.") cmd = [ str(tools.ffprobe), "-v", "error", "-show_streams", "-show_format", "-of", "json", str(path), ] try: result = subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, creationflags=_creation_flags(), ) except (OSError, subprocess.TimeoutExpired) as exc: raise MediaError(f"Could not inspect video: {exc}") from exc if result.returncode != 0: message = (result.stderr or "Unreadable video file").strip().splitlines()[-1] raise MediaError(message[:300]) try: payload = json.loads(result.stdout or "{}") except json.JSONDecodeError as exc: raise MediaError("Could not read video metadata.") from exc streams = payload.get("streams", []) or [] stream = next( (s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic") != 1), None, ) if stream is None: stream = next((s for s in streams if s.get("codec_type") == "video"), None) if stream is None: raise MediaError("No video stream was found in this file.") try: width = int(stream.get("width") or 0) height = int(stream.get("height") or 0) except (TypeError, ValueError): width = height = 0 rotation = _display_rotation(stream) if rotation in (90, 270): width, height = height, width duration = 0.0 for candidate in (payload.get("format", {}).get("duration"), stream.get("duration")): try: candidate_float = float(candidate) if candidate_float > 0: duration = candidate_float break except (TypeError, ValueError): continue format_name = str(payload.get("format", {}).get("format_name", "")) primary_format = format_name.split(",")[0] if format_name else path.suffix.lstrip(".") primary_format = primary_format.upper() codec = str(stream.get("codec_name", "")).upper() return MediaInfo( path=path, kind=MediaKind.VIDEO, size=size, width=width, height=height, format_name=primary_format, duration=duration, codec=codec, ) def _probe_image(path: Path, size: int) -> MediaInfo: try: with Image.open(path) as image: if getattr(image, "is_animated", False) and getattr(image, "n_frames", 1) > 1: raise MediaError("Animated images are not supported yet.") width, height = image.size try: orientation = image.getexif().get(0x0112, 1) except Exception: orientation = 1 if orientation in (5, 6, 7, 8): width, height = height, width format_name = (image.format or path.suffix.lstrip(".")).upper() except MediaError: raise except Exception as exc: raise MediaError(f"Could not read image: {exc}") from exc if width <= 0 or height <= 0: raise MediaError("The image has no readable dimensions.") return MediaInfo( path=path, kind=MediaKind.IMAGE, size=size, width=width, height=height, format_name=format_name, ) def probe_media(path: Path, tools: Optional[FFmpegTools] = None) -> MediaInfo: path = Path(path) kind = classify_path(path) if kind is None: raise MediaError("Unsupported file type.") try: size = path.stat().st_size except OSError as exc: raise MediaError(f"File is not accessible: {exc}") from exc if kind == MediaKind.IMAGE: return _probe_image(path, size) return _probe_video(path, size, tools or find_ffmpeg_tools()) def image_output_extension(source: Path, format_choice: str) -> str: if format_choice == "jpeg": return ".jpg" if format_choice == "webp": return ".webp" if format_choice == "png": return ".png" keep_map = { ".jpg": ".jpg", ".jpeg": ".jpg", ".png": ".png", ".webp": ".webp", ".bmp": ".bmp", ".tif": ".tif", ".tiff": ".tif", } return keep_map.get(source.suffix.lower(), ".jpg") def video_output_extension(codec: str) -> str: return ".webm" if codec in {"vp9", "av1"} else ".mp4" def unique_output_path(source: Path, folder: Optional[Path], suffix: str, extension: str) -> Path: destination_dir = Path(folder) if folder else source.parent base = destination_dir / f"{source.stem}{suffix}{extension}" candidate = base counter = 2 while candidate.exists(): candidate = destination_dir / f"{source.stem}{suffix} ({counter}){extension}" counter += 1 return candidate def _run_encoders(ffmpeg: Path) -> str: try: result = subprocess.run( [str(ffmpeg), "-hide_banner", "-encoders"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=20, creationflags=_creation_flags(), ) except (OSError, subprocess.TimeoutExpired): return "" return result.stdout or "" _encoder_cache: dict[str, Set[str]] = {} def available_encoders(tools: Optional[FFmpegTools] = None) -> Set[str]: tools = tools or find_ffmpeg_tools() if tools.ffmpeg is None: return set() key = str(tools.ffmpeg) if key not in _encoder_cache: encoders: Set[str] = set() for line in _run_encoders(tools.ffmpeg).splitlines(): match = re.match(r"^\s*V\S*\s+(\S+)", line) if match: encoders.add(match.group(1)) _encoder_cache[key] = encoders return set(_encoder_cache[key]) def available_video_codecs(tools: Optional[FFmpegTools] = None) -> Set[str]: encoders = available_encoders(tools) codecs: Set[str] = set() if "libx264" in encoders: codecs.add("h264") if "libx265" in encoders: codecs.add("h265") if "libvpx-vp9" in encoders: codecs.add("vp9") if {"libsvtav1", "libaom-av1"} & encoders: codecs.add("av1") return codecs def compact_error(text: str, limit: int = 400) -> str: lines = [] for raw in (text or "").splitlines(): line = raw.strip() if not line: continue if re.match( r"^(frame|fps|stream_\d|bitrate|total_size|out_time|dup_frames|drop_frames|speed|progress)=", line, ): continue lines.append(line) message = " ".join(lines) if lines else (text or "Encoding failed.").strip() message = re.sub(r"\s+", " ", message) return message[-limit:] if len(message) > limit else message