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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user