From 7b79e14fdcbe5d4f47c5d8438fa368858e6e3aac Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 2 Sep 2026 07:50:43 +0000 Subject: [PATCH] refactor(contrato): el sobre se lee de GET /limits, ya no se copia a mano MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las props se validaban contra `GET /templates` y los presets contra `GET /audio`, pero el sobre —resolución, fps, duraciones, topes— estaba escrito aquí. El comentario decía «pequeño, estable»; el 2026-09-01 shortsmith quitó 1920x1080 (358eec9) porque ninguna plantilla lo componía y esta lista siguió aceptándolo. Un spec apaisado pasaba la validación local y lo rechazaba el servidor: una generación pagada y tirada. Ningún test lo veía, y no por falta de tests: los dos lados eran coherentes consigo mismos. Sólo se ve comparando con lo servido, que es lo que hace ahora `test_shortsmith_live.py`. Lo que era el espejo sobrevive como `BASELINE_LIMITS`, y sólo como suelo para cuando el servicio no conteste. Estrecho a propósito, igual que `BASELINE_PRESET_NAMES`: un fallback nunca puede aceptar lo que el contrato vivo rechaza. Con eso el fallo queda tapado aunque shortsmith no llegue a desplegarse, y contra un shortsmith anterior al endpoint el 404 cae al suelo sin error. `_limit()` resuelve clave a clave: un sobre a medias, o uno más nuevo con claves que aquí todavía no se leen, deja las demás en su sitio en vez de tirar la validación entera al suelo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq --- src/generator/short.py | 20 ++++- src/generator/shortsmith.py | 35 ++++++++ src/generator/shortspec.py | 9 +- src/generator/spec_contract.py | 146 ++++++++++++++++++++++----------- tests/test_shortsmith_live.py | 40 +++++++++ tests/test_spec_contract.py | 54 ++++++++++++ 6 files changed, 251 insertions(+), 53 deletions(-) diff --git a/src/generator/short.py b/src/generator/short.py index d8ae856..85c7952 100644 --- a/src/generator/short.py +++ b/src/generator/short.py @@ -32,7 +32,9 @@ from src.generator.shortsmith import ( ShortsmithUnavailable, ) from src.generator.shortspec import ShortSpecWriter, SpecWriteFailed -from src.generator.spec_contract import SpecInvalid, editorial_notes, validate_spec +from src.generator.spec_contract import ( + BASELINE_LIMITS, SpecInvalid, editorial_notes, validate_spec, +) from src.llm import get_anthropic_client logger = structlog.get_logger() @@ -148,6 +150,16 @@ class ShortProducer: logger.warning("GET /audio falló — paleta base", error=str(e)) return dict(BASELINE_PRESETS) + async def _limits(self) -> dict: + """El sobre del contrato, en vivo. Tampoco tumba nada: sin él se valida + contra el suelo, que es más estrecho que cualquier shortsmith y por eso + no puede dejar pasar un spec que el servidor fuera a rechazar.""" + try: + return await self.client.limits() + except Exception as e: + logger.warning("GET /limits falló — sobre base", error=str(e)) + return dict(BASELINE_LIMITS) + # --- pipeline ----------------------------------------------------------- async def produce(self, session_id: int, @@ -186,6 +198,7 @@ class ShortProducer: # ofrece la paleta base y el render sale igual. templates = await self.client.templates() presets = await self._presets() + limits = await self._limits() # 3. El spec. started = time.monotonic() @@ -193,7 +206,7 @@ class ShortProducer: writer = ShortSpecWriter( llm_call, templates, refresh_templates=lambda: self.client.templates(refresh=True), - presets=presets) + presets=presets, limits=limits) try: written = await writer.write( topic, context, article_url=result.article_url, @@ -269,8 +282,9 @@ class ShortProducer: # de retoque para el que existe este camino. templates = await self.client.templates() presets = await self._presets() + limits = await self._limits() try: - validate_spec(spec, templates, presets=presets) + validate_spec(spec, templates, presets=presets, limits=limits) except SpecInvalid as e: result.failure = ("El spec editado no pasa el contrato: " + "; ".join(e.errors[:6])) diff --git a/src/generator/shortsmith.py b/src/generator/shortsmith.py index 08bdd8a..46e4da2 100644 --- a/src/generator/shortsmith.py +++ b/src/generator/shortsmith.py @@ -24,6 +24,7 @@ import aiohttp import structlog from src.config import settings, SAFE_ACCEPT_ENCODING +from src.generator.spec_contract import BASELINE_LIMITS logger = structlog.get_logger() @@ -97,6 +98,7 @@ class JobResult: #: mitad de una run. _templates_cache: dict[str, dict[str, Any]] = {} _presets_cache: dict[str, dict[str, str]] = {} +_limits_cache: dict[str, dict[str, Any]] = {} class ShortsmithClient: @@ -178,6 +180,39 @@ class ShortsmithClient: logger.info("shortsmith audio presets fetched", presets=sorted(data)) return data + async def limits(self, refresh: bool = False) -> dict[str, Any]: + """El sobre del contrato: resoluciones, fps, duraciones y topes. + + La tercera mitad del contrato vivo, y la que faltaba. Las props se leen + de `/templates` desde el principio y los presets de `/audio`; el sobre + se copiaba a mano en `spec_contract.BASELINE_LIMITS`, y la copia derivó + en cuanto shortsmith quitó 1920x1080. Un 404 es un shortsmith anterior + al endpoint y devuelve el suelo, sin error — fallbacks siempre. + """ + if not refresh and self.base_url in _limits_cache: + return _limits_cache[self.base_url] + try: + async with self._session(30) as sess: + async with sess.get(f"{self.base_url}/limits") as resp: + if resp.status == 404: + data = dict(BASELINE_LIMITS) + elif resp.status != 200: + body = await resp.text() + raise ShortsmithError( + f"GET /limits devolvió {resp.status}: {body[:200]}") + else: + payload = await resp.json() + data = payload if isinstance(payload, dict) and payload \ + else dict(BASELINE_LIMITS) + except aiohttp.ClientError as e: + raise ShortsmithUnavailable(f"shortsmith inalcanzable: {e}") from e + except asyncio.TimeoutError as e: + raise ShortsmithUnavailable("shortsmith no respondió a /limits") from e + _limits_cache[self.base_url] = data + logger.info("shortsmith limits fetched", + resolutions=data.get("resolutions")) + return data + async def render(self, spec: dict[str, Any]) -> str: """`POST /render`. Devuelve el job_id. 422 -> ShortsmithRejected.""" try: diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index 1e92305..12dda42 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -495,9 +495,13 @@ LLMCall = Callable[[str, str], Awaitable[str]] class ShortSpecWriter: def __init__(self, llm_call: LLMCall, templates: dict[str, dict], refresh_templates: Optional[Callable[[], Awaitable[dict]]] = None, - presets: Optional[dict[str, str]] = None): + presets: Optional[dict[str, str]] = None, + limits: Optional[dict] = None): self.llm_call = llm_call self.templates = templates + #: El sobre vivo de `GET /limits`. Sin él se valida contra el suelo, + #: que nunca acepta nada que un shortsmith viejo fuera a rechazar. + self.limits = limits #: Se vuelve a pedir el contrato si una validación falla: el #: renderizador puede haberse actualizado a mitad de la run. self.refresh_templates = refresh_templates @@ -560,7 +564,8 @@ class ShortSpecWriter: last_spec = spec try: - validate_spec(spec, self.templates, presets=self.presets) + validate_spec(spec, self.templates, presets=self.presets, + limits=self.limits) except SpecInvalid as e: history.append(e.errors) feedback = _format_errors(e.errors) diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index 2d5074c..d251f8c 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -1,6 +1,6 @@ """El contrato del spec, leído — no copiado — de shortsmith. -Dos cosas, las dos guiadas por lo que publica `GET /templates`: +Dos cosas, guiadas por lo que shortsmith publica: * `describe_templates()` — el contrato en prosa compacta, para meterlo en el prompt. Añadir una plantilla en shortsmith la deja descrita aquí sola. @@ -10,13 +10,18 @@ Dos cosas, las dos guiadas por lo que publica `GET /templates`: comprobador de fundamento va entre la validación y el render: mandar el spec a `POST /render` para validarlo ya encolaría el render. -La mitad de props del contrato NO vive aquí: se valida contra el esquema -recibido. Lo único escrito a mano es el sobre (version/meta/audio/shots), que -es pequeño, estable, y está anotado con la regla equivalente de -`shortsmith/src/shortsmith/spec.py`. Las reglas de pydantic que cruzan campos -(los límites de MapBounds, "3 barras no dejan sitio para una cita") NO se -replican: las coge el 422 del servidor al enviar, y ese camino también está -cubierto. +**Nada del contrato se escribe aquí.** Las props se validan contra el esquema de +`GET /templates`, los presets contra `GET /audio` y el sobre — resolución, fps, +duraciones, topes — contra `GET /limits`. De cada uno hay una base local, pero +sólo como suelo para cuando el servicio no conteste, nunca como fuente. + +El sobre **sí** estuvo escrito a mano, y por eso hay tres endpoints en vez de +dos: la copia decía «pequeña y estable» hasta que shortsmith quitó 1920x1080 y +esta lista siguió aceptándolo días. Ver `BASELINE_LIMITS`. + +Las reglas de pydantic que cruzan campos (los límites de MapBounds, "3 barras no +dejan sitio para una cita") NO se replican: las coge el 422 del servidor al +enviar, y ese camino también está cubierto. """ from __future__ import annotations @@ -40,14 +45,48 @@ __all__ = [ "TARGET_MAX_DURATION", ] -# Límites del sobre — espejo de shortsmith/spec.py. -RESOLUTIONS = {(1080, 1920), (1920, 1080)} -FPS_VALUES = {24, 25, 30, 60} -MIN_SHOT_DURATION = 0.5 -MIN_TOTAL_DURATION = 5.0 -MAX_TOTAL_DURATION = 180.0 # límite duro de YouTube Shorts -MAX_SHOTS = 64 -META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +#: El sobre que aceptaba shortsmith antes de que `GET /limits` existiera, y el +#: suelo cuando el endpoint no contesta. +#: +#: Esto **era** un espejo a mano, y se rompió como se rompen los espejos: el +#: 2026-09-01 shortsmith quitó 1920x1080 (`358eec9`) porque ninguna plantilla lo +#: componía, y esta lista siguió aceptándolo. Un spec que aquí pasaba, allí se +#: rechazaba: una generación pagada y tirada. Nadie se equivocó al escribirlo — +#: ése es el argumento. Ahora el sobre se lee de `GET /limits` igual que las +#: props se leen de `GET /templates`, y esto es sólo el suelo. +#: +#: Conservador a propósito, como `BASELINE_PRESET_NAMES`: nunca acepta nada que +#: un shortsmith viejo fuera a rechazar. Por eso la resolución es la estrecha. +BASELINE_LIMITS: dict[str, Any] = { + "resolutions": [[1080, 1920]], + "fps": [24, 25, 30, 60], + "meta_id_pattern": r"^[a-z0-9][a-z0-9_-]{0,63}$", + "shot_duration": {"min": 0.5, "max": 180.0}, + "total_duration": {"min": 5.0, "max": 180.0}, # 180 s: límite de Shorts + "shots": {"min": 1, "max": 64}, + "max_narration_chars": 320, + "max_silence_ranges": 16, +} + + +def _limit(limits: Optional[dict], *path: str) -> Any: + """Un número del sobre vivo, con el suelo debajo. + + Se resuelve clave a clave y no de golpe: un shortsmith que publique un sobre + a medias — o uno más nuevo con una clave que aquí todavía no se lee — deja + las demás en su sitio en vez de tirar la validación entera al suelo. + """ + for source in (limits, BASELINE_LIMITS): + node: Any = source + for key in path: + if not isinstance(node, dict) or key not in node: + node = None + break + node = node[key] + if node is not None: + return node + raise KeyError(f"el sobre no publica {'.'.join(path)}") + #: El objetivo editorial, que NO es el techo del contrato. 180 s es lo que el #: renderizador acepta; 20-45 s es lo que se ve entero. @@ -296,24 +335,27 @@ def _check_cross_field(template: str, props: Any, path: str) -> list[str]: # --- el sobre --------------------------------------------------------------- -def _check_meta(meta: Any) -> list[str]: +def _check_meta(meta: Any, limits: Optional[dict] = None) -> list[str]: if not isinstance(meta, dict): return ["meta: se esperaba un objeto"] errors = [] spec_id = meta.get("id") - if not isinstance(spec_id, str) or not META_ID.match(spec_id): + pattern = re.compile(_limit(limits, "meta_id_pattern")) + if not isinstance(spec_id, str) or not pattern.match(spec_id): errors.append("meta.id: minúsculas, dígitos, '_' y '-', empezando por " f"letra o dígito, hasta 64 caracteres (llegó {spec_id!r})") if not isinstance(meta.get("title"), str) or not meta.get("title"): errors.append("meta.title: obligatorio y no vacío") width = meta.get("width", 1080) height = meta.get("height", 1920) - if (width, height) not in RESOLUTIONS: - allowed = ", ".join(f"{w}x{h}" for w, h in sorted(RESOLUTIONS)) + resolutions = [tuple(pair) for pair in _limit(limits, "resolutions")] + if (width, height) not in resolutions: + allowed = ", ".join(f"{w}x{h}" for w, h in sorted(resolutions)) errors.append(f"meta: {width}x{height} no es una resolución admitida ({allowed})") - if meta.get("fps", 30) not in FPS_VALUES: + fps_values = _limit(limits, "fps") + if meta.get("fps", 30) not in fps_values: errors.append(f"meta.fps: {meta.get('fps')!r} no está entre " - f"{sorted(FPS_VALUES)}") + f"{sorted(fps_values)}") for key in meta: if key not in ("id", "title", "width", "height", "fps", "theme"): errors.append(f"meta.{key}: campo no permitido") @@ -327,7 +369,8 @@ BASELINE_PRESET_NAMES = ("sonar", "none") def _check_audio(audio: Any, total: float, - presets: Optional[Iterable[str]] = None) -> list[str]: + presets: Optional[Iterable[str]] = None, + limits: Optional[dict] = None) -> list[str]: if audio is None: return [] if not isinstance(audio, dict): @@ -340,8 +383,9 @@ def _check_audio(audio: Any, total: float, silence = audio.get("silence", []) if not isinstance(silence, list): return errors + ["audio.silence: se esperaba una lista de pares [inicio, fin]"] - if len(silence) > 16: - errors.append(f"audio.silence: {len(silence)} rangos, el máximo es 16") + max_ranges = _limit(limits, "max_silence_ranges") + if len(silence) > max_ranges: + errors.append(f"audio.silence: {len(silence)} rangos, el máximo es {max_ranges}") for i, rango in enumerate(silence): if not (isinstance(rango, (list, tuple)) and len(rango) == 2 and all(isinstance(v, (int, float)) for v in rango)): @@ -361,19 +405,17 @@ def _check_audio(audio: Any, total: float, return errors -#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla -#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero. -MAX_NARRATION_CHARS = 320 - - -def _check_narration(narration: Any, path: str) -> list[str]: +def _check_narration(narration: Any, path: str, + limits: Optional[dict] = None) -> list[str]: + """El tope de narración de un shot, el mismo que aplica shortsmith. + Rechazarla aquí cuesta un reintento del modelo; allí, el render entero.""" if narration is None: return [] if not isinstance(narration, str): return [f"{path}.narration: se esperaba texto"] - if len(narration) > MAX_NARRATION_CHARS: - return [f"{path}.narration: {len(narration)} caracteres, el máximo es " - f"{MAX_NARRATION_CHARS}"] + cap = _limit(limits, "max_narration_chars") + if len(narration) > cap: + return [f"{path}.narration: {len(narration)} caracteres, el máximo es {cap}"] return [] @@ -386,15 +428,18 @@ def _total_duration(spec: dict) -> float: def validate_spec(spec: Any, templates: dict[str, dict], - presets: Optional[Iterable[str]] = None) -> None: + presets: Optional[Iterable[str]] = None, + limits: Optional[dict] = 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. + Las tres mitades del contrato llegan vivas y ninguna se escribe aquí: + `templates` de `GET /templates`, `presets` de `GET /audio` y `limits` de + `GET /limits`. Sin cada una se cae a su base, que nunca acepta nada que un + shortsmith viejo no renderice. """ errors: list[str] = [] if not isinstance(spec, dict): @@ -407,15 +452,18 @@ def validate_spec(spec: Any, templates: dict[str, dict], errors.append(f"{key}: campo no permitido en la raíz " "(las válidas son: version, meta, audio, shots)") - errors.extend(_check_meta(spec.get("meta"))) + errors.extend(_check_meta(spec.get("meta"), limits)) shots = spec.get("shots") if not isinstance(shots, list) or not shots: errors.append("shots: hace falta al menos un shot") raise SpecInvalid(errors) - if len(shots) > MAX_SHOTS: - errors.append(f"shots: {len(shots)} shots, el máximo es {MAX_SHOTS}") + max_shots = _limit(limits, "shots", "max") + if len(shots) > max_shots: + errors.append(f"shots: {len(shots)} shots, el máximo es {max_shots}") + shot_min = _limit(limits, "shot_duration", "min") + shot_max = _limit(limits, "shot_duration", "max") known = ", ".join(sorted(templates)) for i, shot in enumerate(shots): path = f"shots.{i}" @@ -431,13 +479,13 @@ def validate_spec(spec: Any, templates: dict[str, dict], if key not in ("template", "duration", "props", "narration"): errors.append(f"{path}.{key}: campo no permitido " "(las válidas son: template, duration, props, narration)") - errors.extend(_check_narration(shot.get("narration"), path)) + errors.extend(_check_narration(shot.get("narration"), path, limits)) duration = shot.get("duration") if not isinstance(duration, (int, float)) or isinstance(duration, bool): errors.append(f"{path}.duration: obligatoria y numérica") - elif not MIN_SHOT_DURATION <= duration <= MAX_TOTAL_DURATION: + elif not shot_min <= duration <= shot_max: errors.append(f"{path}.duration: {duration} fuera de " - f"[{MIN_SHOT_DURATION}, {MAX_TOTAL_DURATION}]") + f"[{shot_min}, {shot_max}]") if "props" not in shot: errors.append(f"{path}.props: falta y es obligatorio") continue @@ -450,13 +498,15 @@ def validate_spec(spec: Any, templates: dict[str, dict], errors.extend(_check_cross_field(template, shot["props"], props_path)) total = _total_duration(spec) - if total < MIN_TOTAL_DURATION: + total_min = _limit(limits, "total_duration", "min") + total_max = _limit(limits, "total_duration", "max") + if total < total_min: errors.append(f"shots: la duración total ({total:.2f}s) no llega al " - f"mínimo de {MIN_TOTAL_DURATION}s") - if total > MAX_TOTAL_DURATION: + f"mínimo de {total_min}s") + if total > total_max: 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, presets)) + f"de {total_max}s") + errors.extend(_check_audio(spec.get("audio"), total, presets, limits)) if errors: raise SpecInvalid(errors) diff --git a/tests/test_shortsmith_live.py b/tests/test_shortsmith_live.py index 2ea7762..4ed2aa3 100644 --- a/tests/test_shortsmith_live.py +++ b/tests/test_shortsmith_live.py @@ -56,6 +56,46 @@ async def test_healthz_and_templates(): assert sin_tope == [], f"campos con x-fits pero sin x-fits-hard: {sin_tope}" +@pytest.mark.asyncio +async def test_the_envelope_served_is_the_envelope_validated_against(): + """El sobre, contra el servicio de verdad — que es donde derivó. + + La copia local decía «pequeña y estable» y aceptaba 1920x1080 días después + de que shortsmith dejara de renderizarlo. Un test de unidad no podía verlo: + los dos lados eran coherentes consigo mismos. Esto compara con el servido. + + Contra un shortsmith anterior a `GET /limits` el cliente devuelve el suelo + sin error, y entonces esto sólo comprueba que el suelo es el estrecho — que + es exactamente lo que se quiere de un fallback. + """ + from src.generator.spec_contract import BASELINE_LIMITS, validate_spec, SpecInvalid + + client = ShortsmithClient(LIVE_URL) + limits = await client.limits(refresh=True) + + resoluciones = [tuple(pair) for pair in limits["resolutions"]] + assert (1080, 1920) in resoluciones, "el servicio ya no acepta el vertical" + + # Lo que el sobre NO trae, el validador local tiene que rechazarlo. Si + # shortsmith deja de aceptar una resolución, esto falla el día que pasa y no + # la generación siguiente. + spec = json.loads(EXAMPLE.read_text()) + templates = await client.templates(refresh=True) + for size in [(1920, 1080), (1080, 1080)]: + if size in resoluciones: + continue + doc = json.loads(json.dumps(spec)) + doc["meta"]["width"], doc["meta"]["height"] = size + with pytest.raises(SpecInvalid): + validate_spec(doc, templates, limits=limits) + + # Y el suelo nunca puede ser más ancho que lo servido: si lo fuera, un + # shortsmith caído dejaría pasar specs que el vivo rechaza. + suelo = {tuple(pair) for pair in BASELINE_LIMITS["resolutions"]} + assert suelo <= set(resoluciones), ( + f"el suelo {sorted(suelo)} acepta más que el servicio {sorted(resoluciones)}") + + @pytest.mark.asyncio async def test_render_the_reference_example_end_to_end(tmp_path): client = ShortsmithClient(LIVE_URL) diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index ac8b301..4b5bf51 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -80,6 +80,12 @@ def spec_with(*shots, **meta): } +def errors_of_with_limits(spec, limits): + with pytest.raises(SpecInvalid) as exc: + validate_spec(spec, TEMPLATES, limits=limits) + return exc.value.errors + + def errors_of(spec, templates=None): with pytest.raises(SpecInvalid) as exc: validate_spec(spec, templates if templates is not None else TEMPLATES) @@ -171,6 +177,54 @@ def test_resolution_must_be_a_shorts_one(): for e in errors_of(spec_with(shot(), width=800, height=600))) +def test_landscape_is_rejected_even_without_the_live_envelope(): + """El fallo que dio origen a `GET /limits`, clavado. + + shortsmith quitó 1920x1080 en `358eec9` porque ninguna plantilla lo componía. + Esta copia siguió aceptándolo, así que un spec apaisado pasaba aquí y moría + en el servidor: una generación pagada y tirada. El suelo tiene que ser el + estrecho — si alguien lo vuelve a ensanchar «por compatibilidad», esto falla. + """ + assert any("no es una resolución admitida" in e + for e in errors_of(spec_with(shot(), width=1920, height=1080))) + + +def test_the_live_envelope_wins_over_the_floor(): + """El suelo es suelo, no verdad: lo que diga `GET /limits` manda. + + Con un shortsmith que publique el apaisado, el mismo spec pasa sin tocar una + línea de este repo — que es exactamente lo que `GET /templates` hace ya con + una plantilla nueva. + """ + apaisado = spec_with(shot(duration=25.0), width=1920, height=1080) + with pytest.raises(SpecInvalid): + validate_spec(apaisado, TEMPLATES) + validate_spec(apaisado, TEMPLATES, + limits={"resolutions": [[1080, 1920], [1920, 1080]]}) + + +def test_a_partial_envelope_falls_back_key_by_key(): + """Un sobre a medias no tira la validación entera al suelo. + + Un shortsmith que publique menos claves de las que se leen aquí — o una + respuesta recortada — deja las demás en su sitio. Se resuelve clave a clave + justamente para eso. + """ + solo_fps = {"fps": [30]} + validate_spec(spec_with(shot(duration=25.0)), TEMPLATES, limits=solo_fps) + assert any("no es una resolución admitida" in e + for e in errors_of_with_limits( + spec_with(shot(), width=1920, height=1080), solo_fps)) + + +def test_the_envelope_also_moves_the_caps_that_are_not_resolution(): + """Resolución es la que se rompió, pero el sobre entero llega vivo.""" + corto = spec_with(shot(duration=6.0)) # 6 s: válido con el suelo + validate_spec(corto, TEMPLATES) + errors = errors_of_with_limits(corto, {"total_duration": {"min": 20.0, "max": 30.0}}) + assert any("no llega al" in e and "20.0" in e for e in errors) + + def test_total_duration_ceiling_is_the_contract_not_the_target(): """45 s es el objetivo editorial; 180 s es el límite duro. Pasarse de 45 no invalida el spec — eso es una nota, no un error."""