""" lib.ffmpeg — FFmpeg / FFprobe 路径统一入口 ============================================= 本机 FFmpeg 装在 `~/.local/bin`(非 brew、不在默认 PATH), 所有模块统一从这里取路径,避免 `subprocess.run(["ffmpeg", ...])` 找不到命令。 用法: from lib.ffmpeg import FFMPEG, FFPROBE subprocess.run([FFMPEG, "-y", "-i", src, dst]) """ from __future__ import annotations import os import shutil from pathlib import Path _CANDIDATES = [ os.path.expanduser("~/.local/bin/ffmpeg"), "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", ] _CANDIDATES_PROBE = [ os.path.expanduser("~/.local/bin/ffprobe-real"), "/usr/local/bin/ffprobe", "/opt/homebrew/bin/ffprobe", ] def _find_real_ffprobe() -> str | None: """查找真正可用的 ffprobe ⚠️ 本机 `~/.local/bin/ffprobe` 实为 `ffmpeg` 的**符号链接**(不支持 -show_entries); 真正的 ffprobe 在别处(如 Trae/火星代码自带 6.1.1 完整版)。 """ import glob pats = [ os.path.expanduser("~/.marscode/**/modules/ai-agent/ffprobe"), os.path.expanduser("~/.trae/**/ffprobe"), os.path.expanduser("~/.vscode/**/ffprobe"), "/Applications/*.app/Contents/**/ffprobe", ] for pat in pats: for p in sorted(glob.glob(pat, recursive=True), reverse=True): try: if os.path.isfile(p) and os.access(p, os.X_OK): # 验证确实支持 -show_entries(真正的 ffprobe) import subprocess r = subprocess.run([p, "-h"], capture_output=True, text=True, timeout=10) if "show_entries" in ((r.stdout or "") + (r.stderr or "")): return p except Exception: continue return None # ═══════════════════════════════════════════════════════════ # 媒体信息探测(**用 `ffmpeg -i` 解析**) # # ⚠️ 本机 ffprobe 是精简构建:不支持 `-show_entries` / `-select_streams` # (会报 `Unrecognized option`)→ 相对地,`ffmpeg -i` 完全可用。 # 因此统一改用 ffmpeg 输出解析,避免各处 ffprobe 调用**静默失败**。 # ═══════════════════════════════════════════════════════════ def probe_info(media_path) -> dict: """探测媒体信息 → {duration, bitrate_kbps, size_bytes, has_audio, has_video}""" import re import subprocess p = Path(media_path) info = {"duration": 0.0, "bitrate_kbps": 0, "size_bytes": p.stat().st_size if p.exists() else 0, "has_audio": False, "has_video": False} if not p.exists(): return info try: r = subprocess.run([FFMPEG, "-i", str(p)], capture_output=True, text=True, timeout=30) txt = (r.stderr or "") + (r.stdout or "") except Exception: return info m = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", txt) if m: info["duration"] = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3)) m = re.search(r"bitrate:\s*(\d+)\s*kb/s", txt) if m: info["bitrate_kbps"] = int(m.group(1)) elif info["duration"] > 0: info["bitrate_kbps"] = int(info["size_bytes"] * 8 / info["duration"] / 1000) info["has_audio"] = "Audio:" in txt info["has_video"] = "Video:" in txt # 视频尺寸(从 "Video: h264 ..., 1080x1920" 解析) m = re.search(r"Video:.*?(\d{2,5})x(\d{2,5})", txt) if m: info["width"], info["height"] = int(m.group(1)), int(m.group(2)) else: info["width"], info["height"] = 0, 0 return info def probe_duration(media_path) -> float: """媒体时长(秒)—— 兼容实现(ffmpeg -i 解析)""" return probe_info(media_path)["duration"] def has_audio_stream(media_path) -> bool: """是否含音轨 —— 兼容实现""" return probe_info(media_path)["has_audio"] def _resolve(candidates: list[str], name: str) -> str: for c in candidates: if Path(c).exists(): return c found = shutil.which(name) if found: return found # 找不到也返回名字(让调用时报清晰的错),但记录警告 return name FFMPEG = _resolve(_CANDIDATES, "ffmpeg") # ⚠️ 本机 `~/.local/bin/ffprobe` 实为 ffmpeg 的**符号链接**(不支持 -show_entries) # → 优先查找**真正的** ffprobe;找不到就退回 FFMPEG(配合 probe_info 使用) FFPROBE = _find_real_ffprobe() or FFMPEG __all__ = ["FFMPEG", "FFPROBE", "ffmpeg_available"] def ffmpeg_available() -> bool: """ffmpeg 是否真的可用""" return Path(FFMPEG).exists() or bool(shutil.which("ffmpeg"))