fix(short): el primer plano gastaba el gancho en la fecha
El gancho de un Short es lo que se DIBUJA, no solo lo que se dice. El prompt llevaba la mitad hablada desde 4b («the first line is the whole hook») y no tenía nada sobre la mitad vista. De los diecisiete short_en generados, once abren mal: diez ponen la fecha en el titular —el texto más grande del vídeo— mientras el subline de debajo ya lleva el sitio, y el output 131 abre con document_quote. Medido sobre el renderizador a 5,5 s de plano: las cinco plantillas con `headline` lo ponen a tinta plena en 0,33-0,40 s, y shortsmith lo garantiza a cualquier duración desde db1ac7e. Las tres que no lo tienen dejan la banda superior del fotograma al nivel del fondo TODO el plano —su texto se escribe a máquina más abajo y no está entero hasta 2,6-2,8 s—. Con 6,9 s de visionado medio, abrir con una de esas regala el tercio de la ventana en el que se decide todo. La costura es `headline`, preguntada al esquema y no a una lista de nombres: una plantilla nueva de shortsmith con titular podrá abrir sin tocar este repo, y una sin él no podrá. El mismo pacto que el resto del contrato. Y `_closer_to_target` ordenaba SOLO por segundos, lo que hacía decorativo el aviso nuevo: una reescritura que arreglaba el titular pero se pasaba un segundo perdía contra el intento que abría con una fecha. Ahora el gancho va delante — los segundos son un gradiente, el gancho es binario. Comprobado contra los 17 specs reales de producción: avisa de once y de ninguno más. Los que pasan son los titulares que se quieren (62 CHILDREN, 23 HELICOPTERS, RELEASE 05). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -277,7 +277,7 @@ class ShortProducer:
|
||||
logger.warning("Rerender rechazado por el contrato",
|
||||
session_id=session_id, errors=e.errors[:6])
|
||||
return result
|
||||
result.notes = editorial_notes(spec)
|
||||
result.notes = editorial_notes(spec, templates)
|
||||
|
||||
result.title = spec.get("meta", {}).get("title", topic)
|
||||
result.duration_s = sum(s.get("duration", 0) for s in spec["shots"])
|
||||
|
||||
@@ -23,7 +23,7 @@ import structlog
|
||||
|
||||
from src.generator.spec_contract import (
|
||||
SpecInvalid, describe_templates, editorial_notes, estimated_duration,
|
||||
max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
|
||||
max_words_in, opens_badly, validate_spec, NARRATION_ROUNDED_PAD,
|
||||
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
|
||||
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
|
||||
)
|
||||
@@ -215,6 +215,26 @@ compose rather than a label you copy, which makes it the easiest place to \
|
||||
slip in a figure no source gave you — and it is checked exactly like the rest.
|
||||
- The closing shot carries the domain, uppercase, no protocol: {domain}
|
||||
|
||||
# 3c. The opening shot
|
||||
|
||||
The first shot is the hook, and the hook is what is *drawn*, not only what is \
|
||||
said. Both of these are measured on the renderer you are writing for:
|
||||
|
||||
- **Open with a template that has a `headline`.** Those put a display-size line \
|
||||
across the top of the frame within 0.4 seconds, whatever length you give the \
|
||||
shot. The templates without one draw nothing up there at all: their content \
|
||||
types in lower down and is not complete until about 2.6 seconds. The average \
|
||||
view of a Short on this channel is under seven seconds, so opening with one of \
|
||||
those spends a third of it on a frame that has not said anything yet. Those \
|
||||
templates are good shots; they are not opening shots.
|
||||
- **The headline carries the strangest concrete thing you have — a count, a \
|
||||
quantity, an object — and never the date.** The date and the place have a home \
|
||||
one size down in `subline`, and that is the right size for them. A headline \
|
||||
reading "8 JAN 1981" tells someone who has not decided to watch anything at \
|
||||
all; "62 CHILDREN" over a subline of "ONE SILVER CRAFT" tells them what the \
|
||||
video is. Both are real openings from this channel, and the second one is the \
|
||||
shape to copy.
|
||||
|
||||
# 4. Grounding — this is the part that matters
|
||||
|
||||
Every figure, quote, date, and proper noun in your spec must appear in the \
|
||||
@@ -439,17 +459,27 @@ def _off_target(spec: dict) -> float:
|
||||
return max(0.0, TARGET_MIN_DURATION - total, total - TARGET_MAX_DURATION)
|
||||
|
||||
|
||||
def _closer_to_target(a: Optional[SpecResult], b: SpecResult) -> SpecResult:
|
||||
"""De dos specs válidos, el que menos se sale del objetivo.
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
if a is None:
|
||||
return b
|
||||
return a if _off_target(a.spec) <= _off_target(b.spec) else b
|
||||
def rank(r: SpecResult) -> tuple[bool, float]:
|
||||
return (opens_badly(r.spec, templates), _off_target(r.spec))
|
||||
return a if rank(a) <= rank(b) else b
|
||||
|
||||
|
||||
#: (system, prompt) -> texto del modelo.
|
||||
@@ -539,7 +569,7 @@ class ShortSpecWriter:
|
||||
error=str(refresh_err))
|
||||
continue
|
||||
|
||||
notes = editorial_notes(spec)
|
||||
notes = editorial_notes(spec, self.templates)
|
||||
result = SpecResult(spec=spec, attempts=attempt, notes=notes,
|
||||
history=list(history))
|
||||
if not notes:
|
||||
@@ -547,7 +577,7 @@ class ShortSpecWriter:
|
||||
shots=len(spec.get("shots", [])), notes=0)
|
||||
return result
|
||||
|
||||
best = _closer_to_target(best, result)
|
||||
best = _closer_to_target(best, result, self.templates)
|
||||
if note_rounds < NOTE_ATTEMPTS and attempt < MAX_ATTEMPTS:
|
||||
# Nota editorial, no violación del contrato: se comenta y, si
|
||||
# insiste, se renderiza el intento que menos se pase.
|
||||
|
||||
@@ -27,6 +27,8 @@ __all__ = [
|
||||
"SpecInvalid",
|
||||
"validate_spec",
|
||||
"editorial_notes",
|
||||
"opening_notes",
|
||||
"opens_badly",
|
||||
"estimated_duration",
|
||||
"spoken_seconds",
|
||||
"sentence_count",
|
||||
@@ -569,14 +571,105 @@ def estimated_duration(spec: dict) -> float:
|
||||
return total
|
||||
|
||||
|
||||
def editorial_notes(spec: dict) -> list[str]:
|
||||
#: Meses como los escribe el modelo — el spec se genera en inglés — enteros y
|
||||
#: abreviados. Sólo sirven para reconocer una fecha, nunca para rechazar nada.
|
||||
_MONTHS = frozenset(
|
||||
"JANUARY FEBRUARY MARCH APRIL MAY JUNE JULY AUGUST SEPTEMBER OCTOBER "
|
||||
"NOVEMBER DECEMBER JAN FEB MAR APR JUN JUL AUG SEP SEPT OCT NOV DEC".split()
|
||||
)
|
||||
|
||||
#: Un número de cuatro cifras en rango de año. Un titular que es sólo "1947" es
|
||||
#: una fecha; uno que es sólo "62" es una cifra, y esa es exactamente la
|
||||
#: diferencia que decide si esto avisa.
|
||||
_YEAR = re.compile(r"\A(1[4-9]\d\d|20\d\d)\Z")
|
||||
|
||||
|
||||
def _headline_is_only_a_date(headline: str) -> bool:
|
||||
"""El titular no dice más que cuándo.
|
||||
|
||||
Pide dos cosas a la vez, y la segunda es la que evita el falso positivo que
|
||||
importa: que TODOS los tokens sean mes o número, y que haya un mes o un año
|
||||
entre ellos. "62 CHILDREN" tiene una palabra que no es ninguna de las dos y
|
||||
se salva por la primera; "62" a secas pasa la primera y se salva por la
|
||||
segunda, que es lo correcto — una cifra desnuda es justo el titular que se
|
||||
quiere.
|
||||
"""
|
||||
tokens = [t for t in re.split(r"[^A-Za-z0-9]+", headline.upper()) if t]
|
||||
if not tokens or not all(t in _MONTHS or t.isdigit() for t in tokens):
|
||||
return False
|
||||
return any(t in _MONTHS or _YEAR.match(t) for t in tokens)
|
||||
|
||||
|
||||
def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
||||
"""El primer plano es el gancho, y el gancho es lo que se DIBUJA.
|
||||
|
||||
Dos avisos, los dos medidos sobre este renderizador a 5,5 s de plano:
|
||||
|
||||
**Abrir con una plantilla que tenga `headline`.** Las cinco que lo tienen
|
||||
ponen una línea a tamaño de display en la banda superior del fotograma en
|
||||
0,33-0,40 s, y shortsmith lo garantiza a cualquier duración de plano desde
|
||||
`db1ac7e` (`draw.entrance`). En las tres que no lo tienen esa banda se queda
|
||||
en el nivel del fondo *todo el plano*: su contenido se escribe a máquina más
|
||||
abajo y no está entero hasta 2,6-2,8 s. Con 6,9 s de visionado medio, abrir
|
||||
con una de esas tres regala el tercio de la ventana en el que se decide todo.
|
||||
Ocurrió: la sesión del output 131 abrió con `document_quote` y su primera
|
||||
letra no aparecía hasta 1,33 s.
|
||||
|
||||
Deliberadamente NO es una lista de nombres. Se pregunta al esquema que
|
||||
publica shortsmith, así que una plantilla nueva con titular podrá abrir sin
|
||||
tocar esto y una sin él no podrá — el mismo pacto que el resto del contrato.
|
||||
|
||||
**Y el titular lleva la cifra, no la fecha.** De los once casos distintos
|
||||
generados hasta hoy, cinco abren con una fecha por titular mientras el
|
||||
`subline` de debajo ya lleva el sitio: el texto más grande del fotograma se
|
||||
gasta en metadatos. "8 JAN 1981" no le dice nada a quien aún no ha decidido
|
||||
quedarse; "62 CHILDREN" sobre "ONE SILVER CRAFT" le dice de qué va el vídeo.
|
||||
Los dos son titulares reales del canal.
|
||||
"""
|
||||
shots = spec.get("shots") or []
|
||||
if not shots or not isinstance(shots[0], dict):
|
||||
return []
|
||||
first = shots[0]
|
||||
schema = templates.get(first.get("template")) if isinstance(templates, dict) else None
|
||||
if not isinstance(schema, dict):
|
||||
# Plantilla desconocida: de eso ya se queja `validate_spec`, y con más
|
||||
# razón. Aquí callar es lo correcto — un aviso editorial sobre algo que
|
||||
# ni siquiera renderiza es ruido encima de un error.
|
||||
return []
|
||||
|
||||
if "headline" not in (schema.get("properties") or {}):
|
||||
return [f"el primer plano usa {first.get('template')!r}, que no dibuja "
|
||||
"titular: su texto se escribe a máquina y no está entero hasta "
|
||||
"pasados ~2,6s, que es cuando media audiencia ya se ha ido. Abre "
|
||||
"con una plantilla que tenga `headline` y deja ésta para más "
|
||||
"adelante en el vídeo"]
|
||||
|
||||
headline = (first.get("props") or {}).get("headline")
|
||||
if isinstance(headline, str) and _headline_is_only_a_date(headline):
|
||||
return [f"el titular del primer plano es sólo una fecha ({headline!r}): "
|
||||
"es el texto más grande del vídeo y el único que se ve antes de "
|
||||
"que decidan quedarse. Ponle la cifra o el objeto más raro que "
|
||||
"tengas y baja la fecha al `subline`, que es donde ya está el "
|
||||
"sitio"]
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
||||
"""Lo que no viola el contrato pero sí el encargo.
|
||||
|
||||
Va aparte de `validate_spec` justo porque no impide renderizar: un Short de
|
||||
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.
|
||||
"""
|
||||
notes = []
|
||||
# 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)
|
||||
declared = _total_duration(spec)
|
||||
total = estimated_duration(spec)
|
||||
stretched = total > declared + 0.5
|
||||
|
||||
Reference in New Issue
Block a user