video-compose: catch TTS punctuation leaks in final review

Adds a transcript_comparison check to VideoCompose._run_final_review
that word-diffs the whisper/whisperx transcript against script.txt and
fails loudly when the TTS engine literally voiced punctuation tokens
(dot, dots, ellipsis, comma, dash, hyphen, period). Chirp3-HD did this
to ellipses in a production run and it slipped past review; now it
cannot.

Also corrects the "tiny background video" gotcha in
skills/core/hyperframes.md. After six renders of blaming HyperFrames
CSS, the real root cause was 640x360 Pexels sources combined with a
fit-and-pad pre-transform — HyperFrames was rendering the letterboxed
input faithfully. Gotcha now walks through the ffprobe diagnostic and
the scale-to-cover fix, and keeps the wrapper-div pattern for the
right reasons (aspect mismatch handling, not framework bug workaround).

Four regression tests cover the new check: punctuation-leak detection,
clean-audio false-positive guard, graceful skip when inputs missing,
and always-present transcript_comparison section.
This commit is contained in:
calesthio
2026-04-18 22:16:27 -07:00
parent b6ce481073
commit 0efed7427c
3 changed files with 431 additions and 42 deletions

View File

@@ -288,54 +288,76 @@ Same pattern as Remotion.
Things the upstream docs don't warn about but will cost you a 60-minute
render to discover. Fix at author time, not at render time.
### Video elements need explicit size, in BOTH HTML attrs and `!important` CSS
### Full-frame background video: source-resolution trap (NOT a framework bug)
HyperFrames runtime applies inline `style="width:...px; height:...px"` to
every `<video>` element based on the video's **intrinsic** dimensions.
That inline style beats any class-selector CSS you write, so even a
`video.bg-video { width: 100%; height: 100% }` rule gets silently ignored
and the video renders at whatever size the decoded stream reports —
usually a small centered box inside an otherwise-black clip.
Symptom: background video renders as a small centered box with black
around it, even though you've told HyperFrames the clip is 1920×1080.
Six renders of debugging revealed this is **almost always a source
quality issue, not a HyperFrames framework bug.** Fix the input, not
the CSS.
The bug is invisible in some scenarios:
- Low `filter: brightness(0.25-0.35)` makes the small video box look
mostly black.
- Fullscreen typography layered on top hides the problem.
- `object-fit: cover` on a class selector does NOT fix it because the
inline width/height wins first.
Root cause (observed on Pexels + Pixabay stock): many free stock
clips ship at 640×360 or 960×540 even when you ask for the "large"
size tier. If your pre-transform does
`scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:...`
you'll produce a 1920×1080 file whose visible content is a centered
640×360 rectangle with black padding. HyperFrames plays this clip
faithfully — the small-video-in-black-frame you see is a correctly
rendered letterboxed input.
It becomes obvious the moment you go footage-forward (brightness > 0.5,
lower-third typography) — the small centered video box appears against
a black frame that was supposed to be full-bleed b-roll.
Diagnostic first: before blaming CSS, run
`ffprobe -v error -select_streams v -show_entries stream=width,height`
on the source clip and on your pre-transformed clip. If the pre-
transform already shows a 640×360 active region inside 1920×1080
padding, the problem is upstream.
**Correct pattern** for a full-frame background video:
Fix the pre-transform to scale-to-COVER and crop, not scale-to-FIT
and pad:
```
-vf "scale=1920:1080:force_original_aspect_ratio=increase,\
crop=1920:1080,unsharp=3:3:0.6"
```
This upscales the small clip to at least cover 1920×1080, crops the
overflow, and sharpens (unsharp) to offset perceived softness. The
output is full-bleed and HyperFrames will render it edge-to-edge.
The canonical wrapper-div pattern (`patterns.md`) is still the right
HTML shape for backgrounds:
```html
<video
class="clip bg-video"
data-start="0" data-duration="8" data-track-index="0"
src="assets/hero.mp4"
muted playsinline preload="metadata"
width="1920" height="1080"
></video>
<div style="position:absolute;top:0;left:0;width:1920px;height:1080px;overflow:hidden;">
<video
data-start="0" data-duration="60" data-track-index="0"
src="..." muted playsinline
style="width:100%;height:100%;object-fit:cover;"
></video>
</div>
```
```css
video.bg-video {
position: absolute !important; inset: 0 !important;
width: 100% !important; height: 100% !important;
object-fit: cover !important;
/* filter optional */
}
```
Use it because `overflow: hidden` on the wrapper crops gracefully when
the video's aspect differs slightly from 16:9, and because
`object-fit: cover` on the inner `<video>` handles the (rare) case
where source aspect isn't 16:9. It is **not** a workaround for a
framework layout bug — the framework will size `<video class="clip">`
correctly too if the source file has content filling the full frame.
Both the explicit `width="1920" height="1080"` HTML attrs AND the
`!important` CSS are required. Either one alone isn't enough.
Apply visual treatments (`filter`, `border-radius`, etc.) on the
wrapper or on a scoped selector like `.bg-slot video { filter: ... }`.
Alternative: wrap the `<video>` in a `<div class="clip">` and put
`data-start`/`data-duration` on the wrapper. Let the video fill its div
via `position: absolute; inset: 0` without the framework's inline-style
interference. Slightly more DOM but more robust.
Invisible-vs-obvious failure mode: this trap hides when you stack
dim filters (`brightness(0.250.35)`) or full-bleed typography on top —
the letterbox reads as "moody darkness." It becomes obvious the moment
you go footage-forward (brightness > 0.5, lower-third typography). If
you're planning footage-forward from the start, probe your source
resolutions in the asset stage.
When you truly can't source HD clips, switch pipelines: use FFmpeg
hybrid-compositing (scale-to-cover + crop + unsharp in an external
b-roll reel, then overlay HyperFrames typography via chromakey). That's
the pattern `projects/quantum-willow-multiverse/` settled on after six
HyperFrames renders with 640×360 Pexels sources.
### Always preview-scrub footage-forward scenes before render

View File

@@ -627,6 +627,171 @@ def test_decision_log_accepts_render_runtime_selection_with_both_options():
jsonschema.validate(log, schema)
def test_transcript_comparison_catches_literal_punctuation_leak(tmp_path):
"""Regression: Chirp3-HD (and some other TTS engines) read literal `...`
as the word 'dot' in audio output. This failure is invisible to
volume-based audio spotchecks but ships audio that literally says
'dot dot dot' twelve times. The transcript_comparison check catches
this automatically before the video is marked pass."""
import json
# Real-world example: user's script had `...` everywhere for dramatic
# pause, Chirp read them all as "dot", transcript contains "dot dot dot"
# phrases the script never had.
script_text = (
"A computer just did in five minutes what would take every machine on Earth, "
"running since the Big Bang, ten septillion years to finish. "
"We may have gotten help from parallel universes."
)
transcript_data = {
"word_timestamps": [
{"word": "A", "start": 0.0, "end": 0.1},
{"word": "computer", "start": 0.1, "end": 0.5},
{"word": "just", "start": 0.5, "end": 0.7},
{"word": "did", "start": 0.7, "end": 0.9},
{"word": "in", "start": 0.9, "end": 1.0},
{"word": "five", "start": 1.0, "end": 1.3},
{"word": "minutes", "start": 1.3, "end": 1.7},
{"word": "dot", "start": 1.7, "end": 1.9}, # leak!
{"word": "dot", "start": 1.9, "end": 2.1}, # leak!
{"word": "dot", "start": 2.1, "end": 2.3}, # leak!
{"word": "what", "start": 2.5, "end": 2.8},
{"word": "would", "start": 2.8, "end": 3.0},
{"word": "take", "start": 3.0, "end": 3.3},
{"word": "every", "start": 3.3, "end": 3.6},
{"word": "machine", "start": 3.6, "end": 4.0},
{"word": "on", "start": 4.0, "end": 4.2},
{"word": "Earth", "start": 4.2, "end": 4.6},
{"word": "running", "start": 4.7, "end": 5.1},
{"word": "since", "start": 5.1, "end": 5.4},
{"word": "the", "start": 5.4, "end": 5.5},
{"word": "Big", "start": 5.5, "end": 5.8},
{"word": "Bang", "start": 5.8, "end": 6.2},
{"word": "ten", "start": 6.2, "end": 6.5},
{"word": "septillion", "start": 6.5, "end": 7.3},
{"word": "years", "start": 7.3, "end": 7.7},
{"word": "to", "start": 7.7, "end": 7.9},
{"word": "finish", "start": 7.9, "end": 8.3},
{"word": "dot", "start": 8.3, "end": 8.5}, # another leak
{"word": "We", "start": 9.0, "end": 9.2},
{"word": "may", "start": 9.2, "end": 9.4},
{"word": "have", "start": 9.4, "end": 9.6},
{"word": "gotten", "start": 9.6, "end": 9.9},
{"word": "help", "start": 9.9, "end": 10.3},
{"word": "from", "start": 10.3, "end": 10.5},
{"word": "parallel", "start": 10.5, "end": 11.0},
{"word": "universes", "start": 11.0, "end": 11.7},
]
}
transcript_path = tmp_path / "transcript.json"
transcript_path.write_text(json.dumps(transcript_data), encoding="utf-8")
result = VideoCompose._compare_transcript_to_script(transcript_path, script_text)
# Must catch the punctuation leak
assert result["spurious_punctuation_words"], (
"transcript_comparison failed to detect the 'dot' leak from literal ... punctuation."
)
leak_counts = {
entry["word"]: entry["count"]
for entry in result["spurious_punctuation_words"]
}
assert leak_counts.get("dot") == 4, f"Expected 4 'dot' leaks, got {leak_counts}"
# Must produce a CRITICAL-severity issue message
issue_text = " ".join(result["issues"]).lower()
assert "tts punctuation leak" in issue_text
assert "not in the script" in issue_text
# Must NOT mark the transcript as matching
assert result["transcript_matches_script"] is False
def test_transcript_comparison_passes_clean_audio(tmp_path):
"""Clean audio with no punctuation leaks must NOT trigger a false
positive."""
import json
script_text = "The quick brown fox jumps over the lazy dog."
transcript_data = {
"word_timestamps": [
{"word": "The", "start": 0.0, "end": 0.1},
{"word": "quick", "start": 0.1, "end": 0.4},
{"word": "brown", "start": 0.4, "end": 0.7},
{"word": "fox", "start": 0.7, "end": 1.0},
{"word": "jumps", "start": 1.0, "end": 1.3},
{"word": "over", "start": 1.3, "end": 1.6},
{"word": "the", "start": 1.6, "end": 1.7},
{"word": "lazy", "start": 1.7, "end": 2.0},
{"word": "dog", "start": 2.0, "end": 2.3},
]
}
transcript_path = tmp_path / "transcript.json"
transcript_path.write_text(json.dumps(transcript_data), encoding="utf-8")
result = VideoCompose._compare_transcript_to_script(transcript_path, script_text)
assert result["spurious_punctuation_words"] == []
assert result["transcript_matches_script"] is True
assert result["word_accuracy"] >= 0.9
# issues may still have informational content but no CRITICAL TTS leak
assert not any("tts punctuation leak" in i.lower() for i in result["issues"])
def test_transcript_comparison_graceful_when_inputs_missing(tmp_path):
"""When transcript or script is unavailable, the check should NOT
crash — it should record the skip in issues so the silence is visible."""
# No transcript
result = VideoCompose._compare_transcript_to_script(None, "some script text")
assert any("not provided" in i for i in result["issues"])
# No script
dummy = tmp_path / "t.json"
dummy.write_text('{"word_timestamps": []}', encoding="utf-8")
result = VideoCompose._compare_transcript_to_script(dummy, "")
assert any("not provided" in i for i in result["issues"])
# Transcript file missing
result = VideoCompose._compare_transcript_to_script(tmp_path / "nonexistent.json", "script")
assert any("not provided" in i for i in result["issues"])
def test_run_final_review_includes_transcript_comparison_section(tmp_path):
"""Regression: the `transcript_comparison` section must ALWAYS appear in
the final_review output — even when the caller doesn't provide a
transcript. A missing section = silent governance failure."""
import subprocess
# Build a minimal MP4 so _run_final_review can probe it.
mp4 = tmp_path / "out.mp4"
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=#000000:s=320x240:d=2",
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest", str(mp4),
],
capture_output=True, check=True, timeout=30,
)
review = VideoCompose()._run_final_review(
mp4,
edit_decisions={
"version": "1.0",
"renderer_family": "animation-first",
"render_runtime": "hyperframes",
"cuts": [{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}],
},
)
assert "transcript_comparison" in review["checks"], (
"final_review must always include a transcript_comparison section. "
"When the caller doesn't provide a transcript, the section should "
"still appear with a 'skipped' issue entry — not be omitted."
)
tc = review["checks"]["transcript_comparison"]
assert any("not provided" in i for i in tc["issues"])
def test_hyperframes_root_composition_has_data_start_and_duration(tmp_path):
"""Regression: the generated root composition was missing data-start
and data-duration, violating the HyperFrames contract (SKILL.md table)."""

View File

@@ -105,6 +105,32 @@ class VideoCompose(BaseTool):
"edit_decisions.metadata.proposal_render_runtime."
),
},
"narration_transcript_path": {
"type": "string",
"description": (
"Path to a word-level transcript JSON (from `transcriber` "
"tool output). Optional but STRONGLY recommended: when "
"combined with script_path/script_text, final_review "
"runs transcript_comparison and catches TTS failures "
"like 'Chirp3-HD reads ... as the word dot'. Without "
"it, content-level audio bugs ship silently."
),
},
"script_path": {
"type": "string",
"description": (
"Path to the source narration script (plain text). "
"Used by transcript_comparison to diff against the "
"transcribed audio. Provide this OR script_text."
),
},
"script_text": {
"type": "string",
"description": (
"Inline source narration script. Used by "
"transcript_comparison when a file path is unavailable."
),
},
"subtitle_path": {"type": "string"},
"subtitle_style": {
"type": "object",
@@ -1043,7 +1069,13 @@ class VideoCompose(BaseTool):
# --- Post-render: mandatory final self-review ---
if render_result.success and output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
output_path,
edit_decisions,
inputs.get("proposal_packet"),
narration_transcript_path=inputs.get("narration_transcript_path"),
script_text=inputs.get("script_text") or self._read_text_file(
inputs.get("script_path")
),
)
# Attach final_review to the ToolResult data so the compose-director
@@ -1160,7 +1192,13 @@ class VideoCompose(BaseTool):
# Post-render: mandatory final self-review (identical contract to the Remotion path).
if output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
output_path,
edit_decisions,
inputs.get("proposal_packet"),
narration_transcript_path=inputs.get("narration_transcript_path"),
script_text=inputs.get("script_text") or self._read_text_file(
inputs.get("script_path")
),
)
if render_result.data is None:
render_result.data = {}
@@ -1214,7 +1252,13 @@ class VideoCompose(BaseTool):
if render_result.success and output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
output_path,
edit_decisions,
inputs.get("proposal_packet"),
narration_transcript_path=inputs.get("narration_transcript_path"),
script_text=inputs.get("script_text") or self._read_text_file(
inputs.get("script_path")
),
)
if render_result.data is None:
render_result.data = {}
@@ -1353,11 +1397,156 @@ class VideoCompose(BaseTool):
# Final self-review — mandatory post-render inspection
# ------------------------------------------------------------------
# Punctuation/SSML-leak words that should NEVER appear in rendered audio.
# When a TTS engine reads a literal "..." as the word "dot", or a "—" as
# "hyphen", those leak into the transcript. Catching these in the final
# review is the difference between catching a bad voice render in-tool
# vs. shipping a video that says "dot dot dot" twelve times. CRITICAL.
_TTS_PUNCTUATION_LEAK_WORDS = {
"dot", "dots", "ellipsis", "period", "periods",
"comma", "commas", "semicolon", "colon",
"dash", "hyphen", "emdash", "endash",
"parenthesis", "bracket", "brace",
"asterisk", "slash", "backslash",
"exclamation", "question mark",
}
@staticmethod
def _read_text_file(path: str | Path | None) -> str | None:
"""Read a small text file if given a path; None-safe and exception-safe."""
if not path:
return None
try:
return Path(path).read_text(encoding="utf-8")
except Exception:
return None
@classmethod
def _tokenize(cls, text: str) -> list[str]:
"""Split text into comparable word tokens (lowercased, punctuation
stripped, numeric-word-aware). Empty tokens dropped."""
import re
# Preserve hyphenated words as single tokens ("many-worlds" -> "many-worlds").
# Drop everything except letters, digits, hyphens, apostrophes.
cleaned = re.sub(r"[^A-Za-z0-9\-' ]+", " ", text.lower())
return [t for t in cleaned.split() if t and t != "-"]
@classmethod
def _compare_transcript_to_script(
cls,
transcript_path: Path,
script_text: str,
) -> dict[str, Any]:
"""Compare a word-level transcript against the source script.
Purpose: catch TTS failures that look fine on audio-volume/duration
checks but produce garbage content. The canonical example is
Chirp3-HD reading ellipses ("...") literally as the word "dot" — our
volume check says "narration present, not clipped" and the video
ships. This check diffs the actual transcribed audio against what
was supposed to be said, and flags:
- Spurious punctuation-leak words ("dot", "comma", "hyphen", etc.)
that appear in audio but not script → CRITICAL
- Overall word-accuracy ratio against script → SUGGESTION if < 0.9
Returns the transcript_comparison section of final_review, or a
placeholder with an issue describing why the check couldn't run
(missing transcript, missing script) so the review never goes
silently quiet on this contract.
"""
result: dict[str, Any] = {
"transcript_matches_script": False,
"word_accuracy": None,
"script_word_count": 0,
"transcript_word_count": 0,
"spurious_punctuation_words": [],
"issues": [],
}
if not transcript_path or not Path(transcript_path).is_file():
result["issues"].append(
"transcript_comparison skipped: narration_transcript not provided"
)
return result
if not script_text:
result["issues"].append(
"transcript_comparison skipped: script_text not provided"
)
return result
try:
transcript_data = json.loads(Path(transcript_path).read_text(encoding="utf-8"))
except Exception as e:
result["issues"].append(f"transcript_comparison could not parse transcript: {e}")
return result
transcript_words = [
w.get("word", "").strip() for w in transcript_data.get("word_timestamps", [])
]
transcript_tokens = cls._tokenize(" ".join(transcript_words))
script_tokens = cls._tokenize(script_text)
result["script_word_count"] = len(script_tokens)
result["transcript_word_count"] = len(transcript_tokens)
if not script_tokens or not transcript_tokens:
result["issues"].append(
f"transcript_comparison: empty token set "
f"(script={len(script_tokens)}, transcript={len(transcript_tokens)})"
)
return result
# --- Punctuation-leak detection (TTS reading literal punctuation) ---
script_set = set(script_tokens)
leak_occurrences: dict[str, int] = {}
for token in transcript_tokens:
if token in cls._TTS_PUNCTUATION_LEAK_WORDS and token not in script_set:
leak_occurrences[token] = leak_occurrences.get(token, 0) + 1
if leak_occurrences:
formatted = ", ".join(
f"{w!r}×{n}" for w, n in sorted(leak_occurrences.items(), key=lambda x: -x[1])
)
result["spurious_punctuation_words"] = [
{"word": w, "count": n} for w, n in leak_occurrences.items()
]
result["issues"].append(
f"TTS punctuation leak: transcript contains {formatted}"
f"these words are NOT in the script, which means the voice "
f"engine is reading literal punctuation aloud. Rewrite the "
f"script to eliminate the corresponding characters (ellipses, "
f"em-dashes, etc.) and regenerate narration."
)
# --- Word accuracy via set overlap (cheap & ordering-insensitive) ---
# We don't penalize small word-order differences or minor TTS
# hallucinations; we just want to know "did 90%+ of the script's
# content make it into the audio." Using set overlap on the script
# side is robust to transcription noise.
matched = sum(1 for t in script_tokens if t in set(transcript_tokens))
accuracy = matched / max(1, len(script_tokens))
result["word_accuracy"] = round(accuracy, 3)
result["transcript_matches_script"] = accuracy >= 0.9 and not leak_occurrences
if accuracy < 0.9:
result["issues"].append(
f"Low transcript-to-script match: only {accuracy:.0%} of script "
f"words appear in the transcribed audio ({matched}/"
f"{len(script_tokens)}). Narration may be truncated, mispronounced, "
f"or the wrong script was used."
)
return result
def _run_final_review(
self,
output_path: Path,
edit_decisions: dict[str, Any] | None = None,
proposal_packet: dict[str, Any] | None = None,
narration_transcript_path: str | Path | None = None,
script_text: str | None = None,
) -> dict[str, Any]:
"""Run post-render self-review and produce a final_review artifact.
@@ -1706,12 +1895,24 @@ class VideoCompose(BaseTool):
issues.extend(subtitle_check.get("issues", []))
# --- 6. Determine overall status ---
# --- 6. Transcript-vs-script comparison ---
# Catches content-level TTS failures (the classic "Chirp reads `...`
# as the word 'dot'" trap) that volume-based audio checks miss.
# Only runs when caller provides both the transcript and script; when
# skipped, issues list records that so the silence is visible.
transcript_comparison = self._compare_transcript_to_script(
Path(narration_transcript_path) if narration_transcript_path else None,
script_text,
)
issues.extend(transcript_comparison.get("issues", []))
# --- 7. Determine overall status ---
critical_issues = [
i for i in issues
if any(kw in i.lower() for kw in [
"silent downgrade", "delivery promise violation",
"effectively silent", "ffprobe failed", "suspiciously short",
"tts punctuation leak", # reading literal punctuation aloud
])
]
@@ -1739,6 +1940,7 @@ class VideoCompose(BaseTool):
"audio_spotcheck": audio_spotcheck,
"promise_preservation": promise_preservation,
"subtitle_check": subtitle_check,
"transcript_comparison": transcript_comparison,
},
"issues_found": issues,
"recommended_action": recommended_action,