feat(short): la paleta de audio viva — GET /audio en el contrato, el prompt y el bucle de edición
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>
This commit is contained in:
ChemaVX
2026-08-06 14:41:27 +00:00
co-authored by Claude Fable 5
parent 1fd0c1b3d6
commit 93a506b636
11 changed files with 259 additions and 34 deletions
+19 -6
View File
@@ -21,7 +21,7 @@ cubierto.
from __future__ import annotations
import re
from typing import Any, Optional
from typing import Any, Iterable, Optional
__all__ = [
"SpecInvalid",
@@ -183,14 +183,23 @@ def _check_meta(meta: Any) -> list[str]:
return errors
def _check_audio(audio: Any, total: float) -> list[str]:
#: Los presets que existían antes de `GET /audio`. Solo es el default cuando el
#: caller no pasa la paleta viva; con ella, un preset nuevo en shortsmith llega
#: aquí sin tocar este repo — el mismo pacto que las plantillas.
BASELINE_PRESET_NAMES = ("sonar", "none")
def _check_audio(audio: Any, total: float,
presets: Optional[Iterable[str]] = None) -> list[str]:
if audio is None:
return []
if not isinstance(audio, dict):
return ["audio: se esperaba un objeto"]
errors = []
if audio.get("preset", "sonar") not in ("sonar", "none"):
errors.append(f"audio.preset: {audio.get('preset')!r} no es 'sonar' ni 'none'")
known = tuple(presets) if presets else BASELINE_PRESET_NAMES
if audio.get("preset", "sonar") not in known:
errors.append(f"audio.preset: {audio.get('preset')!r} no existe "
f"(los presets son: {', '.join(sorted(known))})")
silence = audio.get("silence", [])
if not isinstance(silence, list):
return errors + ["audio.silence: se esperaba una lista de pares [inicio, fin]"]
@@ -223,12 +232,16 @@ def _total_duration(spec: dict) -> float:
return total
def validate_spec(spec: Any, templates: dict[str, dict]) -> None:
def validate_spec(spec: Any, templates: dict[str, dict],
presets: Optional[Iterable[str]] = None) -> None:
"""Lanza `SpecInvalid` con TODAS las rutas que fallan.
Se devuelven todos los errores de golpe a propósito: el bucle de reintento
se los da al modelo verbatim y arreglar cinco de una vez sale más barato
que cinco vueltas.
`presets` es la paleta viva de `GET /audio`; sin ella se valida contra la
paleta base, que nunca acepta nada que un shortsmith viejo no renderice.
"""
errors: list[str] = []
if not isinstance(spec, dict):
@@ -284,7 +297,7 @@ def validate_spec(spec: Any, templates: dict[str, dict]) -> None:
if total > MAX_TOTAL_DURATION:
errors.append(f"shots: la duración total ({total:.2f}s) pasa del límite "
f"de {MAX_TOTAL_DURATION}s")
errors.extend(_check_audio(spec.get("audio"), total))
errors.extend(_check_audio(spec.get("audio"), total, presets))
if errors:
raise SpecInvalid(errors)