diff --git a/README.md b/README.md index 188db3f..42b5e7d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,16 @@ for case files, `pulse` for debunks, `static` for document drops. The model picks one to match the narrative shape, and the cheapest way to audition them is the edit loop — change `audio.preset` in the spec file and re-send it. +**Narration:** a shot may carry a `narration` line. shortsmith speaks it and +burns the words in as captions, and **the grounding check reads it like +everything else** — narration is prose the model composes rather than a label it +copies, which makes it the easiest place for an unsourced figure to appear. +Timing works the other way round from the rest of the spec: a shot's declared +`duration` becomes a floor, and the shot grows if the line needs longer, so the +claims report also carries how much the video stretched. The prompt tells the +model to lead with the hook, keep lines under 25 words, and never read the +screen aloud — the captions already show the words. + Full spec of the phase: `docs/shortsmith-phase2-spec.md`. ## YouTube (`/upload_short`) diff --git a/docs/shortsmith-phases-4-6.md b/docs/shortsmith-phases-4-6.md index 4d3ba3a..689b8e4 100644 --- a/docs/shortsmith-phases-4-6.md +++ b/docs/shortsmith-phases-4-6.md @@ -63,7 +63,33 @@ keeping until the palette itself proves its value. If it happens, it is an opt-i `Audio.transitions` flag with boundaries passed alongside `silence` — additive, and `sonar`'s reference gate must not notice. -### 4b. Narration (TTS) + burned-in captions +### 4b. Narration (TTS) + burned-in captions — **shipped 2026-08-06** + +What actually landed, and where it differs from the plan below: + +- **Piper as a standalone binary**, not the PyPI package: `piper-phonemize` is a + compiled extension whose wheels chase the interpreter version, and the image's + Python is pinned by digest. Binary, voice and config are all pinned by sha256 — the + voice model *is* the channel's sound. +- **`--noise_scale 0 --noise_w 0` is load-bearing.** Measured before building anything: + the same line twice gave 5.668 s and 5.796 s with different hashes. With the flags, + three runs and one hash. Without that probe the phase would have shipped a renderer + that quietly stopped being deterministic. +- **Declared duration became a floor**, as planned — plus a consequence the plan + missed: `audio.silence` windows are written in absolute seconds, so a stretched shot + slides them onto the wrong line. They are now remapped through the shot they pointed + at. +- **One caption size for the whole video**, fitted against the longest group. Per-group + fitting made the type jump between cues, which is the clearest tell of an + auto-captioned video. +- researchowl got the grounding extension first, as the order below demanded, and one + 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 + every narrated Short would double its own review report. + +Original plan, kept for the record: + +### 4b (as planned). Narration (TTS) + burned-in captions These two ship together: most Shorts are watched muted, so the captions matter more than the voice — but both come from the same new field. diff --git a/src/bot/bot.py b/src/bot/bot.py index 7f98944..088c080 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -464,12 +464,25 @@ def _claims_message(result) -> str: else "no se llegó a escribir un spec") lines.append(f"⚠️ Sin comprobación de fundamento: {why}.") - if result.render_warnings: + # shortsmith manda dos cosas por el mismo canal: textos que no cupieron al + # dibujar y avisos de la narración. Se separan aquí porque piden acciones + # distintas — uno se arregla acortando una cadena, el otro puede significar + # que el Short salió mudo. + trimmed = [w for w in (result.render_warnings or []) if not w.get("kind")] + spoken = [w for w in (result.render_warnings or []) if w.get("kind")] + + if trimmed: lines.append("") - lines.append(f"✂️ {len(result.render_warnings)} textos recortados al dibujar:") - for w in result.render_warnings[:5]: + lines.append(f"✂️ {len(trimmed)} textos recortados al dibujar:") + for w in trimmed[:5]: lines.append(f" • [{w.get('template', '?')}] {str(w.get('text', ''))[:60]}") + if spoken: + lines.append("") + for w in spoken[:5]: + icon = "🔇" if w.get("kind") == "narration" else "⏱" + lines.append(f"{icon} {str(w.get('text', ''))[:160]}") + if result.notes: lines.append("") lines.extend(f"📏 {n}" for n in result.notes) diff --git a/src/generator/grounding.py b/src/generator/grounding.py index b62a8d9..5469863 100644 --- a/src/generator/grounding.py +++ b/src/generator/grounding.py @@ -168,6 +168,24 @@ class Claim: def norm(self) -> str: return normalize(self.text) + @property + def fingerprint(self) -> tuple: + """Identidad del dato, no de su redacción — para no contar dos veces. + + Una cifra se identifica por su número y su unidad CANÓNICA: "35,000 FT" + dibujado en pantalla y "35,000 feet" dicho en la narración son el mismo + dato, y con la narración esa coincidencia pasa a ser lo normal, no la + excepción. Contarlos por separado inflaría justo el informe del que + depende la revisión humana. + + Lo demás se identifica por su forma normalizada: una cita reformulada + NO es la misma cita, y ahí la literalidad es el criterio correcto. + """ + if self.kind == "figure": + number = normalize(self.text.split()[0]) if self.text.split() else "" + return ("figure", number, self.unit) + return (self.kind, self.norm) + _QUOTED = re.compile(r'["“„‟«]([^"“”„‟«»]{3,})' r'["”„‟»]') @@ -315,6 +333,16 @@ def extract_claims(spec: dict) -> list[Claim]: `meta` queda fuera a propósito: el título del spec no se dibuja en ningún fotograma, es el nombre del fichero. + + `narration` SÍ entra, y es de lo más importante que entra: lo que se dibuja + en pantalla son etiquetas cortas que el modelo copia, pero la narración es + prosa que redacta — el sitio natural para deslizar una cifra de más. Se + trata como cualquier prosa del spec: aporta sus citas, cifras y fechas. No + aporta nombres propios, por lo mismo que no los aportan `headline` o + `caption`: una frase entera no es una etiqueta identificadora, y sacar + nombres de dentro de la prosa exigiría adivinar por mayúsculas y llenaría + el informe de ruido. La cifra y la cita, que son lo que se fabrica, están + cubiertas. """ claims: list[Claim] = [] for i, shot in enumerate(spec.get("shots") or []): @@ -328,12 +356,16 @@ def extract_claims(spec: dict) -> list[Claim]: # Reconstruye "232 FT" a partir de {value: 232, unit: "FT"}. for path_prefix, sub in _dicts_with_value_and_unit(props, base): shot_claims = _attach_units(sub, shot_claims, path_prefix) + narration = shot.get("narration") + if isinstance(narration, str): + shot_claims.extend( + _claims_from_text(narration, f"shots.{i}.narration", "narration")) claims.extend(shot_claims) - seen: set[tuple[str, str]] = set() + seen: set[tuple] = set() unique: list[Claim] = [] for claim in claims: - fingerprint = (claim.kind, claim.norm) + fingerprint = claim.fingerprint if not claim.norm or fingerprint in seen: continue seen.add(fingerprint) diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index 7bc1fd6..91b9f7d 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -101,6 +101,31 @@ title a human reads, not a filename. - version is 1. Keep meta at 1080x1920. - audio.preset — pick the one whose mood fits the shape you chose: {presets} + +# 3b. Narration — the voice-over + +Every shot takes an optional `narration`: one or two spoken sentences, read \ +aloud by the renderer and burned in as captions. Write it for the ear. + +- **The first line is the whole hook.** Two seconds decide whether anyone \ +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 under 25 words. 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 45 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. +- Spoken register, not caption register: normal sentence case, ordinary \ +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. +- 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. - The closing shot carries the domain, uppercase, no protocol: {domain} # 4. Grounding — this is the part that matters diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index be2198e..31963ee 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -27,6 +27,7 @@ __all__ = [ "SpecInvalid", "validate_spec", "editorial_notes", + "estimated_duration", "describe_templates", "TARGET_MIN_DURATION", "TARGET_MAX_DURATION", @@ -224,6 +225,22 @@ def _check_audio(audio: Any, total: float, return errors +#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla +#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero. +MAX_NARRATION_CHARS = 320 + + +def _check_narration(narration: Any, path: str) -> list[str]: + if narration is None: + return [] + if not isinstance(narration, str): + return [f"{path}.narration: se esperaba texto"] + if len(narration) > MAX_NARRATION_CHARS: + return [f"{path}.narration: {len(narration)} caracteres, el máximo es " + f"{MAX_NARRATION_CHARS}"] + return [] + + def _total_duration(spec: dict) -> float: total = 0.0 for shot in spec.get("shots") or []: @@ -275,9 +292,10 @@ def validate_spec(spec: Any, templates: dict[str, dict], f"(las plantillas son: {known})") continue for key in shot: - if key not in ("template", "duration", "props"): + if key not in ("template", "duration", "props", "narration"): errors.append(f"{path}.{key}: campo no permitido " - "(las válidas son: template, duration, props)") + "(las válidas son: template, duration, props, narration)") + errors.extend(_check_narration(shot.get("narration"), path)) duration = shot.get("duration") if not isinstance(duration, (int, float)) or isinstance(duration, bool): errors.append(f"{path}.duration: obligatoria y numérica") @@ -303,6 +321,36 @@ 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 +#: El respiro que shortsmith deja tras cada línea antes de permitir el corte. +NARRATION_PAD = 0.45 + + +def estimated_duration(spec: dict) -> float: + """Lo que durará el vídeo, no lo que suman las duraciones declaradas. + + Con narración, la duración declarada es un suelo: shortsmith estira el shot + 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. + """ + total = 0.0 + for shot in spec.get("shots") or []: + if not isinstance(shot, dict): + continue + declared = shot.get("duration") + 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) + total += declared + return total + + def editorial_notes(spec: dict) -> list[str]: """Lo que no viola el contrato pero sí el encargo. @@ -311,15 +359,22 @@ def editorial_notes(spec: dict) -> list[str]: si insiste, se renderiza igual antes que tirar la generación a la basura. """ notes = [] - total = _total_duration(spec) + declared = _total_duration(spec) + total = estimated_duration(spec) + stretched = total > declared + 0.5 + how = (f"la duración estimada son {total:.1f}s con la narración " + f"({declared:.1f}s de shots)" if stretched + else f"la duración total son {total:.1f}s") + if total < TARGET_MIN_DURATION: - notes.append(f"la duración total son {total:.1f}s y el objetivo es " + 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: - notes.append(f"la duración total son {total:.1f}s y el objetivo es " - f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: " - "recorta shots o acorta duraciones") + 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 " + f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}") return notes diff --git a/tests/test_bot_short_report.py b/tests/test_bot_short_report.py index caa6720..a8c1bef 100644 --- a/tests/test_bot_short_report.py +++ b/tests/test_bot_short_report.py @@ -140,3 +140,28 @@ class TestSessionFromFilename: assert _session_from_filename("myspec.json") is None assert _session_from_filename("") is None assert _session_from_filename(None) is None + + +class TestNarrationWarnings: + """shortsmith manda por el mismo canal los textos recortados y los avisos + de la voz. Piden acciones distintas, así que se muestran distintos.""" + + def test_a_silent_shot_is_reported_as_such(self): + text = _claims_message(result_with(render_warnings=[ + {"kind": "narration", "text": "shots.2.narration not spoken: piper exited 1"}])) + assert "🔇" in text and "shots.2.narration" in text + assert "recortados" not in text + + def test_a_stretched_video_says_so(self): + text = _claims_message(result_with(render_warnings=[ + {"kind": "timing", + "text": "narration stretched the video from 32.0s to 41.5s"}])) + assert "41.5s" in text + assert "recortados" not in text + + def test_trimmed_text_and_narration_do_not_get_mixed_up(self): + text = _claims_message(result_with(render_warnings=[ + {"template": "data_card", "text": "FILA LARGA", "requested": 44, "size": 38}, + {"kind": "narration", "text": "no voice installed"}])) + assert "1 textos recortados" in text # sólo cuenta el de dibujo + assert "🔇 no voice installed" in text diff --git a/tests/test_grounding.py b/tests/test_grounding.py index 4a9cdb1..0068a96 100644 --- a/tests/test_grounding.py +++ b/tests/test_grounding.py @@ -286,3 +286,110 @@ def test_summary_lists_every_ungrounded_string(spec): summary = check_grounding(corrupted, CHUNKS).summary() assert "⚠️ 1 sin encontrar" in summary assert '"12000"' in summary + + +# --- narración (fase 4b) ---------------------------------------------------- +# La narración es prosa que el modelo REDACTA, no una etiqueta que copia: es +# el sitio natural donde se cuela una cifra de más. Estos tests existen antes +# que el campo, a propósito — el comprobador se escribe sin conocer lo +# comprobado (fase 2 §12). + + +def test_a_fabricated_figure_hiding_in_the_narration_is_caught(spec): + """El caso que justifica la fase entera: la pantalla dice la verdad y la + voz añade una cifra que no está en ninguna fuente.""" + narrated = copy.deepcopy(spec) + narrated["shots"][0]["narration"] = ( + "Three separate radars tracked the object at 41,000 feet.") + + report = check_grounding(narrated, CHUNKS) + + assert [c.text for c in report.ungrounded] == ["41,000 feet"] + assert report.ungrounded[0].path == "shots.0.narration" + + +def test_a_narration_that_stays_with_the_sources_is_clean(spec): + narrated = copy.deepcopy(spec) + narrated["shots"][0]["narration"] = ( + "On November 17, 1986, the crew was cruising at 35,000 feet over Alaska.") + + assert check_grounding(narrated, CHUNKS).clean + + +def test_a_quote_invented_for_the_voice_over_is_caught(spec): + """Una cita hablada es una cita: o es verbatim o no lo es.""" + narrated = copy.deepcopy(spec) + narrated["shots"][0]["narration"] = ( + 'The captain said it was “the size of two aircraft carriers”.') + + report = check_grounding(narrated, CHUNKS) + + assert any("two aircraft carriers" in c.text for c in report.ungrounded) + + +def test_saying_out_loud_what_the_screen_already_shows_is_one_claim(spec): + """Deduplicado por (tipo, forma normalizada): la misma cifra dibujada y + narrada no infla el informe ni se cuenta dos veces.""" + plain = check_grounding(spec, CHUNKS) + narrated = copy.deepcopy(spec) + narrated["shots"][0]["narration"] = "Three radars, 35,000 feet over Alaska." + + report = check_grounding(narrated, CHUNKS) + + assert report.total == plain.total + + +def test_a_leak_from_the_example_is_still_diagnosed_as_a_leak_in_narration(spec): + """La narración no se libra del segundo diagnóstico: una cifra del ejemplo + del prompt sigue siendo fuga, no invención.""" + narrated = copy.deepcopy(spec) + narrated["shots"][0]["narration"] = "The aircraft itself measured 232 ft." + + report = check_grounding(narrated, [{"url": "u", "content": "Nothing useful."}]) + + assert any(c.text == "232 ft" for c in report.contaminated) + + +def test_narration_contributes_no_name_claims(spec): + """Una frase entera no es una etiqueta identificadora. Sacar nombres de la + prosa exigiría adivinar por mayúsculas y llenaría el informe de ruido.""" + narrated = copy.deepcopy(spec) + narrated["shots"][0]["narration"] = "Nobody at Hangar Eighteen ever confirmed it." + + claims = [c for c in extract_claims(narrated) if c.path == "shots.0.narration"] + + assert claims == [] + + +def test_a_shot_without_narration_behaves_exactly_as_before(spec): + """El campo es opcional: un spec de hoy tiene que dar el mismo informe.""" + before = check_grounding(spec, CHUNKS) + with_empty = copy.deepcopy(spec) + with_empty["shots"][0]["narration"] = "" + + after = check_grounding(with_empty, CHUNKS) + + assert after.total == before.total and after.clean == before.clean + + +def test_the_same_figure_spelled_two_ways_is_one_claim(): + """La huella de una cifra es su número y su unidad canónica. Sin esto, la + voz repitiendo la pantalla duplicaría medio informe.""" + spec = {"shots": [{"template": "scale_bars", "props": { + "headline": "CRUISE ALTITUDE 35,000 FT"}, + "narration": "They were cruising at 35,000 feet."}]} + + claims = extract_claims(spec) + + assert len([c for c in claims if c.kind == "figure"]) == 1 + + +def test_the_same_number_with_different_units_stays_two_claims(): + """1,600 ft y 1,600 m no son el mismo dato, y confundirlos sería peor que + duplicar: escondería una cifra sin comprobar.""" + spec = {"shots": [{"template": "x", "props": { + "headline": "1,600 FT ACROSS", "footer": "1,600 m of runway"}}]} + + figures = [c for c in extract_claims(spec) if c.kind == "figure"] + + assert {c.unit for c in figures} == {"ft", "m"} diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index 3a16344..b0da517 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -254,3 +254,72 @@ def test_validation_accepts_a_template_nobody_wrote_here(): "properties": {"title": {"type": "string", "minLength": 1}}}} validate_spec(spec_with({"template": "holo_scan", "duration": 30.0, "props": {"title": "X"}}), templates) + + +# --- narración (fase 4b) ---------------------------------------------------- + + +def test_a_shot_may_carry_narration(): + doc = spec_with(shot(duration=25.0)) + doc["shots"][0]["narration"] = "Three radars tracked it that night." + validate_spec(doc, TEMPLATES) + + +def test_an_overlong_narration_is_rejected_with_its_path(): + doc = spec_with(shot(duration=25.0)) + doc["shots"][0]["narration"] = "x" * 400 + assert any("shots.0.narration" in e and "320" in e for e in errors_of(doc)) + + +def test_narration_that_is_not_text_is_rejected(): + doc = spec_with(shot(duration=25.0)) + doc["shots"][0]["narration"] = ["a", "b"] + assert any("shots.0.narration" in e for e in errors_of(doc)) + + +def test_an_unknown_shot_key_still_names_the_valid_ones(): + doc = spec_with(shot(duration=25.0)) + doc["shots"][0]["voiceover"] = "nope" + assert any("narration" in e for e in errors_of(doc)) + + +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 + no cabe, y el modelo tiene que enterarse ANTES de pagar el render.""" + from src.generator.spec_contract import estimated_duration + + doc = spec_with(shot(duration=3.0)) + doc["shots"][0]["narration"] = "A" * 142 # ~10 s de voz + + assert estimated_duration(doc) > 10.0 + + +def test_a_shot_with_room_for_its_line_is_estimated_as_declared(): + from src.generator.spec_contract import estimated_duration + + doc = spec_with(shot(duration=30.0)) + doc["shots"][0]["narration"] = "Short line." + + assert estimated_duration(doc) == pytest.approx(30.0) + + +def test_narration_that_overshoots_the_target_is_flagged_as_narration(): + """El consejo tiene que decir QUÉ recortar: con la voz mandando, acortar + duraciones no arregla nada.""" + # 3 shots de 8 s = 24 s declarados, dentro del objetivo y sin avisos. Con + # ~21 s de voz cada uno se van a 65 s: sin la estimación, silencio absoluto. + quiet = spec_with(*[shot(duration=8.0) for _ in range(3)]) + assert editorial_notes(quiet) == [] + + doc = copy.deepcopy(quiet) + for s in doc["shots"]: + s["narration"] = "A" * 300 + + note = editorial_notes(doc)[0] + + assert "narración" in note and "estimada" in note + + +def test_a_spec_without_narration_keeps_the_old_wording(): + 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