103 lines
3.2 KiB
Python
103 lines
3.2 KiB
Python
"""Locating ffmpeg / ffprobe and running them without flashing console windows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
IS_WINDOWS = sys.platform.startswith("win")
|
|
EXE_SUFFIX = ".exe" if IS_WINDOWS else ""
|
|
|
|
# Keep ffprobe/ffmpeg helper calls from popping up a console on Windows.
|
|
_NO_WINDOW = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WINDOWS else {}
|
|
|
|
|
|
def app_dir() -> Path:
|
|
"""Directory holding the executable (frozen) or the project root (source)."""
|
|
if getattr(sys, "frozen", False):
|
|
return Path(sys.executable).resolve().parent
|
|
return Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def run_quiet(args: list[str], timeout: float | None = 60) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
args,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=timeout,
|
|
**_NO_WINDOW,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class FFmpegTools:
|
|
ffmpeg: str
|
|
ffprobe: str
|
|
version: str = ""
|
|
encoders: set[str] = field(default_factory=set)
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return bool(self.ffmpeg and self.ffprobe)
|
|
|
|
|
|
def _candidate_dirs(configured_dir: str | None) -> list[Path]:
|
|
dirs: list[Path] = []
|
|
if configured_dir:
|
|
dirs.append(Path(configured_dir))
|
|
base = app_dir()
|
|
dirs += [base, base / "ffmpeg", base / "ffmpeg" / "bin", base / "bin"]
|
|
return dirs
|
|
|
|
|
|
def _find_in(dirs: list[Path], name: str) -> str | None:
|
|
for d in dirs:
|
|
p = d / f"{name}{EXE_SUFFIX}"
|
|
if p.is_file() and (IS_WINDOWS or os.access(p, os.X_OK)):
|
|
return str(p)
|
|
return None
|
|
|
|
|
|
def locate_tools(configured_dir: str | None = None) -> FFmpegTools | None:
|
|
"""Find ffmpeg + ffprobe: configured dir, next to the app, then PATH."""
|
|
dirs = _candidate_dirs(configured_dir)
|
|
ffmpeg = _find_in(dirs, "ffmpeg")
|
|
if ffmpeg:
|
|
# Prefer the ffprobe that ships alongside the chosen ffmpeg.
|
|
ffprobe = _find_in([Path(ffmpeg).parent], "ffprobe") or _find_in(dirs, "ffprobe")
|
|
else:
|
|
ffmpeg = shutil.which("ffmpeg")
|
|
ffprobe = None
|
|
if ffmpeg and not ffprobe:
|
|
ffprobe = _find_in([Path(ffmpeg).parent], "ffprobe") or shutil.which("ffprobe")
|
|
if not ffmpeg or not ffprobe:
|
|
return None
|
|
|
|
tools = FFmpegTools(ffmpeg=ffmpeg, ffprobe=ffprobe)
|
|
try:
|
|
out = run_quiet([ffmpeg, "-hide_banner", "-version"], timeout=15).stdout.decode(errors="replace")
|
|
first = out.splitlines()[0] if out else ""
|
|
m = re.match(r"ffmpeg version (\S+)", first)
|
|
tools.version = m.group(1) if m else first
|
|
enc = run_quiet([ffmpeg, "-hide_banner", "-encoders"], timeout=15).stdout.decode(errors="replace")
|
|
tools.encoders = parse_encoders(enc)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return tools
|
|
|
|
|
|
def parse_encoders(text: str) -> set[str]:
|
|
names = set()
|
|
for line in text.splitlines():
|
|
# " V....D libx264 libx264 H.264 ..."
|
|
m = re.match(r"^\s*([VAS])[\w.]{5}\s+(\S+)\s", line)
|
|
if m and m.group(2) != "=":
|
|
names.add(m.group(2))
|
|
return names
|