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:
@@ -86,6 +86,17 @@ What actually landed, and where it differs from the plan below:
|
|||||||
thing that order revealed: with the voice repeating on-screen figures, claims had to
|
thing that order revealed: with the voice repeating on-screen figures, claims had to
|
||||||
be de-duplicated by *canonical unit* ("35,000 FT" and "35,000 feet" are one claim) or
|
be de-duplicated by *canonical unit* ("35,000 FT" and "35,000 feet" are one claim) or
|
||||||
every narrated Short would double its own review report.
|
every narrated Short would double its own review report.
|
||||||
|
- **researchowl's estimate of the voice had to be measured, not assumed** (2026-08-12).
|
||||||
|
It shipped with 14.2 characters per second, taken from a single line, and that
|
||||||
|
overshot every narration by about a fifth — four to six seconds on a whole Short,
|
||||||
|
enough to make the spec writer rewrite videos that were already inside the target.
|
||||||
|
Every generation since narration shipped had spent all three attempts on it.
|
||||||
|
Synthesizing the 28 narration lines the bot had actually written gave 18.5 char/s
|
||||||
|
**plus 0.25 s at every full stop**, which is the term that matters: Piper's
|
||||||
|
`SENTENCE_SILENCE` is per sentence, so "Witness identities. Sensor details. Locations
|
||||||
|
redacted." costs three quarters of a second that a characters-only model gives away.
|
||||||
|
Estimates now land within half a second of the three rendered MP4s. The lesson is the
|
||||||
|
older one restated: a constant taken from one sample is a guess with a decimal point.
|
||||||
|
|
||||||
Original plan, kept for the record:
|
Original plan, kept for the record:
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ __all__ = [
|
|||||||
"validate_spec",
|
"validate_spec",
|
||||||
"editorial_notes",
|
"editorial_notes",
|
||||||
"estimated_duration",
|
"estimated_duration",
|
||||||
|
"spoken_seconds",
|
||||||
"describe_templates",
|
"describe_templates",
|
||||||
"TARGET_MIN_DURATION",
|
"TARGET_MIN_DURATION",
|
||||||
"TARGET_MAX_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_MIN_DURATION = 20.0
|
||||||
TARGET_MAX_DURATION = 45.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):
|
class SpecInvalid(Exception):
|
||||||
"""El spec no cumple el contrato. `errors` son rutas + motivo, verbatim."""
|
"""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)
|
raise SpecInvalid(errors)
|
||||||
|
|
||||||
|
|
||||||
#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale
|
#: Caracteres por segundo de la voz, sin contar las pausas. Medido el
|
||||||
#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí
|
#: 2026-08-12 sintetizando de verdad las 28 líneas de narración que el bot ha
|
||||||
#: lo que shortsmith sabrá exacto al sintetizar.
|
#: escrito hasta hoy con el mismo Piper y las mismas banderas que usa shortsmith
|
||||||
NARRATION_CHARS_PER_SECOND = 14.2
|
#: (`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.
|
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
|
||||||
NARRATION_PAD = 0.45
|
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:
|
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,
|
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
|
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.
|
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
|
total = 0.0
|
||||||
for shot in spec.get("shots") or []:
|
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
|
declared = float(declared) if isinstance(declared, (int, float)) else 0.0
|
||||||
narration = shot.get("narration")
|
narration = shot.get("narration")
|
||||||
if isinstance(narration, str) and narration.strip():
|
if isinstance(narration, str) and narration.strip():
|
||||||
spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD
|
declared = max(declared, spoken_seconds(narration) + NARRATION_PAD)
|
||||||
declared = max(declared, spoken)
|
|
||||||
total += declared
|
total += declared
|
||||||
return total
|
return total
|
||||||
|
|
||||||
@@ -366,18 +413,48 @@ def editorial_notes(spec: dict) -> list[str]:
|
|||||||
f"({declared:.1f}s de shots)" if stretched
|
f"({declared:.1f}s de shots)" if stretched
|
||||||
else f"la duración total son {total:.1f}s")
|
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 "
|
notes.append(f"{how} y el objetivo es "
|
||||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
||||||
"queda corto, añade un shot o alarga los que tienes")
|
"queda corto, añade un shot o alarga los que tienes")
|
||||||
elif total > TARGET_MAX_DURATION:
|
elif total > TARGET_MAX_DURATION + TARGET_GRACE:
|
||||||
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 "
|
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
|
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 -----------------------------------
|
# --- el contrato en prosa, para el prompt -----------------------------------
|
||||||
|
|
||||||
def _describe_field(name: str, schema: dict, required: bool, defs: dict,
|
def _describe_field(name: str, schema: dict, required: bool, defs: dict,
|
||||||
|
|||||||
+107
-2
@@ -283,15 +283,73 @@ def test_an_unknown_shot_key_still_names_the_valid_ones():
|
|||||||
assert any("narration" in e for e in errors_of(doc))
|
assert any("narration" in e for e in errors_of(doc))
|
||||||
|
|
||||||
|
|
||||||
|
#: Líneas de narración de specs que se renderizaron de verdad, con lo que tarda
|
||||||
|
#: Piper en decirlas. Medido el 2026-08-12 con el binario, el modelo y las
|
||||||
|
#: banderas de shortsmith (`en_US-lessac-medium`, length_scale 1.0,
|
||||||
|
#: --noise_scale 0 --noise_w 0), que son deterministas: estos segundos se
|
||||||
|
#: reproducen. Se eligieron los extremos del muestreo de 28 líneas — la más
|
||||||
|
#: rápida, la más lenta y las dos más largas — porque son las que rompen un
|
||||||
|
#: modelo mal calibrado; la media la aguanta cualquiera.
|
||||||
|
MEASURED = [
|
||||||
|
("Eight FBI witness interviews. Five digital renderings. All describe the "
|
||||||
|
"same shape flying across America for twenty-four years.", 7.809),
|
||||||
|
("The files are public now, but sections remain blacked out. Witness "
|
||||||
|
"identities. Sensor details. Locations redacted.", 8.140),
|
||||||
|
("Three hundred seventy-eight files released. Hundreds of incidents "
|
||||||
|
"documented. And the government still cannot explain what those shapes "
|
||||||
|
"were.", 7.681),
|
||||||
|
("The files came out. The numbers stayed classified.", 3.310),
|
||||||
|
("Nothing should have been able to hold station beside them up there.", 3.396),
|
||||||
|
("The Air Force's own investigators called it unexplained.", 2.990),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("line,real", MEASURED)
|
||||||
|
def test_the_estimate_lands_within_a_second_of_the_voice(line, real):
|
||||||
|
"""La estimación es lo único que separa un aviso útil de una reescritura
|
||||||
|
inventada, así que se contrasta contra audio medido, no contra sí misma.
|
||||||
|
|
||||||
|
El margen es un segundo. Más apretado sería falso — esto estima, no
|
||||||
|
sintetiza — y más ancho deja de decir nada: el error del modelo anterior
|
||||||
|
sobre un Short entero era de cuatro a seis segundos, y de ahí salían los
|
||||||
|
tres intentos que se gastaban en cada generación.
|
||||||
|
"""
|
||||||
|
from src.generator.spec_contract import spoken_seconds
|
||||||
|
|
||||||
|
assert spoken_seconds(line) == pytest.approx(real, abs=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_line_of_short_sentences_is_not_taken_for_fast_prose():
|
||||||
|
"""Piper calla un cuarto de segundo en cada punto. Cuatro frases cortas son
|
||||||
|
un segundo de silencio, y contarlas como texto corrido las da por rápidas:
|
||||||
|
es el caso donde más se equivocaba el modelo de sólo caracteres."""
|
||||||
|
from src.generator.spec_contract import spoken_seconds
|
||||||
|
|
||||||
|
chopped = "The files are public now, but sections remain blacked out. " \
|
||||||
|
"Witness identities. Sensor details. Locations redacted."
|
||||||
|
flowing = "The files are public now but sections remain blacked out with " \
|
||||||
|
"witness identities sensor details and locations redacted"
|
||||||
|
|
||||||
|
assert len(chopped) < len(flowing)
|
||||||
|
assert spoken_seconds(chopped) > spoken_seconds(flowing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_decimal_point_is_not_the_end_of_a_sentence():
|
||||||
|
from src.generator.spec_contract import spoken_seconds
|
||||||
|
|
||||||
|
assert spoken_seconds("It climbed to 1.5 miles") == \
|
||||||
|
pytest.approx(spoken_seconds("It climbed to 155 miles"))
|
||||||
|
|
||||||
|
|
||||||
def test_the_estimate_counts_the_voice_not_just_the_declared_seconds():
|
def test_the_estimate_counts_the_voice_not_just_the_declared_seconds():
|
||||||
"""La duración declarada es un suelo: shortsmith estira el shot si la frase
|
"""La duración declarada es un suelo: shortsmith estira el shot si la frase
|
||||||
no cabe, y el modelo tiene que enterarse ANTES de pagar el render."""
|
no cabe, y el modelo tiene que enterarse ANTES de pagar el render."""
|
||||||
from src.generator.spec_contract import estimated_duration
|
from src.generator.spec_contract import estimated_duration
|
||||||
|
|
||||||
doc = spec_with(shot(duration=3.0))
|
doc = spec_with(shot(duration=3.0))
|
||||||
doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz
|
doc["shots"][0]["narration"] = MEASURED[0][0] # 7,81 s de voz medidos
|
||||||
|
|
||||||
assert estimated_duration(doc) > 10.0
|
assert estimated_duration(doc) > 8.0
|
||||||
|
|
||||||
|
|
||||||
def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
|
def test_a_shot_with_room_for_its_line_is_estimated_as_declared():
|
||||||
@@ -320,6 +378,53 @@ def test_narration_that_overshoots_the_target_is_flagged_as_narration():
|
|||||||
assert "narración" in note and "estimada" in note
|
assert "narración" in note and "estimada" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_over_the_target_is_not_worth_a_rewrite():
|
||||||
|
"""El objetivo sigue siendo 45 s, pero la estimación tiene un segundo de
|
||||||
|
error por línea: avisar por medio segundo es avisar del estimador. Caso
|
||||||
|
real — la sesión 168 salió a 45,4 s y se pagó una generación por ello."""
|
||||||
|
from src.generator.spec_contract import TARGET_GRACE, TARGET_MAX_DURATION
|
||||||
|
|
||||||
|
justo = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE - 0.1))
|
||||||
|
pasado = spec_with(shot(duration=TARGET_MAX_DURATION + TARGET_GRACE + 0.1))
|
||||||
|
|
||||||
|
assert editorial_notes(justo) == []
|
||||||
|
assert editorial_notes(pasado)
|
||||||
|
# Y el consejo se mide contra el objetivo, no contra el margen: se pide
|
||||||
|
# bajar hasta 45, no hasta 46,5.
|
||||||
|
assert "sobran 1.6s" in editorial_notes(pasado)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_grace_works_at_both_ends():
|
||||||
|
from src.generator.spec_contract import TARGET_GRACE, TARGET_MIN_DURATION
|
||||||
|
|
||||||
|
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
|
||||||
|
- TARGET_GRACE + 0.1))) == []
|
||||||
|
assert editorial_notes(spec_with(shot(duration=TARGET_MIN_DURATION
|
||||||
|
- TARGET_GRACE - 0.1)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_says_how_much_to_cut_and_from_where():
|
||||||
|
""""Recorta narración" no dice cuánta, y las tres veces que saltó este aviso
|
||||||
|
el modelo devolvió un spec que seguía pasándose. El exceso va en palabras
|
||||||
|
porque es lo que el modelo escribe, y señalando el plano que más habla."""
|
||||||
|
doc = spec_with(shot(duration=4.0), shot(duration=4.0))
|
||||||
|
doc["shots"][0]["narration"] = "Short line."
|
||||||
|
doc["shots"][1]["narration"] = " ".join(["word"] * 200)
|
||||||
|
|
||||||
|
note = editorial_notes(doc)[0]
|
||||||
|
|
||||||
|
assert "palabras de narración" in note
|
||||||
|
assert "shots.1" in note and "shots.0" not in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_advice_for_a_silent_spec_never_mentions_narration():
|
||||||
|
"""Sin voz, pedir que recorte narración es mandarlo a arreglar algo que no
|
||||||
|
existe: lo que sobra son duraciones declaradas."""
|
||||||
|
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
|
||||||
|
|
||||||
|
assert "narración" not in note and "duraciones declaradas" in note
|
||||||
|
|
||||||
|
|
||||||
def test_a_spec_without_narration_keeps_the_old_wording():
|
def test_a_spec_without_narration_keeps_the_old_wording():
|
||||||
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
|
note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0]
|
||||||
assert "duración total" in note and "estimada" not in note
|
assert "duración total" in note and "estimada" not in note
|
||||||
|
|||||||
Reference in New Issue
Block a user