feat(boards): render narration aligned visual sequences - #174
DonIsmaelito wants to merge 29 commits into
Conversation
…ames in the skill contract
…nd guard hard rule thirteen
…audio events in captions and tidy docs
…al hosts and guard hard rule fourteen
There was a problem hiding this comment.
40 issues found across 48 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="helpers/edl.py">
<violation number="1" location="helpers/edl.py:65">
P2: Caption evidence with an infinite timestamp is accepted as timed speech. Require finite start and end values in both the word and alignment checks.</violation>
<violation number="2" location="helpers/edl.py:158">
P2: When a track JSON file is not UTF-8, validation raises `UnicodeDecodeError` instead of the documented `EDLValidationError`. Catch `UnicodeDecodeError` with the other track-read errors.</violation>
<violation number="3" location="helpers/edl.py:181">
P2: When width or height is fractional, normalization silently changes the deliverable dimensions. Reject non-integral dimensions before converting them to integers.</violation>
<violation number="4" location="helpers/edl.py:359">
P1: When a declared deliverable path already exists, rendering overwrites it, including a possible source or prior delivery. Reject existing output paths before rendering.</violation>
<violation number="5" location="helpers/edl.py:408">
P2: When a named track contains a non-list value, validation accepts it and rendering fails after extraction. Require the selected named track to be a list before returning it.</violation>
</file>
<file name="helpers/board.py">
<violation number="1" location="helpers/board.py:689">
P1: When a non-first beat omits `at` (or sets `"next"`), its implicit start is filled from the previous beat's *start* instead of its *end*. The second-pass chaining line (`pending[index + 1][0].start = beat.end`) is then unreachable because the fill already made every implicit start >= 0, and the guard `nxt.start <= beat.start` triggers, so the board always fails with `beat 'one' has no end and beat 'two' has no explicit start`. This breaks the documented default behavior ("Omit to start when the previous beat ends", `at` table in board-spec.md) for any board that chains beats implicitly.</violation>
<violation number="2" location="helpers/board.py:781">
P2: When a non-code element sets `enter: "type"`, validation accepts it but the renderer shows it immediately instead of typing. Reject `type` for non-code elements.</violation>
<violation number="3" location="helpers/board.py:800">
P2: Re-rendering the same board in separate Python processes changes `hash(element_id)`, so block reveals and shake motion are not reproducible. Derive this seed from a stable digest or deterministic checksum.</violation>
<violation number="4" location="helpers/board.py:844">
P2: A typo or unsupported `fit` value silently switches to contain framing, potentially adding bars or changing the crop. Validate `fit` against `cover` and `contain` before rendering.</violation>
</file>
<file name="helpers/web_source.py">
<violation number="1" location="helpers/web_source.py:132">
P1: A hostname whose DNS lookup fails is accepted, and a hostname can change or redirect after this one-time check, so yt-dlp may still fetch a private service. Reject unresolved hosts and enforce public-destination checks for each connection and redirect.</violation>
<violation number="2" location="helpers/web_source.py:344">
P2: When two supported sources have IDs sharing the first 64 slug characters, `source_directory` assigns them the same folder. The second inspection can overwrite the first source’s metadata and reuse its proxy files, so append a hash of the full extractor/ID identity to the directory name.</violation>
<violation number="3" location="helpers/web_source.py:1002">
P2: When `--end` exceeds the source duration, inspection and acquisition still accept the range and record it as exact. Reject ranges whose end exceeds the inspected video duration before creating the proxy or downloading the approved source.</violation>
</file>
<file name="helpers/render_illustration.py">
<violation number="1" location="helpers/render_illustration.py:40">
P2: On a successful run, run() prints stdout but never stderr. Typst emits font-substitution and deprecation warnings to stderr while exiting 0, so CeTZ/PNG renders would silently swallow them. Print captured stderr to sys.stderr when non-empty (add `import sys`).</violation>
<violation number="2" location="helpers/render_illustration.py:53">
P2: When a different `roger` is already on `PATH`, `ensure_roger` bypasses the pinned `@penrose/roger@3.3.1` cache and renders with that version or an unrelated binary. Remove the global shortcut or verify its version before using it.</violation>
<violation number="3" location="helpers/render_illustration.py:64">
P2: When two first-use Penrose renders share the cache, both can observe no executable and run `npm install` into the same prefix. Install under a per-version lock or a temporary directory, then atomically publish the completed cache.</violation>
<violation number="4" location="helpers/render_illustration.py:181">
P2: main() catches only RuntimeError and ValueError, so mkdir permission failures or subprocess exec errors (FileNotFoundError/OSError) produce a raw traceback instead of the intended `error: ...` message. Catch OSError alongside RuntimeError and ValueError for consistent agent-facing failures.</violation>
</file>
<file name="helpers/boardlib.py">
<violation number="1" location="helpers/boardlib.py:64">
P2: When `beats` is supplied as a default or override, `write()` can emit duplicate element IDs without detecting them because validation scans `self.beats`. Validate IDs from the assembled `board["beats"]` instead.</violation>
<violation number="2" location="helpers/boardlib.py:148">
P2: When narration does not contain the template words, `titlecard()` and `endcard()` raise before rendering despite accepting arbitrary display text and topics. Make the anchors beat-relative by default or require callers to provide them instead of embedding script-specific words.</violation>
<violation number="3" location="helpers/boardlib.py:151">
P2: When a board contains multiple title or end cards, these fixed IDs make `BoardBuilder.write()` reject the board. Add a caller-supplied prefix or namespace to every generated card ID.</violation>
</file>
<file name="tests/test_boardlib.py">
<violation number="1" location="tests/test_boardlib.py:68">
P2: This test does not exercise the behavior named in the test: the nonexistent path fails before clip duration is inspected. Create a valid video shorter than the beat (or mock the probe) and assert the duration-specific error so regressions in short-clip validation are caught.</violation>
</file>
<file name="helpers/render.py">
<violation number="1" location="helpers/render.py:970">
P2: Non-finite overlay times pass validation and reach ffmpeg as `nan` or `inf`, causing an invalid render instead of an actionable EDL error. Reject non-finite `start`, `duration`, and their sum before returning the range.</violation>
<violation number="2" location="helpers/render.py:1361">
P2: For portrait or non-16:9 bases, the preflight sheet crops the base before applying normalized overlay rectangles, so it can show incorrect placement and hide protected or caption regions. Preserve the base aspect ratio and transform rectangle coordinates into the letterboxed cell.</violation>
<violation number="3" location="helpers/render.py:1641">
P2: When `--build-subtitles --preflight-base` is used without an existing `subtitles` path, the contact sheet omits the caption rail and can approve overlays that the final render rejects. Treat `args.build_subtitles` as evidence that subtitles will be present.</violation>
</file>
<file name="helpers/captions.py">
<violation number="1" location="helpers/captions.py:71">
P2: When an ElevenLabs alignment contains a negative or non-finite character timestamp, `_words_from_char_alignment` accepts it and can emit a shifted or invalid cue. Validate character timings before buffering them, using the same finite/non-negative checks as `_as_word`.</violation>
<violation number="2" location="helpers/captions.py:197">
P2: When the requested subtitle path already exists, `write_substation` silently replaces the previous deliverable. Open the output exclusively so reruns cannot destroy an existing caption file.</violation>
</file>
<file name="SKILL.md">
<violation number="1" location="SKILL.md:68">
P2: When the PATH ffmpeg lacks libass and Homebrew's `ffmpeg-full` keg is not installed, caption burn-in does not automatically fall back; `ffmpeg_with_subtitles()` crashes while probing the missing keg paths. Document installing a libass-enabled ffmpeg explicitly, or update the helper to skip missing candidates and emit its actionable error.</violation>
<violation number="2" location="SKILL.md:200">
P3: `SKILL.md` now embeds caption and illustration procedures that violate the repository's documentation boundary. Move this procedure prose into feature references and leave only a concise pointer in `SKILL.md`.</violation>
</file>
<file name="references/web-sourcing.md">
<violation number="1" location="references/web-sourcing.md:101">
P2: When the base video contains illustration content in the bottom 16%, captions still cover it because `render.py` does not validate protected regions against the caption rail. Keep base illustration content out of the rail or add a renderer check; do not document this as an enforced guarantee.</violation>
</file>
<file name="install.md">
<violation number="1" location="install.md:62">
P2: This installs Typst for every macOS user even when no CeTZ slot is used, contradicting the surrounding promise that optional engines are installed lazily. Make the command a CeTZ-specific instruction instead of running it during the general install.</violation>
</file>
<file name="helpers/layout_qc.py">
<violation number="1" location="helpers/layout_qc.py:59">
P2: Malformed manifests using JSON booleans pass validation because `_number` converts `true` and `false` to numeric dimensions and coordinates. Reject booleans before calling `float()` so the QC command cannot approve invalid measurements.</violation>
</file>
<file name="helpers/web_shot.py">
<violation number="1" location="helpers/web_shot.py:84">
P2: When `VIDEO_USE_CHROME` points to an existing directory or non-executable file, `find_chrome()` returns it and `Popen` fails with an uncaught permission error instead of selecting another browser or reporting the documented missing-browser message. Validate that the override is an executable file before returning it.</violation>
<violation number="2" location="helpers/web_shot.py:268">
P2: When `--full-page` or a large `--selector` is used, the screenshot can exceed the 80-million-pixel viewport check without a bound. Cap the actual page/element capture dimensions or reject captures that exceed the output budget before rendering.</violation>
<violation number="3" location="helpers/web_shot.py:372">
P2: When `--trim` processes a transparent PNG whose content shares the transparent pixel RGB, `make_card()` drops alpha before diffing, so opaque content is treated as background and remains untrimmed. Diff the RGBA image, including alpha, when calculating the content bounds.</violation>
</file>
<file name="tests/test_skill_contract.py">
<violation number="1" location="tests/test_skill_contract.py:60">
P2: When a new rule is appended to `SKILL.md` without updating `HARD_RULES`, this assertion still passes, so later deletion of that rule is untested. Require equal counts so every appended rule is registered.</violation>
</file>
<file name="tests/test_fetch_asset.py">
<violation number="1" location="tests/test_fetch_asset.py:36">
P2: These tests claim to verify rejection before any request, but they never stub `fetch_asset.download`; a validation-order regression can contact the live Simple Icons URL instead of failing deterministically. Inject a mock that calls `pytest.fail` before both invalid logo cases and the non-HTTP image case.</violation>
</file>
<file name="references/overlays.md">
<violation number="1" location="references/overlays.md:49">
P2: The protected-regions rule is understated: `validate_overlay_contracts` in helpers/render.py (~1100–1112) skips only `cutaway` overlays when checking intersection with a protected region, so `full`, `center`, `left`, `right`, pip, and custom-rect overlays are all rejected too. As written, this doc implies only splits and PIPs are constrained, which can mislead authors into placing a `center` or `full` layout over a protected illustration and getting a hard preflight failure. Widen the sentence to cover every overlay kind except `cutaway`.</violation>
</file>
<file name="helpers/fetch_asset.py">
<violation number="1" location="helpers/fetch_asset.py:244">
P2: When a board emoji has an invalid size, `render_emoji` raises `ValueError`, but `Board._render_emoji` catches only `SystemExit`, so the board CLI leaks a traceback instead of reporting a board error. Catch the validation exception at the board boundary or use the exception type that boundary already handles.</violation>
</file>
<file name="tests/test_comment_style.py">
<violation number="1" location="tests/test_comment_style.py:15">
P3: Comments containing common punctuation such as `!`, `?`, or `-` currently pass this guard. Expand the predicate to reject all non-word, non-whitespace characters so the test enforces the punctuation-free convention it documents.</violation>
</file>
<file name="references/editing/board-spec.md">
<violation number="1" location="references/editing/board-spec.md:161">
P3: With `--preview`, `--frame` writes half-resolution stills, not full-resolution inspection frames. Qualify this output as current render resolution or explicitly exclude preview renders.</violation>
</file>
<file name="references/deliverables.md">
<violation number="1" location="references/deliverables.md:42">
P3: When preview mode is used or loudness measurement fails, the renderer uses one-pass loudnorm, so this declaration does not guarantee two-pass processing. Describe `loudness` as the loudnorm target and document the normal two-pass path plus its fallback.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| output_path = ( | ||
| (output_dir / f"{deliverable_id}.mp4").resolve() | ||
| if output_dir is not None | ||
| else _resolve_path(declared_file, edit_dir) | ||
| ) |
There was a problem hiding this comment.
P1: When a declared deliverable path already exists, rendering overwrites it, including a possible source or prior delivery. Reject existing output paths before rendering.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/edl.py, line 359:
<comment>When a declared deliverable path already exists, rendering overwrites it, including a possible source or prior delivery. Reject existing output paths before rendering.</comment>
<file context>
@@ -0,0 +1,536 @@
+ raise EDLValidationError(f"{label} currently supports only 48000 Hz audio")
+ declared_file = str(item.get("file") or f"deliverables/{deliverable_id}.mp4")
+ # an output directory override replaces the declared file name
+ output_path = (
+ (output_dir / f"{deliverable_id}.mp4").resolve()
+ if output_dir is not None
</file context>
| output_path = ( | |
| (output_dir / f"{deliverable_id}.mp4").resolve() | |
| if output_dir is not None | |
| else _resolve_path(declared_file, edit_dir) | |
| ) | |
| output_path = ( | |
| (output_dir / f"{deliverable_id}.mp4").resolve() | |
| if output_dir is not None | |
| else _resolve_path(declared_file, edit_dir) | |
| ) | |
| if output_path.exists() or output_path.is_symlink(): | |
| raise EDLValidationError(f"{label} output already exists: {output_path}") |
| # fill implicit starts from previous ends and compute ends | ||
| for index, (beat, raw) in enumerate(pending): | ||
| if beat.start < 0: | ||
| beat.start = pending[index - 1][0].start |
There was a problem hiding this comment.
P1: When a non-first beat omits at (or sets "next"), its implicit start is filled from the previous beat's start instead of its end. The second-pass chaining line (pending[index + 1][0].start = beat.end) is then unreachable because the fill already made every implicit start >= 0, and the guard nxt.start <= beat.start triggers, so the board always fails with beat 'one' has no end and beat 'two' has no explicit start. This breaks the documented default behavior ("Omit to start when the previous beat ends", at table in board-spec.md) for any board that chains beats implicitly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/board.py, line 689:
<comment>When a non-first beat omits `at` (or sets `"next"`), its implicit start is filled from the previous beat's *start* instead of its *end*. The second-pass chaining line (`pending[index + 1][0].start = beat.end`) is then unreachable because the fill already made every implicit start >= 0, and the guard `nxt.start <= beat.start` triggers, so the board always fails with `beat 'one' has no end and beat 'two' has no explicit start`. This breaks the documented default behavior ("Omit to start when the previous beat ends", `at` table in board-spec.md) for any board that chains beats implicitly.</comment>
<file context>
@@ -0,0 +1,1606 @@
+ # fill implicit starts from previous ends and compute ends
+ for index, (beat, raw) in enumerate(pending):
+ if beat.start < 0:
+ beat.start = pending[index - 1][0].start
+ if raw.get("end") not in (None, "next"):
+ beat.end = resolve_time(raw["end"], words=self.words, base=beat.start, after=beat.start, label=f"beat '{beat.id}'.end")
</file context>
| try: | ||
| infos = socket.getaddrinfo(hostname, None) | ||
| except (socket.gaierror, UnicodeError, OSError): | ||
| return [] |
There was a problem hiding this comment.
P1: A hostname whose DNS lookup fails is accepted, and a hostname can change or redirect after this one-time check, so yt-dlp may still fetch a private service. Reject unresolved hosts and enforce public-destination checks for each connection and redirect.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/web_source.py, line 132:
<comment>A hostname whose DNS lookup fails is accepted, and a hostname can change or redirect after this one-time check, so yt-dlp may still fetch a private service. Reject unresolved hosts and enforce public-destination checks for each connection and redirect.</comment>
<file context>
@@ -0,0 +1,1226 @@
+ try:
+ infos = socket.getaddrinfo(hostname, None)
+ except (socket.gaierror, UnicodeError, OSError):
+ return []
+ addresses = []
+ for info in infos:
</file context>
| # named track files need a track_id to pick one track | ||
| if isinstance(payload, dict) and isinstance(payload.get("tracks"), dict): | ||
| track_id = reframe.get("track_id") | ||
| return payload["tracks"].get(str(track_id)) if track_id else None |
There was a problem hiding this comment.
P2: When a named track contains a non-list value, validation accepts it and rendering fails after extraction. Require the selected named track to be a list before returning it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/edl.py, line 408:
<comment>When a named track contains a non-list value, validation accepts it and rendering fails after extraction. Require the selected named track to be a list before returning it.</comment>
<file context>
@@ -0,0 +1,536 @@
+ # named track files need a track_id to pick one track
+ if isinstance(payload, dict) and isinstance(payload.get("tracks"), dict):
+ track_id = reframe.get("track_id")
+ return payload["tracks"].get(str(track_id)) if track_id else None
+ if isinstance(payload, dict):
+ raw = payload.get("keyframes")
</file context>
| return payload["tracks"].get(str(track_id)) if track_id else None | |
| named_track = payload["tracks"].get(str(track_id)) if track_id else None | |
| return named_track if isinstance(named_track, list) else None |
|
|
||
| # find a roger executable or install the pinned version into the cache with npm | ||
| def ensure_roger(cache_root: Path) -> Path: | ||
| installed = shutil.which("roger") |
There was a problem hiding this comment.
P2: When a different roger is already on PATH, ensure_roger bypasses the pinned @penrose/roger@3.3.1 cache and renders with that version or an unrelated binary. Remove the global shortcut or verify its version before using it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/render_illustration.py, line 53:
<comment>When a different `roger` is already on `PATH`, `ensure_roger` bypasses the pinned `@penrose/roger@3.3.1` cache and renders with that version or an unrelated binary. Remove the global shortcut or verify its version before using it.</comment>
<file context>
@@ -0,0 +1,186 @@
+
+# find a roger executable or install the pinned version into the cache with npm
+def ensure_roger(cache_root: Path) -> Path:
+ installed = shutil.which("roger")
+ if installed:
+ return Path(installed)
</file context>
| print(result.stdout.strip()) | ||
|
|
There was a problem hiding this comment.
P2: On a successful run, run() prints stdout but never stderr. Typst emits font-substitution and deprecation warnings to stderr while exiting 0, so CeTZ/PNG renders would silently swallow them. Print captured stderr to sys.stderr when non-empty (add import sys).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/render_illustration.py, line 40:
<comment>On a successful run, run() prints stdout but never stderr. Typst emits font-substitution and deprecation warnings to stderr while exiting 0, so CeTZ/PNG renders would silently swallow them. Print captured stderr to sys.stderr when non-empty (add `import sys`).</comment>
<file context>
@@ -0,0 +1,186 @@
+ detail = (result.stderr or result.stdout or "unknown error").strip()
+ raise RuntimeError(f"command failed ({result.returncode}): {detail[-2500:]}")
+ if result.stdout.strip():
+ print(result.stdout.strip())
+
+
</file context>
| print(result.stdout.strip()) | |
| if result.stdout.strip(): | |
| print(result.stdout.strip()) | |
| if result.stderr.strip(): | |
| print(result.stderr.strip(), file=sys.stderr) |
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| SKIP_DIRS = {".venv", "venv", "node_modules", "__pycache__", ".git", "media", "edit"} | ||
| PUNCTUATION = re.compile(r"[.,:;()\"'`]") |
There was a problem hiding this comment.
P3: Comments containing common punctuation such as !, ?, or - currently pass this guard. Expand the predicate to reject all non-word, non-whitespace characters so the test enforces the punctuation-free convention it documents.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_comment_style.py, line 15:
<comment>Comments containing common punctuation such as `!`, `?`, or `-` currently pass this guard. Expand the predicate to reject all non-word, non-whitespace characters so the test enforces the punctuation-free convention it documents.</comment>
<file context>
@@ -0,0 +1,83 @@
+
+ROOT = Path(__file__).resolve().parents[1]
+SKIP_DIRS = {".venv", "venv", "node_modules", "__pycache__", ".git", "media", "edit"}
+PUNCTUATION = re.compile(r"[.,:;()\"'`]")
+DEFINITIONS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
+
</file context>
| PUNCTUATION = re.compile(r"[.,:;()\"'`]") | |
| PUNCTUATION = re.compile(r"[^\w\s]|_") |
| element entry). | ||
| - `--contact <png>` — one thumbnail per beat at its settled frame with the | ||
| spoken words underneath; review it before rendering at full size. | ||
| - `--frame <t>` — full-resolution still(s) for close inspection. |
There was a problem hiding this comment.
P3: With --preview, --frame writes half-resolution stills, not full-resolution inspection frames. Qualify this output as current render resolution or explicitly exclude preview renders.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At references/editing/board-spec.md, line 161:
<comment>With `--preview`, `--frame` writes half-resolution stills, not full-resolution inspection frames. Qualify this output as current render resolution or explicitly exclude preview renders.</comment>
<file context>
@@ -0,0 +1,193 @@
+ element entry).
+- `--contact <png>` — one thumbnail per beat at its settled frame with the
+ spoken words underneath; review it before rendering at full size.
+- `--frame <t>` — full-resolution still(s) for close inspection.
+- `--write-edl <edl.json>` — a ready version-2 EDL with the board as its only
+ source; add `--subtitles master.ass` (built with `helpers/captions.py` from
</file context>
| - `--frame <t>` — full-resolution still(s) for close inspection. | |
| - `--frame <t>` — still(s) at the current render resolution for close inspection; omit `--preview` for full resolution. |
|
|
||
| ## Subtitles (when requested) | ||
|
|
||
| First verify that the final audio contains audible speech and that timestamped |
There was a problem hiding this comment.
P3: SKILL.md now embeds caption and illustration procedures that violate the repository's documentation boundary. Move this procedure prose into feature references and leave only a concise pointer in SKILL.md.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At SKILL.md, line 200:
<comment>`SKILL.md` now embeds caption and illustration procedures that violate the repository's documentation boundary. Move this procedure prose into feature references and leave only a concise pointer in `SKILL.md`.</comment>
<file context>
@@ -177,6 +197,15 @@ Hard rules: apply **per-segment during extraction** (not post-concat, which re-e
## Subtitles (when requested)
+First verify that the final audio contains audible speech and that timestamped
+transcript or alignment JSON exists. Subtitle text must be derived from those
+spoken words. Never invent caption sentences to summarize a music-only video.
</file context>
| - `file` is relative to the EDL directory unless absolute. | ||
| - `width`/`height` may also be written as `resolution: "1080x1920"`. | ||
| - `fps` accepts integers, decimals, or rationals such as `30000/1001`. | ||
| - `loudness` sets the two-pass loudnorm target per deliverable. |
There was a problem hiding this comment.
P3: When preview mode is used or loudness measurement fails, the renderer uses one-pass loudnorm, so this declaration does not guarantee two-pass processing. Describe loudness as the loudnorm target and document the normal two-pass path plus its fallback.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At references/deliverables.md, line 42:
<comment>When preview mode is used or loudness measurement fails, the renderer uses one-pass loudnorm, so this declaration does not guarantee two-pass processing. Describe `loudness` as the loudnorm target and document the normal two-pass path plus its fallback.</comment>
<file context>
@@ -0,0 +1,78 @@
+- `file` is relative to the EDL directory unless absolute.
+- `width`/`height` may also be written as `resolution: "1080x1920"`.
+- `fps` accepts integers, decimals, or rationals such as `30000/1001`.
+- `loudness` sets the two-pass loudnorm target per deliverable.
+
+## Reframe modes
</file context>
| - `loudness` sets the two-pass loudnorm target per deliverable. | |
| - `loudness` sets the loudnorm target per deliverable; final renders normally use two-pass normalization. |
Why
Narrated edits need visuals to appear when the relevant words are spoken. This adds a board format and renderer for arranging text, image cards and clips on that timeline.
Use this for narrated explainers, product walkthroughs and educational videos where text, screenshots and clips appear alongside the spoken explanation.
Builds on #148 for layout checks and #172 for emoji asset rendering.
Changes
Limits
Layout checks cover settled states, not every transition. Fonts depend on installed or supplied files. The built-in mono audio mix is separate from #167; listening review remains pending. Failed runs can leave partial outputs.
No fonts or source media are bundled. The broader video-explainer skill remains in the Workflows candidate. Narration, Music and #170 are optional adjacent capabilities rather than runtime prerequisites.
This builds on #148 and #172 rather than duplicating their helpers. Visual composition overlaps #170 in purpose, but uses a separate board schema and does not change its renderer.
Summary by cubic
Adds a board renderer that arranges text, image cards, and clips into visual sequences timed to narration word anchors, replacing manually placed overlays with a beat-sheet schema. Also adds supporting helpers for asset acquisition, web sourcing, illustration rendering, and layout QC, plus EDL v2 support in
render.py.helpers/board.pyresolves explicit times and word anchors into animated sequences, rendering previews, final video, contact sheets, timing records, and optional EDLs.boardlib.py), caption conversion (captions.py), layout collision checks (layout_qc.py), still/logo/emoji acquisition (fetch_asset.py), web capture and cards (web_shot.py), web video sourcing (web_source.py), and Penrose/CeTZ illustration rendering (render_illustration.py).render.pynow validates EDL v2 deliverables with reframe tracks and loudness targets, and preflights overlay compositions before rendering.Written for commit 767dc6c. Summary will update on new commits.