OmniVoice-Studio / backend /services /ffmpeg_utils.py
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
5.76 kB
import asyncio
import errno
import logging
import os
import shutil
logger = logging.getLogger("omnivoice.api")
# Cap concurrent ffmpeg jobs so macOS posix_spawn can't hit EAGAIN under load.
_FFMPEG_SEMAPHORE: "asyncio.Semaphore | None" = None
_FFMPEG_CONCURRENCY = 2
def _get_semaphore() -> asyncio.Semaphore:
global _FFMPEG_SEMAPHORE
if _FFMPEG_SEMAPHORE is None:
_FFMPEG_SEMAPHORE = asyncio.Semaphore(_FFMPEG_CONCURRENCY)
return _FFMPEG_SEMAPHORE
def find_ffmpeg():
"""Locate an ffmpeg binary.
Resolution order:
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled).
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
3. Common system paths / ``PATH``.
Returns the path string, or ``None`` if nothing found.
"""
# 1. Env var injected by Tauri host
env_path = os.environ.get("FFMPEG_PATH")
if env_path:
resolved = shutil.which(env_path)
if resolved:
return resolved
# 2. imageio-ffmpeg bundled static binary
try:
import imageio_ffmpeg
candidate = imageio_ffmpeg.get_ffmpeg_exe()
if candidate and os.path.isfile(candidate):
return candidate
logger.debug("imageio_ffmpeg binary not found at %s", candidate)
except Exception as e:
logger.debug("imageio_ffmpeg unavailable: %s", e)
# 3. Well-known system paths + PATH lookup
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
if shutil.which(path):
return path
logger.warning("ffmpeg not found in env, imageio, or system PATH")
return None
def resolve_ffprobe() -> str | None:
"""Resolve an ffprobe binary path.
Resolution order (per issue #76 and 01-03-PLAN.md must_haves):
1. ``OMNIVOICE_FFPROBE_PATH`` env var — the canonical, namespaced path
injected by Tauri pointing at the bundled sidecar (e.g.
``/usr/lib/omnivoice-studio/bin/ffprobe`` on .deb installs).
2. ``FFPROBE_PATH`` env var — legacy alias kept for backward
compatibility with older Tauri shells / dev environments.
3. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
Returns the resolved path string, or ``None`` if nothing found. Callers
that need a hard failure should use :func:`find_ffprobe` instead.
"""
for env_key in ("OMNIVOICE_FFPROBE_PATH", "FFPROBE_PATH"):
path = os.environ.get(env_key)
if not path:
continue
# The env var may carry either an absolute path to a file OR a bare
# command name (legacy). Accept both shapes — file first.
if os.path.isfile(path):
return path
resolved = shutil.which(path)
if resolved:
return resolved
system_probe = shutil.which("ffprobe")
if system_probe:
return system_probe
return None
def find_ffprobe():
"""Locate an ffprobe binary (legacy wrapper around :func:`resolve_ffprobe`).
Falls back to deriving the path from ``find_ffmpeg()`` so the
co-located ffprobe in an ffmpeg-bundle download (e.g. BtbN, evermeet.cx)
is still picked up when only ffmpeg has been resolved.
"""
resolved = resolve_ffprobe()
if resolved:
return resolved
try:
ffmpeg_path = find_ffmpeg()
if ffmpeg_path:
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
if os.path.isfile(candidate):
return candidate
except Exception:
pass
return None
async def _spawn_with_retry(cmd, **kwargs):
"""Spawn a subprocess, retrying briefly on EAGAIN (posix_spawn resource pressure)."""
delay = 0.1
last_err = None
for _ in range(5):
try:
return await asyncio.create_subprocess_exec(*cmd, **kwargs)
except BlockingIOError as e:
last_err = e
if e.errno != errno.EAGAIN:
raise
await asyncio.sleep(delay)
delay *= 2
except OSError as e:
if e.errno == errno.EAGAIN:
last_err = e
await asyncio.sleep(delay)
delay *= 2
continue
raise
raise last_err if last_err else RuntimeError("spawn failed")
async def run_ffmpeg(cmd, timeout: float = 1800.0, capture: bool = True):
"""Run an ffmpeg subprocess with concurrency cap, timeout, and proper cleanup.
Returns (returncode, stdout_bytes, stderr_bytes). Raises asyncio.TimeoutError
on hard timeout (after killing + reaping the process).
"""
stdout = asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL
stderr = asyncio.subprocess.PIPE
async with _get_semaphore():
proc = await _spawn_with_retry(cmd, stdout=stdout, stderr=stderr)
try:
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
raise
return proc.returncode, out, err
finally:
# Guarantee reaping — prevents zombie pileup under timeouts or errors.
if proc.returncode is None:
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass