feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s

El comprobador va primero, antes que el campo (fase 2 §12): la narración es
prosa que el modelo redacta, no una etiqueta que copia, y es donde se cuela
una cifra sin fuente. De paso, la huella de una cifra pasa a ser número +
unidad canónica: con la voz repitiendo la pantalla, '35,000 FT' y '35,000
feet' son el mismo dato y contarlos dos veces inflaría el informe del que
depende la revisión humana.

editorial_notes estima la duración CON la voz: la declarada es un suelo y sin
esto el modelo escribiría 40 s de shots, les colgaría narración y se enteraría
del Short de 65 s cuando ya está pagado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-06 15:38:34 +00:00
co-authored by Claude Fable 5
parent 93a506b636
commit a13c3062b6
9 changed files with 375 additions and 13 deletions
+34 -2
View File
@@ -168,6 +168,24 @@ class Claim:
def norm(self) -> str:
return normalize(self.text)
@property
def fingerprint(self) -> tuple:
"""Identidad del dato, no de su redacción — para no contar dos veces.
Una cifra se identifica por su número y su unidad CANÓNICA: "35,000 FT"
dibujado en pantalla y "35,000 feet" dicho en la narración son el mismo
dato, y con la narración esa coincidencia pasa a ser lo normal, no la
excepción. Contarlos por separado inflaría justo el informe del que
depende la revisión humana.
Lo demás se identifica por su forma normalizada: una cita reformulada
NO es la misma cita, y ahí la literalidad es el criterio correcto.
"""
if self.kind == "figure":
number = normalize(self.text.split()[0]) if self.text.split() else ""
return ("figure", number, self.unit)
return (self.kind, self.norm)
_QUOTED = re.compile(r'["“„‟«]([^"“”„‟«»]{3,})'
r'["”„‟»]')
@@ -315,6 +333,16 @@ def extract_claims(spec: dict) -> list[Claim]:
`meta` queda fuera a propósito: el título del spec no se dibuja en ningún
fotograma, es el nombre del fichero.
`narration` SÍ entra, y es de lo más importante que entra: lo que se dibuja
en pantalla son etiquetas cortas que el modelo copia, pero la narración es
prosa que redacta — el sitio natural para deslizar una cifra de más. Se
trata como cualquier prosa del spec: aporta sus citas, cifras y fechas. No
aporta nombres propios, por lo mismo que no los aportan `headline` o
`caption`: una frase entera no es una etiqueta identificadora, y sacar
nombres de dentro de la prosa exigiría adivinar por mayúsculas y llenaría
el informe de ruido. La cifra y la cita, que son lo que se fabrica, están
cubiertas.
"""
claims: list[Claim] = []
for i, shot in enumerate(spec.get("shots") or []):
@@ -328,12 +356,16 @@ def extract_claims(spec: dict) -> list[Claim]:
# Reconstruye "232 FT" a partir de {value: 232, unit: "FT"}.
for path_prefix, sub in _dicts_with_value_and_unit(props, base):
shot_claims = _attach_units(sub, shot_claims, path_prefix)
narration = shot.get("narration")
if isinstance(narration, str):
shot_claims.extend(
_claims_from_text(narration, f"shots.{i}.narration", "narration"))
claims.extend(shot_claims)
seen: set[tuple[str, str]] = set()
seen: set[tuple] = set()
unique: list[Claim] = []
for claim in claims:
fingerprint = (claim.kind, claim.norm)
fingerprint = claim.fingerprint
if not claim.norm or fingerprint in seen:
continue
seen.add(fingerprint)
+25
View File
@@ -101,6 +101,31 @@ title a human reads, not a filename.
- version is 1. Keep meta at 1080x1920.
- audio.preset pick the one whose mood fits the shape you chose:
{presets}
# 3b. Narration — the voice-over
Every shot takes an optional `narration`: one or two spoken sentences, read \
aloud by the renderer and burned in as captions. Write it for the ear.
- **The first line is the whole hook.** Two seconds decide whether anyone \
watches the rest, and the opening shot's narration is those two seconds. Lead \
with the strangest true thing you have, not with a preamble.
- Keep a line under 25 words. Long sentences lose the listener and stretch the \
shot; the renderer will not cut your voice off, it will make the shot longer \
instead, and a Short that drifts past 45 seconds is a Short people leave.
- **Do not read the screen aloud.** The captions already show your words and \
the template already shows its own. If the shot draws "35,000 FT", the voice \
says what that altitude meant, not the number again.
- Spoken register, not caption register: normal sentence case, ordinary \
punctuation, whole words. The on-screen props are terse and uppercase; the \
narration is a person talking. Write "seventeenth of November" rather than \
"17 NOV" the voice reads exactly what you type, and it will say "one seven \
N-O-V" if you make it.
- Not every shot needs one. Silence over a single strong image is a choice, \
and a wall of continuous talking is not.
- Everything in section 4 applies to narration word for word. It is prose you \
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}
# 4. Grounding — this is the part that matters
+62 -7
View File
@@ -27,6 +27,7 @@ __all__ = [
"SpecInvalid",
"validate_spec",
"editorial_notes",
"estimated_duration",
"describe_templates",
"TARGET_MIN_DURATION",
"TARGET_MAX_DURATION",
@@ -224,6 +225,22 @@ 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]:
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}"]
return []
def _total_duration(spec: dict) -> float:
total = 0.0
for shot in spec.get("shots") or []:
@@ -275,9 +292,10 @@ def validate_spec(spec: Any, templates: dict[str, dict],
f"(las plantillas son: {known})")
continue
for key in shot:
if key not in ("template", "duration", "props"):
if key not in ("template", "duration", "props", "narration"):
errors.append(f"{path}.{key}: campo no permitido "
"(las válidas son: template, duration, props)")
"(las válidas son: template, duration, props, narration)")
errors.extend(_check_narration(shot.get("narration"), path))
duration = shot.get("duration")
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
errors.append(f"{path}.duration: obligatoria y numérica")
@@ -303,6 +321,36 @@ def validate_spec(spec: Any, templates: dict[str, dict],
raise SpecInvalid(errors)
#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale
#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí
#: lo que shortsmith sabrá exacto al sintetizar.
NARRATION_CHARS_PER_SECOND = 14.2
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
NARRATION_PAD = 0.45
def estimated_duration(spec: dict) -> float:
"""Lo que durará el vídeo, no lo que suman las duraciones declaradas.
Con narración, la duración declarada es un suelo: shortsmith estira el shot
si la frase no cabe. Sin esta estimación el modelo escribiría 40 s de shots,
les colgaría narración a todos y recibiría un Short de 55 s sin que nada le
hubiera avisado el aviso llegaría del render, cuando ya está pagado.
"""
total = 0.0
for shot in spec.get("shots") or []:
if not isinstance(shot, dict):
continue
declared = shot.get("duration")
declared = float(declared) if isinstance(declared, (int, float)) else 0.0
narration = shot.get("narration")
if isinstance(narration, str) and narration.strip():
spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD
declared = max(declared, spoken)
total += declared
return total
def editorial_notes(spec: dict) -> list[str]:
"""Lo que no viola el contrato pero sí el encargo.
@@ -311,15 +359,22 @@ def editorial_notes(spec: dict) -> list[str]:
si insiste, se renderiza igual antes que tirar la generación a la basura.
"""
notes = []
total = _total_duration(spec)
declared = _total_duration(spec)
total = estimated_duration(spec)
stretched = total > declared + 0.5
how = (f"la duración estimada son {total:.1f}s con la narración "
f"({declared:.1f}s de shots)" if stretched
else f"la duración total son {total:.1f}s")
if total < TARGET_MIN_DURATION:
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
notes.append(f"{how} y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
"queda corto, añade un shot o alarga los que tienes")
elif total > TARGET_MAX_DURATION:
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
"recorta shots o acorta duraciones")
fix = ("recorta narración: la voz manda sobre la duración declarada"
if stretched else "recorta shots o acorta duraciones")
notes.append(f"{how} y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}")
return notes