diff --git a/src/generator/examples/jal1628.json b/src/generator/examples/jal1628.json index 8885ecd..b33ef98 100644 --- a/src/generator/examples/jal1628.json +++ b/src/generator/examples/jal1628.json @@ -139,7 +139,7 @@ "caption": "CONTACT HOLDS RELATIVE POSITION", "turn_deg": 360 }, - "narration": "He tried to shake it. Full circle, steep descent, and it was still there." + "narration": "He tried to shake it. Full circle, steep descent, and it stayed there." }, { "template": "signal_strips", @@ -207,7 +207,7 @@ "url": "THEEXCLUSIONZONE.COM", "show_mark": true }, - "narration": "The file was never closed. It was filed, and left where anyone can read it." + "narration": "The file was never closed. It was left where anyone can read it." } ] } diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index bc99b1f..c1a96fe 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -23,7 +23,8 @@ import structlog from src.generator.spec_contract import ( SpecInvalid, describe_templates, editorial_notes, estimated_duration, - validate_spec, NARRATION_WORDS_PER_SECOND, + max_words_in, validate_spec, NARRATION_ROUNDED_PAD, + NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND, TARGET_MAX_DURATION, TARGET_MIN_DURATION, ) @@ -49,20 +50,26 @@ NOTE_ATTEMPTS = 1 #: El ejemplo de referencia habla 74. La sesión 167 habló 97 y salió a 47,5 s. NARRATION_WORD_BUDGET = 80 -#: Lo que mide una línea. No es preferencia de estilo: son las líneas del -#: ejemplo (11, 12, 10, 14, 12, 15 palabras). El tope anterior — "menos de 25" — -#: no describía nada que el canal hubiera publicado, y el modelo escribió líneas -#: de 25 y 27 palabras sin saltarse ninguna regla. -NARRATION_WORDS_PER_LINE = 12 -NARRATION_WORDS_PER_LINE_MAX = 18 - -#: Lo que dura un plano como mucho. También del ejemplo (6,0 s el más largo, -#: 5,6 de media). Sin este tope el modelo declaraba 42 s en seis planos de siete +#: Lo que dura un plano como mucho. Del ejemplo (6,0 s el más largo, 5,6 de +#: media). Sin este tope el modelo declaraba 42 s en seis planos de siete #: segundos y LUEGO les colgaba la narración encima: el primer borrador salía a #: 50 s las tres veces que se midió, y hacía falta una reescritura entera para #: bajarlo. MAX_SHOT_DURATION = 6.0 +#: Lo que mide una línea. No es preferencia de estilo: es la media del ejemplo. +NARRATION_WORDS_PER_LINE = 12 + +#: Y el tope duro NO se escribe a mano: es cuántas palabras caben en el plano +#: más largo que se permite declarar. Escribirlos por separado fue el fallo que +#: hizo que el modelo infradeclarase casi todos sus planos, y no por pereza: el +#: prompt le pedía a la vez líneas de hasta 18 palabras, planos de 6 s como +#: mucho y tiempo declarado suficiente para su propia voz. Las tres a la vez son +#: imposibles — 18 palabras piden 7,3 s — así que el modelo rompía la única que +#: nadie comprobaba, la duración declarada. Derivando el tope de la duración, la +#: contradicción no puede volver. +NARRATION_WORDS_PER_LINE_MAX = max_words_in(MAX_SHOT_DURATION, sentences=2) + EXAMPLE_PATH = Path(__file__).parent / "examples" / "jal1628.json" __all__ = ["ShortSpecWriter", "SpecResult", "SpecWriteFailed", "NARRATIVE_SHAPES"] @@ -156,11 +163,12 @@ aloud by the renderer and burned in as captions. Write it for the ear. 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 to {words_per_line} words, hard stop at {words_per_line_max}.** \ -That is the example's own average, and it is not a style preference: a 25-word \ -line is three seconds of your whole budget spent on one shot. 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 {target_max:.0f} seconds is a \ -Short people leave. +That is the example's own average, and the hard stop is not a style preference \ +either — it is exactly as much as fits in the longest shot you are allowed to \ +declare. {words_per_line_max} words is {max_shot:.0f} seconds; the same limit, \ +written twice. Go past it and the shot has to grow, because the renderer will \ +not cut your voice off — it makes the shot longer instead, and a Short that \ +drifts past {target_max:.0f} 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. @@ -176,12 +184,24 @@ with one narrated shot out of eight is not a Short with a voice, it is a Short \ that forgot to speak. - **Give every narrated shot enough time for its own line, and work it out \ rather than guessing.** The voice reads about {words_per_second:.1f} words a \ -second and pauses a quarter second at every full stop, so: +second and pauses a quarter second at every full stop, so **count the words AND \ +count the sentences**: - duration ≥ words ÷ {words_per_second:.1f} + half a second + duration ≥ words ÷ {words_per_second:.1f} + {sentence_pause} × sentences + \ +{rounded_pad} - A twelve-word line needs five seconds; give that shot 5.0, not 4.0. This is \ -the one rule that makes your own arithmetic true: a shot runs for the LONGER of \ + The second term is the one that catches people out. "Witness identities. \ +Sensor details. Locations redacted." is six words and three full stops: it is \ +not a fast line, it is three quarters of a second of silence on top. Two lines \ +of the same length do not take the same time if one of them is chopped. + + Worked, on the example below: shot 1 speaks twelve words in one sentence, so \ +12 ÷ {words_per_second:.1f} + {sentence_pause} + {rounded_pad} = 5.1, and it \ +declares 5.5. Shot 4 speaks thirteen words in two sentences, so 13 ÷ \ +{words_per_second:.1f} + 0.5 + {rounded_pad} = 5.7, and it declares 6.0. Round \ +up, never down. + + This is the one rule that makes your own arithmetic true: a shot runs for the LONGER of \ its declared duration and its line — never shorter, the voice is never cut off \ — so a shot that declares less than its line silently grows, and the video ends \ up longer than the durations you wrote. Hold this rule and the total you \ @@ -466,6 +486,8 @@ class ShortSpecWriter: words_per_line=NARRATION_WORDS_PER_LINE, words_per_line_max=NARRATION_WORDS_PER_LINE_MAX, max_shot=MAX_SHOT_DURATION, + sentence_pause=f"{NARRATION_SENTENCE_SILENCE:g}", + rounded_pad=f"{NARRATION_ROUNDED_PAD:g}", example=_load_example(), article=article, context=context, diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index 040065a..cdadc4e 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -29,6 +29,9 @@ __all__ = [ "editorial_notes", "estimated_duration", "spoken_seconds", + "sentence_count", + "teachable_seconds", + "max_words_in", "describe_templates", "TARGET_MIN_DURATION", "TARGET_MAX_DURATION", @@ -350,11 +353,54 @@ NARRATION_PAD = 0.45 #: le pide que recorte texto, no tiempo. NARRATION_WORDS_PER_SECOND = 2.75 +#: El respiro redondeado hacia arriba, para la regla que se le enseña al modelo. +#: `NARRATION_PAD` son 0,45 s; "medio segundo" se sostiene en la cabeza y va +#: sobrado, que es la dirección correcta en la que equivocarse. +NARRATION_ROUNDED_PAD = 0.5 + #: 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 sentence_count(line: str) -> int: + """Frases de una línea, contadas como las cuenta Piper para sus pausas.""" + return max(1, len(_SENTENCE_END.findall(line))) if line.strip() else 0 + + +def teachable_seconds(words: int, sentences: int = 1) -> float: + """Lo que hay que DECLARAR para una línea, en las unidades que el modelo cuenta. + + Es `spoken_seconds` traducido de caracteres a palabras. La traducción hace + falta porque un LLM no cuenta caracteres de fiar, pero sí cuenta palabras y + puntos — y la regla tiene que ser computable por quien debe obedecerla, o no + es una regla, es un deseo. + + Los dos términos son los mismos que los de la voz. La versión anterior del + prompt colapsaba el segundo en un "+ medio segundo" fijo, y ese es el mismo + error de clase que tenía el estimador antes del 2026-08-12: sin pausa por + frase, una línea troceada en frases cortas se da por rápida. Medido contra + las 28 líneas reales, aquella regla se quedaba corta en 14 y hasta 2,27 s — + o sea que un modelo que la obedeciera al pie de la letra seguiría + infradeclarando la mitad de sus planos. Con el término por frase el peor + caso baja a 1,27 s y sólo en 5 de 28. + """ + return (words / NARRATION_WORDS_PER_SECOND + + sentences * NARRATION_SENTENCE_SILENCE + + NARRATION_ROUNDED_PAD) + + +def max_words_in(seconds: float, sentences: int = 1) -> int: + """Cuántas palabras caben en un plano de esa duración, según la regla de arriba. + + Existe para que el tope de palabras por línea y el tope de duración de plano + no puedan volver a contradecirse: se deriva uno del otro en vez de escribir + los dos a mano. + """ + room = seconds - sentences * NARRATION_SENTENCE_SILENCE - NARRATION_ROUNDED_PAD + return max(1, int(room * NARRATION_WORDS_PER_SECOND)) + + def spoken_seconds(line: str) -> float: """Lo que tarda la voz en decir una línea, sin el respiro final. diff --git a/tests/test_shortspec.py b/tests/test_shortspec.py index 1b4531f..b01e1db 100644 --- a/tests/test_shortspec.py +++ b/tests/test_shortspec.py @@ -134,7 +134,9 @@ def test_the_worked_example_narrates_most_of_its_shots(): def test_the_worked_example_declares_time_for_its_own_narration(): """Un plano que se queda corto para su propia voz enseña a infradeclarar: el render no corta la voz, alarga el plano, y el total se va del objetivo.""" - from src.generator.spec_contract import NARRATION_WORDS_PER_SECOND, spoken_seconds + from src.generator.spec_contract import ( + NARRATION_PAD, sentence_count, spoken_seconds, teachable_seconds, + ) example = json.loads(EXAMPLE.read_text()) for i, shot in enumerate(example["shots"]): @@ -142,10 +144,14 @@ def test_the_worked_example_declares_time_for_its_own_narration(): if not narration: continue # La cuenta que el prompt le pide al modelo, aplicada al ejemplo que le - # pone delante. Si no cuadran, la regla en prosa pierde. - rule = len(narration.split()) / NARRATION_WORDS_PER_SECOND + 0.5 - assert shot["duration"] >= rule, f"shot {i} declara menos de lo que habla" - assert shot["duration"] >= spoken_seconds(narration), \ + # pone delante. Si no cuadran, la regla en prosa pierde: el ejemplo es + # la señal más fuerte. Dos de estas seis líneas NO cumplían — y ese es + # exactamente el defecto que el modelo copiaba. + rule = teachable_seconds(len(narration.split()), sentence_count(narration)) + assert shot["duration"] >= rule - 1e-9, \ + f"shot {i} declara menos de lo que su propia regla pide" + # Y contra la voz medida, no sólo contra la regla que la aproxima. + assert shot["duration"] >= spoken_seconds(narration) + NARRATION_PAD - 1e-9, \ f"shot {i} se quedaría corto para su propia voz" @@ -187,6 +193,69 @@ def test_the_worked_example_obeys_the_budget_it_preaches(): assert round(sum(lines) / len(lines)) == NARRATION_WORDS_PER_LINE +def test_the_longest_line_allowed_fits_in_the_longest_shot_allowed(): + """La contradicción que hacía infradeclarar, convertida en test. + + El prompt pedía a la vez líneas de hasta 18 palabras, planos de 6 s como + mucho, y tiempo declarado suficiente para la propia voz. Las tres juntas son + imposibles — 18 palabras piden 7,3 s — y el modelo rompía la única que nadie + comprobaba. Si alguien vuelve a subir el tope de palabras a mano, esto salta. + """ + from src.generator.shortspec import ( + MAX_SHOT_DURATION, NARRATION_WORDS_PER_LINE_MAX, + ) + from src.generator.spec_contract import teachable_seconds + + # En el caso malo: una línea al tope, partida en dos frases (dos pausas). + assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX, 2) <= MAX_SHOT_DURATION + + # Y el tope es apretado, no una holgura cómoda que esconda otra vez el fallo: + # una palabra más ya no cabría. + assert teachable_seconds(NARRATION_WORDS_PER_LINE_MAX + 1, 2) > MAX_SHOT_DURATION + + +def test_the_prompt_rule_counts_the_pauses_and_not_only_the_words(): + """Palabras por segundo a secas es el mismo error que tenía el estimador. + + Medido contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 + + 0.5` se quedaba corta en 14 y hasta 2,27 s: obedecerla al pie de la letra + seguía infradeclarando media docena de planos. La regla del prompt tiene que + llevar el término por frase, y tiene que ser LA MISMA que aplican los tests. + """ + w, _ = writer("{}") + prompt = w.build_prompt("Caso X", "material", None, "X.TEST") + + assert "× sentences" in prompt + assert "count the sentences" in prompt + + # Una línea troceada cuesta más que una seguida con las mismas palabras. + from src.generator.spec_contract import teachable_seconds + assert teachable_seconds(12, 3) > teachable_seconds(12, 1) + + +def test_the_worked_arithmetic_in_the_prompt_is_actually_right(): + """Un ejemplo numérico equivocado enseña la cuenta equivocada, y se lee antes + que la fórmula.""" + import re as _re + from src.generator.spec_contract import sentence_count, teachable_seconds + + w, _ = writer("{}") + prompt = w.build_prompt("Caso X", "material", None, "X.TEST") + example = json.loads(EXAMPLE.read_text()) + + worked = _re.findall( + r"[Ss]hot (\d+) speaks \w+ words in \w+ sentences?, so [^=]+= ([\d.]+)", + prompt) + # Sin esto el test pasa en vacío si alguien reescribe el párrafo. + assert len(worked) == 2, f"no se encontraron las cuentas trabajadas: {worked}" + + for index, claimed in worked: + narration = example["shots"][int(index)]["narration"] + real = teachable_seconds(len(narration.split()), sentence_count(narration)) + assert abs(real - float(claimed)) < 0.05, \ + f"el prompt dice {claimed}s para shots.{index}, la regla da {real:.2f}s" + + def test_prompt_says_out_loud_that_there_is_no_article_yet(): w, _ = writer("{}") assert "No article URL yet" in w.build_prompt("X", "m", None, "X.TEST")