fix(short): los textos ilegibles se avisaban con el render ya pagado
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s

La nota que teníamos era falsa: los avisos `severe` de shortsmith SÍ llegan a
un humano — el informe de Telegram los saca en rojo. Lo que pasa es que llegan
colgados del render TERMINADO, así que hacerles caso significa editar el spec a
mano y pagar un segundo. Por eso nadie actuó nunca.

`x-fits` tampoco podía ser la comprobación, y a propósito: es guía blanda que el
propio ejemplo de referencia se salta por uno o tres caracteres viéndose bien.
Parar ahí sería gritar con specs buenos, y un aviso que grita se ignora — que es
justo cómo sobrevivieron los graves. Así que shortsmith publica desde 289d50e un
segundo número medido por campo, `x-fits-hard`, y esto lo comprueba antes de
gastar el render: cuesta un reintento del modelo en vez de un render. El mismo
movimiento que hizo `MAX_CUE_CHARS` con los captions en e32c59f.

Auditado contra los 17 short_en de producción: OCHO llevan al menos un texto que
se dibuja ilegible.

- El ya conocido, Cash-Landrum: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH
  RADIATION EXPOSURE`, 36 px pedidos y 20 dibujados. Salta con 63 caracteres
  contra un presupuesto de 61 — así de vertical es la curva pegada al muro.
- El que nadie había visto es peor y más común: el CARTEL DE CIERRE.
  `counter_close.lines` pide 110 px y se dibujó a 28 en el peor caso, y por
  debajo de 64 en cinco de los ocho. Es la llamada a la acción, y en un tercio
  del catálogo es el texto más pequeño del fotograma.
- Y la tolerancia aguanta sobre datos reales: `STILL UNEXPLAINED` encoge de 110
  a 84 px y no se avisa, que es lo correcto.

El desempate entre intentos pasa a contar averías —gancho y texto ilegible—
antes que segundos, por la misma razón que en 6d9b602: una reescritura que
arregla un rótulo pero se pasa un segundo perdía contra la que no lo arreglaba.

Un campo sin presupuesto propio (`Bar.unit`, que se dibuja dentro de la cadena
de `value_label`) se salta a conciencia: reconstruir esa cadena aquí pediría
conocer el formato de la plantilla, que es lo que este repo no sabe ni debe.

Y contra un shortsmith anterior a 289d50e esto se calla — no puede inventarse el
número —, así que de que el contrato traiga el campo se encarga
test_shortsmith_live.py, que es quien habla con el servicio.

Suite: 274 pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-09-01 15:31:05 +00:00
co-authored by Claude Opus 5
parent 6d9b6025ba
commit 198b0e6238
6 changed files with 330 additions and 38 deletions
+20 -14
View File
@@ -23,7 +23,7 @@ import structlog
from src.generator.spec_contract import (
SpecInvalid, describe_templates, editorial_notes, estimated_duration,
max_words_in, opens_badly, validate_spec, NARRATION_ROUNDED_PAD,
defect_notes, max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
)
@@ -136,12 +136,17 @@ headline. A {max_shot:.0f}-second shot with a short line on it is not a \
generous shot, it is a shot the viewer has already finished reading.
- Every string is drawn as given. Write them the way they should appear: \
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
- **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \
will catch for you.** Nothing rejects a longer string: the renderer shrinks the \
type until it fits, so a string at twice its budget is drawn at a fraction of \
its size and ends up the smallest text on a frame it was supposed to dominate. \
- **"CABE ~N caracteres dibujados" is a width, not a character count you can \
argue with.** Nothing rejects a longer string: the renderer shrinks the type \
until it fits, so a string at twice its budget is drawn at a fraction of its \
size and ends up the smallest text on a frame it was supposed to dominate. \
Stay at or under N. On a quote that means picking a shorter verbatim span, \
never squeezing the whole sentence in.
- **"ILEGIBLE por encima de M" is the line that is actually checked**, before \
anything renders. Between N and M the text is drawn a little smaller and looks \
fine — that tolerance is deliberate. Past M it is not a smaller headline, it is \
an unreadable one: one real caption asked for 36 px and was drawn at 20 on a \
1080-wide frame. Aim at N; M is the wall.
- Respect every max length and list-length limit above. They are enforced.
- Colours are palette names ({colors}) — never hex.
- Quotes carry the typographic quote marks: “SPLIT RADAR IMAGE”, with U+201C \
@@ -461,24 +466,25 @@ def _off_target(spec: dict) -> float:
def _closer_to_target(a: Optional[SpecResult], b: SpecResult,
templates: dict[str, dict]) -> SpecResult:
"""De dos specs válidos, el mejor: primero el gancho, luego la duración.
"""De dos specs válidos, el mejor: primero las averías, luego la duración.
Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que
obedecía la nota a medias — 53 s en vez de 58 — se tiraba entera y salía el
largo. El empate se lo lleva el anterior: sin razón para cambiar, no se
cambia.
El gancho va delante de los segundos, y no por gusto: ordenar sólo por
duración deja pasar el caso que hace inútil el aviso de apertura — un
segundo intento que arregla el titular pero se pasa un segundo perdería
contra el primero, y el modelo habría obedecido la nota para nada. Los
segundos fuera de objetivo son un gradiente; abrir con una fecha o con un
plano que aún se está escribiendo es binario, y cuesta más.
Las averías van delante de los segundos, y no por gusto: ordenar sólo por
duración deja pasar el caso que hace inútiles los avisos — un segundo
intento que arregla el titular, o acorta un rótulo que iba a salir
ilegible, pero se pasa un segundo, perdería contra el primero, y el modelo
habría obedecido la nota para nada. Los segundos fuera de objetivo son un
gradiente; abrir con una fecha, o dibujar un texto que no se lee, es
binario y cuesta más.
"""
if a is None:
return b
def rank(r: SpecResult) -> tuple[bool, float]:
return (opens_badly(r.spec, templates), _off_target(r.spec))
def rank(r: SpecResult) -> tuple[int, float]:
return (len(defect_notes(r.spec, templates)), _off_target(r.spec))
return a if rank(a) <= rank(b) else b
+122 -11
View File
@@ -28,7 +28,8 @@ __all__ = [
"validate_spec",
"editorial_notes",
"opening_notes",
"opens_badly",
"unreadable_notes",
"defect_notes",
"estimated_duration",
"spoken_seconds",
"sentence_count",
@@ -655,9 +656,113 @@ def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
return []
def opens_badly(spec: dict, templates: dict[str, dict]) -> bool:
"""Si el gancho está mal. Lo usa el desempate entre intentos válidos."""
return bool(opening_notes(spec, templates))
#: Cuántos textos ilegibles se nombran antes de resumir. Cinco caben en un
#: mensaje de Telegram y son de sobra para que el modelo entienda el patrón; la
#: lista entera sólo entierra el consejo.
MAX_NAMED_UNREADABLE = 5
def _budget_of(node: Any) -> Optional[int]:
"""`x-fits-hard` del nodo, resuelto por si el campo es una lista de textos."""
if not isinstance(node, dict):
return None
hard = node.get("x-fits-hard")
return hard if isinstance(hard, int) else None
def _too_long(value: Any, node: Any, path: str) -> list[tuple[str, int, int]]:
"""(ruta, longitud, presupuesto) de cada cadena que pasa de `x-fits-hard`."""
hard = _budget_of(node)
if hard is None:
return []
if isinstance(value, str):
return [(path, len(value), hard)] if len(value) > hard else []
if isinstance(value, list):
# El presupuesto de una lista de textos es POR LÍNEA: el campo se dibuja
# como varias líneas, no como una cadena unida. Medirlo sobre el total
# avisaría de una cita bien partida en dos.
return [(f"{path}[{i}]", len(v), hard)
for i, v in enumerate(value)
if isinstance(v, str) and len(v) > hard]
return []
def unreadable_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Los textos que shortsmith va a dibujar ilegibles, dichos ANTES del render.
`x-fits` es guía blanda y está bien que lo sea: el ejemplo de referencia se
pasa de varios de sus propios presupuestos por uno o tres caracteres y se ve
bien. Parar ahí sería gritar con specs buenos. `x-fits-hard` es la otra
línea que shortsmith publica desde 289d50e: pasada ella la cadena no es un
titular más pequeño, es uno ilegible, y ahí sí hay algo que decir.
El caso real: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION
EXPOSURE` pidió 36 px y se dibujó a **20** en un fotograma de 1080 de ancho.
El aviso existía —shortsmith lo manda con `severe`, y el informe de Telegram
lo saca en rojo— pero salía del render TERMINADO, y hacerle caso significaba
editar el spec a mano y pagar un segundo render. Comprobarlo aquí cuesta un
reintento del modelo. Es exactamente el mismo movimiento que `MAX_CUE_CHARS`
hizo con los captions.
Nota editorial y no error de contrato, y a propósito: los caracteres son un
proxy de los píxeles y el número es una medida sobre inglés en mayúsculas
realista, así que una cadena estrecha puede pasarse de la cuenta y caber.
Rechazarla sería el error que `x-fits` evita a conciencia.
Los campos con `x-fits-part-of` se saltan: no tienen presupuesto propio
porque se dibujan dentro de la cadena de otro (`Bar.unit` va en
`f"{value} {unit}"`), y reconstruir esa cadena aquí pediría conocer el
formato de la plantilla, que es justo lo que este repo no sabe ni debe.
"""
found: list[tuple[str, int, int]] = []
for i, shot in enumerate(spec.get("shots") or []):
if not isinstance(shot, dict):
continue
schema = templates.get(shot.get("template")) if isinstance(templates, dict) else None
if not isinstance(schema, dict):
continue
defs = schema.get("$defs", {})
props = shot.get("props")
if not isinstance(props, dict):
continue
for name, value in props.items():
node = (schema.get("properties") or {}).get(name)
if not isinstance(node, dict) or "x-fits-part-of" in node:
continue
path = f"shots.{i}.{name}"
found.extend(_too_long(value, node, path))
# Listas de objetos: el presupuesto vive en el submodelo.
item = _resolve(node.get("items", {}), defs) if node.get("type") == "array" else {}
if item.get("type") == "object" and isinstance(value, list):
for k, entry in enumerate(value):
if not isinstance(entry, dict):
continue
for sub, sub_value in entry.items():
sub_node = (item.get("properties") or {}).get(sub)
if isinstance(sub_node, dict) and "x-fits-part-of" not in sub_node:
found.extend(_too_long(sub_value, sub_node,
f"{path}[{k}].{sub}"))
if not found:
return []
named = ", ".join(f"{path} ({n} caracteres, caben {hard})"
for path, n, hard in found[:MAX_NAMED_UNREADABLE])
rest = f" y {len(found) - MAX_NAMED_UNREADABLE} más" if len(found) > MAX_NAMED_UNREADABLE else ""
return [f"{len(found)} texto{'s' if len(found) > 1 else ''} se va{'n' if len(found) > 1 else ''} "
"a dibujar ILEGIBLE: el renderizador encoge lo que no cabe y a esta "
"longitud queda por debajo de la mitad del tamaño de diseño. Acorta "
f"{named}{rest}"]
def defect_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
"""Lo que está MAL, frente a lo que está fuera de objetivo.
La distinción decide dos cosas: qué se dice primero y, sobre todo, cómo se
eligen los intentos entre sí. Un vídeo que se pasa cinco segundos se ve; uno
que abre con una fecha o lleva un rótulo ilegible no se arregla durando
menos.
"""
return opening_notes(spec, templates) + unreadable_notes(spec, templates)
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
@@ -667,9 +772,9 @@ def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez;
si insiste, se renderiza igual antes que tirar la generación a la basura.
"""
# Primero el gancho: si el vídeo se abre mal, es lo que hay que arreglar
# antes que su duración.
notes = opening_notes(spec, templates)
# Primero los defectos: si el vídeo abre mal o lleva un texto ilegible, eso
# va antes que su duración, que es un objetivo y no una avería.
notes = defect_notes(spec, templates)
declared = _total_duration(spec)
total = estimated_duration(spec)
stretched = total > declared + 0.5
@@ -747,12 +852,18 @@ def _describe_field(name: str, schema: dict, required: bool, defs: dict,
bits.append("no vacío")
if "maxLength" in schema:
bits.append(f"máx {schema['maxLength']} caracteres")
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por
# shortsmith contra sus propias fuentes. No se valida — los caracteres son
# un proxy de los píxeles — pero es lo único que evita que el modelo escriba
# una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible.
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño y `x-fits-hard`
# dónde deja de leerse, los dos medidos por shortsmith contra sus propias
# fuentes. Ninguno se valida — los caracteres son un proxy de los píxeles —
# pero el segundo sí se comprueba antes de renderizar (`unreadable_notes`),
# así que se le enseñan los dos: el objetivo y la línea roja. Sin el segundo,
# el modelo lee "~16" como una sugerencia sin consecuencia y escribe 58.
if "x-fits" in schema:
bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados")
if "x-fits-hard" in schema:
bits.append(f"ILEGIBLE por encima de {schema['x-fits-hard']}")
if "x-fits-part-of" in schema:
bits.append(f"se dibuja dentro de {schema['x-fits-part-of']}, comparte su sitio")
for key, text in (("minimum", ""), ("maximum", ""),
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
if key in schema: