"""Cliente HTTP de shortsmith — el renderizador de Shorts. shortsmith vive en su propio repo y su propio pod (`shortsmith-svc`), y expone cuatro cosas: el contrato (`GET /templates`), el envío (`POST /render`), el estado (`GET /jobs/{id}`) y el MP4 (`GET /jobs/{id}/video`). Regla de capas (convención del repo): esto vive en `generator/` y NO importa nada de `bot/`. El progreso sale por un callable genérico. El contrato NO se copia aquí. `GET /templates` publica el esquema de props de cada plantilla y es la única fuente de verdad: añadir una plantilla en shortsmith la deja disponible al generador sin tocar este repo. Copiar los esquemas crearía una segunda fuente que se desincroniza en silencio — la misma clase de fallo que el desfase de versión de ffmpeg que provocó el OOM de v1. """ from __future__ import annotations import asyncio import time from dataclasses import dataclass, field from typing import Any, Callable, Optional import aiohttp import structlog from src.config import settings, SAFE_ACCEPT_ENCODING logger = structlog.get_logger() #: Cadencia del sondeo. Un Short de 42 s tarda ~32 s en renderizar y el techo #: de 180 s del contrato tarda ~138 s: 2 s da una barra de progreso viva sin #: martillear el servicio. POLL_INTERVAL = 2.0 #: Techo del sondeo. Más allá de esto el job está atascado, no lento. POLL_CEILING = 600.0 QUEUED, RUNNING, DONE, ERROR = "queued", "running", "done", "error" __all__ = [ "ShortsmithClient", "ShortsmithError", "ShortsmithUnavailable", "ShortsmithRejected", "JobResult", "BASELINE_PRESETS", "POLL_INTERVAL", "POLL_CEILING", ] #: La paleta que existía antes de que shortsmith publicara `GET /audio`. Es el #: fallback cuando el endpoint no está (404 = shortsmith anterior) o no se pudo #: consultar: la paleta mejora el spec, no lo define, y quedarse en sonar nunca #: rompe un render. BASELINE_PRESETS = { "sonar": "low drone and sonar pings tightening toward the close", "none": "digital silence", } class ShortsmithError(Exception): """Cualquier fallo hablando con shortsmith.""" class ShortsmithUnavailable(ShortsmithError): """No se pudo contactar con el servicio (red, DNS, timeout de conexión).""" class ShortsmithRejected(ShortsmithError): """422: el spec no pasó la validación del servidor. `errors` son los errores de pydantic tal cual los devuelve shortsmith, con su `loc` completo. Se propagan sin parafrasear: las rutas exactas (`shots.0.radar_sweep.props.sweeeps`) son lo más útil que se le puede dar al modelo para corregir. """ def __init__(self, errors: list[dict[str, Any]]): self.errors = errors super().__init__(f"shortsmith rechazó el spec ({len(errors)} error/es)") @dataclass class JobResult: job_id: str status: str progress: float = 0.0 warnings: list[dict[str, Any]] = field(default_factory=list) error: Optional[str] = None @property def ok(self) -> bool: return self.status == DONE #: Caché del contrato para la vida del proceso (clave: base_url). Se refresca a #: petición cuando una validación falla, por si el renderizador se actualizó a #: mitad de una run. _templates_cache: dict[str, dict[str, Any]] = {} _presets_cache: dict[str, dict[str, str]] = {} class ShortsmithClient: def __init__(self, base_url: str | None = None, timeout: float | None = None): self.base_url = (base_url or settings.shortsmith_url).rstrip("/") self.timeout = timeout if timeout is not None else settings.shortsmith_timeout # --- transporte --------------------------------------------------------- def _session(self, total: float) -> aiohttp.ClientSession: # Accept-Encoding explícito SIEMPRE: el default de aiohttp anuncia br si # hay backend instalado y su decode está roto en 3.14 (KNOWN-ISSUES.md). return aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=total), headers={"Accept-Encoding": SAFE_ACCEPT_ENCODING}, ) async def health(self) -> dict[str, Any]: """`GET /healthz`. Sirve de comprobación previa barata.""" try: async with self._session(10) as sess: async with sess.get(f"{self.base_url}/healthz") as resp: if resp.status != 200: raise ShortsmithError(f"healthz devolvió {resp.status}") return await resp.json() except aiohttp.ClientError as e: raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e except asyncio.TimeoutError as e: raise ShortsmithUnavailable("shortsmith no respondió a healthz") from e async def templates(self, refresh: bool = False) -> dict[str, Any]: """El contrato: nombre de plantilla -> JSON Schema de sus props.""" if not refresh and self.base_url in _templates_cache: return _templates_cache[self.base_url] try: async with self._session(30) as sess: async with sess.get(f"{self.base_url}/templates") as resp: if resp.status != 200: body = await resp.text() raise ShortsmithError( f"GET /templates devolvió {resp.status}: {body[:200]}") data = await resp.json() except aiohttp.ClientError as e: raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e except asyncio.TimeoutError as e: raise ShortsmithUnavailable("shortsmith no respondió a /templates") from e _templates_cache[self.base_url] = data logger.info("shortsmith templates fetched", n=len(data)) return data async def audio_presets(self, refresh: bool = False) -> dict[str, str]: """La paleta de audio: nombre de preset -> nota de una línea (`GET /audio`). La mitad de audio del contrato vivo: shortsmith añade un preset y el prompt lo ofrece sin tocar este repo. Un 404 es un shortsmith anterior al endpoint y devuelve la paleta base, sin error — fallbacks siempre. """ if not refresh and self.base_url in _presets_cache: return _presets_cache[self.base_url] try: async with self._session(30) as sess: async with sess.get(f"{self.base_url}/audio") as resp: if resp.status == 404: data = dict(BASELINE_PRESETS) elif resp.status != 200: body = await resp.text() raise ShortsmithError( f"GET /audio devolvió {resp.status}: {body[:200]}") else: payload = await resp.json() presets = payload.get("presets") data = (presets if isinstance(presets, dict) and presets else dict(BASELINE_PRESETS)) except aiohttp.ClientError as e: raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e except asyncio.TimeoutError as e: raise ShortsmithUnavailable("shortsmith no respondió a /audio") from e _presets_cache[self.base_url] = data logger.info("shortsmith audio presets fetched", presets=sorted(data)) return data async def render(self, spec: dict[str, Any]) -> str: """`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected.""" try: async with self._session(60) as sess: async with sess.post(f"{self.base_url}/render", json=spec) as resp: if resp.status == 422: detail = (await resp.json()).get("detail") raise ShortsmithRejected( detail if isinstance(detail, list) else [{"msg": str(detail)}]) if resp.status not in (200, 202): body = await resp.text() raise ShortsmithError( f"POST /render devolvió {resp.status}: {body[:300]}") data = await resp.json() except aiohttp.ClientError as e: raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e except asyncio.TimeoutError as e: raise ShortsmithUnavailable("shortsmith no respondió a /render") from e job_id = data.get("job_id") if not job_id: raise ShortsmithError(f"/render no devolvió job_id: {str(data)[:200]}") logger.info("shortsmith job queued", job_id=job_id) return job_id async def job(self, job_id: str) -> JobResult: try: async with self._session(30) as sess: async with sess.get(f"{self.base_url}/jobs/{job_id}") as resp: if resp.status == 404: raise ShortsmithError(f"job {job_id} no existe") if resp.status != 200: body = await resp.text() raise ShortsmithError( f"GET /jobs/{job_id} devolvió {resp.status}: {body[:200]}") data = await resp.json() except aiohttp.ClientError as e: raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e except asyncio.TimeoutError as e: raise ShortsmithUnavailable(f"shortsmith no respondió por el job {job_id}") from e return JobResult( job_id=data.get("job_id", job_id), status=data.get("status", ""), progress=data.get("progress") or 0.0, warnings=data.get("warnings") or [], error=data.get("error"), ) async def poll(self, job_id: str, on_progress: Optional[Callable[[float, str], Any]] = None, interval: float = POLL_INTERVAL, ceiling: float | None = None) -> JobResult: """Sondea hasta done/error. Devuelve el JobResult final. Un job en `error` se DEVUELVE, no se lanza: el caller decide (el spec sigue valiendo aunque el render falle). Solo el atasco y los fallos de transporte lanzan. """ deadline = time.monotonic() + min( ceiling if ceiling is not None else POLL_CEILING, self.timeout) last_reported = -1.0 while True: result = await self.job(job_id) if on_progress and result.progress != last_reported: last_reported = result.progress try: await _maybe_await(on_progress(result.progress, result.status)) except Exception as e: # el progreso nunca tumba un render logger.warning("shortsmith progress callback falló", error=str(e)) if result.status in (DONE, ERROR): return result if time.monotonic() >= deadline: raise ShortsmithError( f"job {job_id} sigue en '{result.status}' pasados " f"{min(ceiling if ceiling is not None else POLL_CEILING, self.timeout):.0f}s " "— está atascado, no lento") await asyncio.sleep(interval) async def fetch_video(self, job_id: str) -> bytes: try: async with self._session(self.timeout) as sess: async with sess.get(f"{self.base_url}/jobs/{job_id}/video") as resp: if resp.status != 200: body = await resp.text() raise ShortsmithError( f"GET /jobs/{job_id}/video devolvió {resp.status}: {body[:200]}") return await resp.read() except aiohttp.ClientError as e: raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e except asyncio.TimeoutError as e: raise ShortsmithUnavailable(f"descarga del vídeo {job_id} agotó el tiempo") from e async def _maybe_await(value): if asyncio.iscoroutine(value): return await value return value