reel-studio-2 / core /utils.py
xvepkj's picture
initial commit
3d2098f
Raw
History Blame
3.79 kB
"""Small shared utilities: logging, JSON extraction, retries, ffprobe."""
from __future__ import annotations
import json
import logging
import re
import subprocess
import time
from typing import Any, Callable, Optional
log = logging.getLogger("reelstudio")
if not log.handlers:
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
log.addHandler(_h)
log.setLevel(logging.INFO)
# ---------------------------------------------------------------------------
# JSON extraction (LLMs love markdown fences)
# ---------------------------------------------------------------------------
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
def extract_json(text: str) -> Any:
"""Parse JSON out of an LLM response, stripping markdown fences.
Tries, in order: fenced block, the raw text, and the largest
{...} / [...] slice. Raises ValueError if nothing parses.
"""
candidates = []
m = _FENCE_RE.search(text)
if m:
candidates.append(m.group(1))
candidates.append(text.strip())
for opener, closer in (("{", "}"), ("[", "]")):
start, end = text.find(opener), text.rfind(closer)
if start != -1 and end > start:
candidates.append(text[start : end + 1])
for c in candidates:
try:
return json.loads(c)
except (json.JSONDecodeError, TypeError):
continue
raise ValueError(f"No parseable JSON in LLM response: {text[:200]!r}")
# ---------------------------------------------------------------------------
# Retry with exponential backoff
# ---------------------------------------------------------------------------
class HardAPIError(Exception):
"""Non-retryable API failure (400/401/403/404 class)."""
def retry_backoff(
fn: Callable[[], Any],
attempts: int = 4,
base_delay: float = 2.0,
retry_on: tuple = (Exception,),
label: str = "call",
) -> Any:
"""Run ``fn`` with exponential backoff. HardAPIError is never retried."""
last: Optional[Exception] = None
for i in range(attempts):
try:
return fn()
except HardAPIError:
raise
except retry_on as e: # noqa: PERF203
last = e
delay = base_delay * (2**i)
log.warning("%s failed (attempt %d/%d): %s — retrying in %.1fs", label, i + 1, attempts, e, delay)
if i < attempts - 1:
time.sleep(delay)
raise last # type: ignore[misc]
# ---------------------------------------------------------------------------
# FFmpeg helpers
# ---------------------------------------------------------------------------
def run_ffmpeg(args: list[str], label: str = "ffmpeg") -> None:
"""Run an ffmpeg command, raising with stderr tail on failure."""
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", *args]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-2000:]
raise RuntimeError(f"{label} failed (exit {proc.returncode}): {tail}")
def ffprobe_duration(path: str) -> float:
"""True media duration in seconds via ffprobe."""
proc = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
],
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise RuntimeError(f"ffprobe failed for {path}: {proc.stderr[-500:]}")
return float(proc.stdout.strip())
def slugify(text: str, max_len: int = 40) -> str:
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return s[:max_len] or "untitled"