From cfe4a6d75406143ec82c6732a9d977dadc70145b Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Thu, 6 Aug 2026 21:32:49 +0000 Subject: [PATCH] feat(short): el ejemplo del prompt habla, y por eso los specs vuelven a hablar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La primera generación con narración viva narró 5 de 7 planos. La siguiente, ya con la sección de presupuestos de ancho en el prompt, narró 1 de 8. La causa no era la sección 3b sino la 5: el ejemplo trabajado — un spec completo de 8 planos presentado como "el que produjo un buen vídeo" — no llevaba ni una narración, porque es anterior a la voz. Contra esa señal de formato, una regla en prosa que además dice "no todos los planos necesitan una" pierde siempre. Ahora el ejemplo narra 6 de sus 8 y calla en los dos que dibujan una cita, donde la voz sólo competiría con palabras que ya están en pantalla. Sus duraciones suben para pagar lo que habla: un plano que se queda corto para su propia voz enseñaría a infradeclarar, y el render no corta la voz, alarga el plano. La ventana de silencio se recoloca porque seguía a document_quote por tiempo. El aviso de ancho ya no cita el hueco de 16 caracteres de quote_a: shortsmith envuelve esas citas en dos líneas y el presupuesto vivo es 31. Co-Authored-By: Claude Fable 5 --- src/generator/examples/jal1628.json | 130 ++++++++++++++++++++++------ src/generator/shortspec.py | 22 +++-- tests/test_shortspec.py | 32 +++++++ 3 files changed, 150 insertions(+), 34 deletions(-) diff --git a/src/generator/examples/jal1628.json b/src/generator/examples/jal1628.json index 9ce0190..8885ecd 100644 --- a/src/generator/examples/jal1628.json +++ b/src/generator/examples/jal1628.json @@ -10,33 +10,61 @@ }, "audio": { "preset": "sonar", - "silence": [[31.0, 36.0]] + "silence": [ + [ + 33.5, + 38.5 + ] + ] }, "shots": [ { "template": "radar_sweep", - "duration": 4.0, + "duration": 6.0, "props": { "headline": "3 RADARS", "subline": "1 UNEXPLAINED RETURN", "contact_bearing_deg": 210, "sweeps": 2 - } + }, + "narration": "Something pulled alongside a seven forty-seven over Alaska, and stayed there." }, { "template": "track_map", - "duration": 4.0, + "duration": 5.5, "props": { "headline": "17 NOV 1986", "subline": "35,000 FT · 600 MPH", "waypoints": [ - {"label": "FORT YUKON", "lat": 66.57, "lon": -145.27}, - {"label": "FAIRBANKS", "lat": 64.84, "lon": -147.72}, - {"label": "TALKEETNA", "lat": 62.32, "lon": -150.11}, - {"label": "ANCHORAGE", "lat": 61.22, "lon": -149.90} + { + "label": "FORT YUKON", + "lat": 66.57, + "lon": -145.27 + }, + { + "label": "FAIRBANKS", + "lat": 64.84, + "lon": -147.72 + }, + { + "label": "TALKEETNA", + "lat": 62.32, + "lon": -150.11 + }, + { + "label": "ANCHORAGE", + "lat": 61.22, + "lon": -149.9 + } ], - "bounds": {"lat_min": 60.4, "lat_max": 67.4, "lon_min": -152.0, "lon_max": -143.5} - } + "bounds": { + "lat_min": 60.4, + "lat_max": 67.4, + "lon_min": -152.0, + "lon_max": -143.5 + } + }, + "narration": "Nothing should have been able to hold station beside them up there." }, { "template": "data_card", @@ -44,13 +72,26 @@ "props": { "card_title": "FLIGHT CREW", "rows": [ - {"key": "CAPT. KENJU TERAUCHI", "value": "PILOT IN COMMAND"}, - {"key": "EX-FIGHTER PILOT", "value": "JASDF"}, - {"key": "29 YEARS", "value": "FLYING EXPERIENCE"}, - {"key": "10,000+", "value": "FLIGHT HOURS"} + { + "key": "CAPT. KENJU TERAUCHI", + "value": "PILOT IN COMMAND" + }, + { + "key": "EX-FIGHTER PILOT", + "value": "JASDF" + }, + { + "key": "29 YEARS", + "value": "FLYING EXPERIENCE" + }, + { + "key": "10,000+", + "value": "FLIGHT HOURS" + } ], "footer": "REPORTS TWO LIGHTS PACING THE AIRCRAFT" - } + }, + "narration": "The man reporting it flew fighters before he flew airliners." }, { "template": "scale_bars", @@ -58,7 +99,12 @@ "props": { "headline": "REPORTED SCALE", "bars": [ - {"label": "BOEING 747", "value": 232, "unit": "FT", "color": "ink"}, + { + "label": "BOEING 747", + "value": 232, + "unit": "FT", + "color": "ink" + }, { "label": "ESTIMATED OBJECT", "value": 2000, @@ -67,7 +113,10 @@ "value_label": "~1,600 – 2,000 FT" } ], - "quote": ["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"], + "quote": [ + "“TWICE THE SIZE OF", + "AN AIRCRAFT CARRIER”" + ], "attribution": "— CAPT. TERAUCHI, ESTIMATE" } }, @@ -78,37 +127,60 @@ "headline": "EVASIVE MANEUVER", "subline": "360° TURN · −4,000 FT", "legend": [ - {"label": "JAL 1628", "color": "ink"}, - {"label": "UNIDENTIFIED CONTACT", "color": "amber"} + { + "label": "JAL 1628", + "color": "ink" + }, + { + "label": "UNIDENTIFIED CONTACT", + "color": "amber" + } ], "caption": "CONTACT HOLDS RELATIVE POSITION", "turn_deg": 360 - } + }, + "narration": "He tried to shake it. Full circle, steep descent, and it was still there." }, { "template": "signal_strips", - "duration": 7.0, + "duration": 6.0, "props": { "headline": "THREE INDEPENDENT SOURCES", "strips": [ { "label": "ONBOARD RADAR", "sublabel": "CONTACT 7–8 NM · 10 O'CLOCK", - "markers": [0.26, 0.48, 0.63, 0.81] + "markers": [ + 0.26, + 0.48, + 0.63, + 0.81 + ] }, { "label": "ANCHORAGE CENTER", "sublabel": "PRIMARY RETURNS THROUGH TURNS", - "markers": [0.26, 0.48, 0.63, 0.81] + "markers": [ + 0.26, + 0.48, + 0.63, + 0.81 + ] }, { "label": "ELMENDORF ROCC", "sublabel": "TRACKED “FLIGHT OF TWO”", - "markers": [0.26, 0.48, 0.63, 0.81] + "markers": [ + 0.26, + 0.48, + 0.63, + 0.81 + ] } ], "footnote": "FAIRBANKS RADAR: NOTHING" - } + }, + "narration": "He was not the only one seeing it. Though not everyone did." }, { "template": "document_quote", @@ -128,10 +200,14 @@ "props": { "count_to": 1500, "count_label": "PAGES OF FAA DOCUMENTATION", - "lines": ["40 YEARS", "STILL OPEN"], + "lines": [ + "40 YEARS", + "STILL OPEN" + ], "url": "THEEXCLUSIONZONE.COM", "show_mark": true - } + }, + "narration": "The file was never closed. It was filed, and left where anyone can read it." } ] } diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index 0c71318..3845b9e 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -92,10 +92,10 @@ a card with four rows needs longer than a headline. SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words. - **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \ will catch for you.** Nothing rejects a longer string: the renderer shrinks the \ -type until it fits, so a quote of 58 characters in a 16-character slot is drawn \ -at a quarter of its size and ends up the smallest text on a frame it was \ -supposed to dominate. Stay at or under N. On a quote that means picking a \ -shorter verbatim span, never squeezing the whole sentence in. +type until it fits, so a string at twice its budget is drawn at a fraction of \ +its size and ends up the smallest text on a frame it was supposed to dominate. \ +Stay at or under N. On a quote that means picking a shorter verbatim span, \ +never squeezing the whole sentence in. - Respect every max length and list-length limit above. They are enforced. - Colours are palette names ({colors}) — never hex. - Quotes carry the typographic quote marks: “SPLIT RADAR IMAGE”, with U+201C \ @@ -127,8 +127,15 @@ 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. +- **Most shots carry one.** The example narrates six of its eight and leaves \ +silent exactly the two that draw a quotation, where the voice would only be \ +competing with words already on the frame. Chosen silence is an edit; a spec \ +with one narrated shot out of eight is not a Short with a voice, it is a Short \ +that forgot to speak. +- Narration costs seconds. A shot is never cut short to fit the voice — it \ +grows instead — so a line that needs six seconds in a four-second shot pushes \ +your whole total past the target. Write the line, then give the shot the time \ +the line actually takes. - 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. @@ -155,7 +162,8 @@ state the fact plainly. # 5. A worked example — FORMAT ONLY This is a case_file that produced a good video. Read it for shape: how long a \ -shot runs, how a headline is worded, how the shots build. +shot runs, how a headline is worded, which shots speak and which stay silent, \ +how the shots build. It is not source material. Do not reuse its strings, figures, coordinates, \ quotes or waypoints — not even if it covers the same case you were asked \ diff --git a/tests/test_shortspec.py b/tests/test_shortspec.py index a273c55..abc26eb 100644 --- a/tests/test_shortspec.py +++ b/tests/test_shortspec.py @@ -113,6 +113,38 @@ def test_prompt_includes_the_worked_example_in_full(): assert "counter_close" in prompt +def test_the_worked_example_narrates_most_of_its_shots(): + """El ejemplo es la señal de formato más fuerte del prompt, más que cualquier + regla en prosa. Sin narración en él, el modelo escribía specs mudos aunque la + sección 3b le dijera lo contrario: en la primera generación tras añadir la voz + narró 1 de 8 planos. Si alguien vuelve a dejar el ejemplo mudo, esto salta. + """ + example = json.loads(EXAMPLE.read_text()) + shots = example["shots"] + spoken = [s for s in shots if s.get("narration")] + + assert len(spoken) >= len(shots) * 0.6, "el ejemplo enseña a no narrar" + + # Y el silencio del ejemplo es una decisión, no un olvido: calla justo donde + # la plantilla ya dibuja una cita. + silent = {s["template"] for s in shots if not s.get("narration")} + assert silent == {"scale_bars", "document_quote"} + + +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_CHARS_PER_SECOND + + example = json.loads(EXAMPLE.read_text()) + for i, shot in enumerate(example["shots"]): + narration = shot.get("narration", "") + if not narration: + continue + needs = len(narration) / NARRATION_CHARS_PER_SECOND + assert shot["duration"] >= needs, f"shot {i} declara menos de lo que habla" + + 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")