Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
shortsmith ya compone la banda sonora (sonar); ahora publica una paleta (pulse, static) y este repo la consume en vivo: el prompt la ofrece con sus notas de mood, validate_spec la usa como fuente de verdad para audio.preset, y editar el preset en /short_spec es la manera gratis de escucharlas. Sin /audio (404 o caída) todo cae a la paleta base y nada deja de renderizar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
12 KiB
Python
277 lines
12 KiB
Python
"""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
|