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>
91 lines
3.2 KiB
Python
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
|