fix(short): la voz se midió con una sola frase, y por eso el bot reescribía Shorts que ya cabían

`NARRATION_CHARS_PER_SECOND` era 14,2, sacado de una única línea de 82
caracteres. Sintetizando de verdad las 28 líneas que el bot ha escrito hasta
hoy — mismo Piper, mismo modelo, mismas banderas deterministas — la voz lee a
18,5 car/s y se calla 0,25 s en cada punto. Contar las frases aparte es lo que
arregla el caso raro: "Witness identities. Sensor details. Locations redacted."
son tres cuartos de segundo de silencio que un modelo de caracteres a secas
regala.

El error del modelo viejo era de cuatro a seis segundos sobre un Short entero,
siempre por arriba, y con eso el aviso de duración saltaba en vídeos que
estaban dentro del objetivo. Contrastado ahora contra los tres MP4 que hay
renderizados: 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40
reales.

Dos cosas más, del mismo tirón:

- Un margen de 1,5 s antes de avisar. La estimación acierta dentro de un
  segundo por línea, así que medio segundo de exceso puede ser del estimador y
  no del spec; la sesión 168 se llevó una generación entera por ochocientas
  milésimas. El objetivo sigue siendo 20-45.
- El consejo va en palabras, no en "recorta narración", y señala el plano que
  más habla. Las tres veces que saltó, el modelo devolvió un spec que seguía
  pasándose: no sabía cuánto.

Los segundos medidos entran en los tests como tabla, no como número redondo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-12 21:37:17 +00:00
co-authored by Claude Opus 5
parent 366ded1f59
commit 9bfa0fdac2
3 changed files with 206 additions and 13 deletions
+88 -11
View File
@@ -28,6 +28,7 @@ __all__ = [
"validate_spec",
"editorial_notes",
"estimated_duration",
"spoken_seconds",
"describe_templates",
"TARGET_MIN_DURATION",
"TARGET_MAX_DURATION",
@@ -47,6 +48,15 @@ META_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
TARGET_MIN_DURATION = 20.0
TARGET_MAX_DURATION = 45.0
#: Lo que se le perdona al objetivo antes de gastar una reescritura. La
#: estimación de la voz acierta dentro de un segundo por línea, así que un
#: exceso de medio segundo puede ser del estimador y no del spec — y una
#: reescritura cuesta cuatro céntimos y un minuto para ahorrar un segundo que
#: nadie ve. El objetivo sigue siendo 20-45: esto sólo decide cuándo vale la
#: pena decirlo. Sin este margen, la sesión 168 (45,8 s estimados) se llevaba
#: una generación entera por ochocientas milésimas.
TARGET_GRACE = 1.5
class SpecInvalid(Exception):
"""El spec no cumple el contrato. `errors` son rutas + motivo, verbatim."""
@@ -321,12 +331,47 @@ 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
#: Caracteres por segundo de la voz, sin contar las pausas. Medido el
#: 2026-08-12 sintetizando de verdad las 28 líneas de narración que el bot ha
#: escrito hasta hoy con el mismo Piper y las mismas banderas que usa shortsmith
#: (`en_US-lessac-medium`, length_scale 1.0, --noise_scale 0 --noise_w 0):
#: 2429 caracteres en 140,91 s de audio.
NARRATION_CHARS_PER_SECOND = 18.5
#: Piper añade este silencio DESPUÉS DE CADA FRASE, no sólo al final de la
#: línea, y es un valor que shortsmith fija a propósito (`voice.SENTENCE_SILENCE`).
#: Contarlo por separado es lo que arregla el caso raro: "Witness identities.
#: Sensor details. Locations redacted." son tres frases cortas que valen 0,75 s
#: de pausa, y un modelo de caracteres a secas las da por rápidas.
NARRATION_SENTENCE_SILENCE = 0.25
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
NARRATION_PAD = 0.45
#: Palabras por segundo de la misma medida (387 palabras en 140,91 s). Sólo se
#: usa para traducir un exceso de segundos a palabras en el aviso: al modelo se
#: le pide que recorte texto, no tiempo.
NARRATION_WORDS_PER_SECOND = 2.75
#: Final de frase: un punto pegado a la palabra y seguido de espacio o de nada.
#: El decimal de "1.5" no cuenta, y por eso mira lo que va detrás.
_SENTENCE_END = re.compile(r"[.!?](?=\s|$)")
def spoken_seconds(line: str) -> float:
"""Lo que tarda la voz en decir una línea, sin el respiro final.
Dos términos porque la voz tiene dos: lee a ritmo casi constante y se calla
un cuarto de segundo en cada punto. La versión anterior sólo tenía el
primero y con un ritmo medido sobre una única frase — 14,2 car/s —, así que
sobreestimaba cada línea alrededor de un 20 %. Sobre un Short entero eso son
de cuatro a seis segundos de duración que no existen, suficientes para que
el bucle de reescritura se disparara con vídeos que estaban dentro del
objetivo.
"""
line = " ".join(line.split())
if not line:
return 0.0
sentences = max(1, len(_SENTENCE_END.findall(line)))
return (len(line) / NARRATION_CHARS_PER_SECOND
+ sentences * NARRATION_SENTENCE_SILENCE)
def estimated_duration(spec: dict) -> float:
@@ -336,6 +381,9 @@ def estimated_duration(spec: dict) -> float:
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.
Contrastada contra los tres MP4 que hay renderizados (sesiones 166, 167 y
168): 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40 reales.
"""
total = 0.0
for shot in spec.get("shots") or []:
@@ -345,8 +393,7 @@ def estimated_duration(spec: dict) -> float:
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)
declared = max(declared, spoken_seconds(narration) + NARRATION_PAD)
total += declared
return total
@@ -366,18 +413,48 @@ def editorial_notes(spec: dict) -> list[str]:
f"({declared:.1f}s de shots)" if stretched
else f"la duración total son {total:.1f}s")
if total < TARGET_MIN_DURATION:
if total < TARGET_MIN_DURATION - TARGET_GRACE:
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:
fix = ("recorta narración: la voz manda sobre la duración declarada"
if stretched else "recorta shots o acorta duraciones")
elif total > TARGET_MAX_DURATION + TARGET_GRACE:
notes.append(f"{how} y el objetivo es "
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}")
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
+ _how_to_trim(spec, total - TARGET_MAX_DURATION, stretched))
return notes
def _how_to_trim(spec: dict, excess: float, stretched: bool) -> str:
"""El consejo, en la unidad en la que el modelo puede obedecerlo.
"Recorta narración" no dice cuánta, y las tres veces que se ha disparado
esto el modelo devolvió un spec que seguía pasándose. Un exceso en segundos
tampoco le sirve, porque no escribe segundos: escribe frases. Así que el
aviso va en palabras y señala DÓNDE están las más largas.
"""
if not stretched:
return (f"sobran {excess:.1f}s: recorta un shot o baja las duraciones "
"declaradas")
words = max(3, round(excess * NARRATION_WORDS_PER_SECOND))
advice = (f"sobran {excess:.1f}s, unas {words} palabras de narración — la voz "
"manda sobre la duración declarada, así que acortar los shots no "
"quita ni un segundo")
spoken = sorted(
((i, len((s.get("narration") or "").split()))
for i, s in enumerate(spec.get("shots") or []) if isinstance(s, dict)),
key=lambda pair: -pair[1])
spoken = [pair for pair in spoken if pair[1]]
if not spoken:
return advice
# Sólo las que de verdad son largas: señalar una línea de dos palabras al
# lado de una de veinte convierte el consejo en ruido.
named = [f"shots.{i} ({n} palabras)"
for i, n in spoken[:2] if n * 2 >= spoken[0][1]]
return advice + f"; {'las líneas más largas son' if len(named) > 1 else 'la línea más larga es'} {' y '.join(named)}"
# --- el contrato en prosa, para el prompt -----------------------------------
def _describe_field(name: str, schema: dict, required: bool, defs: dict,