Skip to content

feat: GIF render target, Playwright command player, synthetic cursor - #34

Merged
thorwhalen merged 2 commits into
mainfrom
feat/gif-render-target-and-command-player
Sep 17, 2026
Merged

thorwhalen merged 2 commits into
mainfrom
feat/gif-render-target-and-command-player

Conversation

@thorwhalen

Copy link
Copy Markdown
Collaborator

Three additions that together let walkthru record a browser demo end to end in Python — something it could describe but not play.

They came out of a real job: documenting an interactive HTML report in a README. Each gap was found by hitting it.

GifRenderTarget — walkthru.adapters.gif

The second RenderTarget, and a different kind from reelee's, which is the part worth naming: reelee builds a film from panels; this re-frames a screencast the recorder already produced. The Demo Document supplies the camera track, the video supplies the pixels.

PLAN §6 kept secondary targets for "build only when real". A GIF plays inline in a README on GitHub and PyPI where a video does not — that made it real.

All the meaning is pure and tested without ffmpeg installed:

camera_segments(timeline, total_ms=…) slices the timeline at each keyframe
crop_box(rect, zoom, frame_w=…, frame_h=…, aspect=…) geometry: aspect-corrected, clamped, even
gif_filtergraph(segments, …) the -filter_complex string
video_to_gif(…, runner=subprocess.run) the only part that shells out

ffmpeg is a system dependency, so there is no extra — an extra with nothing for pip to install would be a lie. check_ffmpeg() raises with install commands instead of letting a FileNotFoundError surface.

PlaywrightCommandPlayer — walkthru.adapters.playwright.player

The CommandPlayer the Python side lacked. The adapters could locate, wait and record; nothing could act, so a browser demo needed an app-specific executor.

PAGE_COMMANDS (the id → handler table) is a keyword argument, so an app with its own vocabulary reuses the player: commands={**PAGE_COMMANDS, "app.save": handler}. An unknown id raises by default — a silently skipped command produces a demo that is wrong in a way nobody notices until they watch the video.

install_synthetic_cursor — walkthru.adapters.playwright.cursor

The Python-side renderer for CursorCue, which has been in the schema since the first draft. Playwright's screencast — like every OS-level capture — omits the mouse pointer, so a demo whose whole point is "hover this and watch what happens" records as a tooltip appearing for no visible reason. The overlay is inert (pointer-events: none) and installed as an init script, so it survives navigation.

Two sharp edges, both found by a real render

Both are pinned by tests, because neither is visible from unit tests alone:

  1. setsar=1 per segment. Cropping different rects gives each segment a different sample aspect ratio, and concat refuses inputs whose SAR differs — it fails at configure time with Input link parameters do not match.
  2. The cursor SVG must be base64. A raw utf8,<svg xmlns='http://…'> data URI terminates the surrounding JavaScript string at its first apostrophe. An init script that fails to parse throws where nobody is watching: no exception reaches Python, the page renders normally, and the only symptom is a finished video with no cursor in it. That is exactly how the first version shipped.

Testing

182 passed (was 172). New: tests/test_gif_render_target.py (17), tests/test_playwright_player.py (15), tests/test_playwright_cursor.py (8). No browser and no ffmpeg needed — every adapter is duck-typed or takes an injected runner. The firewall test still passes: the core imports none of this.

First real use is ductus's README demo, whose recorder script is ~150 lines against this API.

Three additions that together let walkthru record a browser demo end to end in
Python, which it could describe but not play.

GifRenderTarget (adapters/gif) is the second RenderTarget, and a different kind
from reelee's: reelee builds a film from panels, this re-frames a screencast the
recorder already produced -- the document supplies the camera track, the video
supplies the pixels. A GIF plays inline in a README on GitHub and PyPI where a
video does not, which is what made a secondary target real (PLAN 6). All the
meaning is pure -- camera_segments, crop_box, gif_filtergraph -- and only
video_to_gif shells out, through an injected runner, so the path is tested with
no ffmpeg installed. ffmpeg is a system dependency, so there is no extra to
install; check_ffmpeg() raises with install commands instead.

PlaywrightCommandPlayer (adapters/playwright/player.py) is the CommandPlayer the
Python side lacked: the adapters could locate, wait and record, but nothing could
act. The id -> handler table is a keyword argument, and an unknown id raises by
default -- a silently skipped command produces a demo that is wrong in a way
nobody notices until they watch the video.

install_synthetic_cursor is the Python-side renderer for CursorCue. Playwright's
screencast omits the pointer, so a demo whose point is 'hover this and watch'
records as a tooltip appearing for no reason.

Two sharp edges found by a real render and pinned by tests: every segment needs
setsar=1 or concat rejects it (cropping different rects changes the sample aspect
ratio), and the cursor SVG must be base64 -- a raw data URI ends the surrounding
JS string at its first apostrophe, and an init script that fails to parse leaves
no trace except a video with no cursor in it.

See DECISIONS.md D14 and D15.
… the suite does

The suite deliberately has no pytest-asyncio dependency -- every async entry
point is driven through asyncio.run from a sync test. The new tests used the
marker instead, which passes locally where the plugin happens to be installed
and fails in CI with 'async def functions are not natively supported'.
@thorwhalen
thorwhalen merged commit 40782bc into main Sep 17, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the feat/gif-render-target-and-command-player branch September 17, 2026 11:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant