Files
VoiceStudio/backend/services/ffmpeg_utils.py
T
debpalashandClaude Opus 4.7 67328d04fe refactor: split backend into api/core/services/schemas, harden security + fd pressure, add searchable language picker, fix segment fragmentation
Backend:
- Split monolithic main.py into backend/{api/routers,core,schemas,services}
- core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER)
- core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate
- services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap
- services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts,
  bounded-loop merge; public clean_up_segments API
- services/model_manager.py: robust lock.locked() handling
- api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs,
  POST /dub/cleanup-segments endpoint
- api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg
- api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form
- api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper
- api/routers/system.py: try/finally tmp cleanup, subprocess timeouts
- schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs
- main.py: threading.Lock around crash log writes

Frontend:
- components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap
- App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button;
  fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref)
- components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder
- index.css: ss-* combobox styles matching Gruvbox theme

Tests:
- tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py

Chore:
- .gitignore: exclude omnivoice.zip, /research/ reference clones
- Remove tracked stray root test scripts + crash_log.txt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:20:19 +05:30

91 lines
3.2 KiB
Python

import asyncio
import errno
import logging
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():
try:
import imageio_ffmpeg
# This will natively extract and return an architecture-specific static FFmpeg binary!
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception as e:
logger.warning(f"imageio_ffmpeg failed to provide static binary: {e}. Falling back to default system path.")
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
if shutil.which(path):
return path
raise RuntimeError("ffmpeg not found in bundle or system path")
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