Skip to content

feat(core): capture a whole deck as video from the CLI - #2

Open
desek wants to merge 3 commits into
feat/export-slides-as-pngfrom
feat/cli-capture-deck-as-video
Open

feat(core): capture a whole deck as video from the CLI#2
desek wants to merge 3 commits into
feat/export-slides-as-pngfrom
feat/cli-capture-deck-as-video

Conversation

@desek

@desek desek commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Adds open-slide video, a headless CLI subcommand that captures a whole deck as a single continuous MP4.

Stacked on 1weiho#379 — this targets feat/export-slides-as-png and reuses the capture pipeline that PR establishes. Review after it lands, or read the three commits here in isolation.

Motivation

A deck is currently shareable three ways: presented live, exported as static HTML, or exported as PDF. All three require a viewer to drive the deck themselves, and none of them carry the animation an author spent effort on.

A recording is the missing distribution format. Release notes, README headers, docs pages, and social posts all take video and none of them take a deck. The workaround today is to screen-record present mode, which is the wrong tool: a screen capture samples whenever the compositor delivered a frame, so it drops and duplicates frames under load, cannot be reproduced, and bakes in whatever the recording machine was doing at the time. A re-record is never the same file, so it can be neither diffed nor regenerated in CI.

The pipeline already exists. 1weiho#379's export.ts boots Vite in process, launches Chromium, enumerates decks, resolves targets, and writes atomically. None of that needed rebuilding. What it lacked is time: renderOne navigates, waits for the page to settle, and screenshots once.

What's included

The subcommand. open-slide video --slide <id> produces one MP4 covering every page in order; --all produces one per deck.

open-slide video --slide governance-loop
open-slide video --all --fps 60 --dwell 2000
open-slide video --slide intro --per-page --keep-frames

Flags: --slide / --all (mutually exclusive), --page (requires --slide), --out, --fps (1–120, default 30), --duration, --dwell (default 1500 ms), --per-page, --keep-frames, --port, --timeout.

How time works. Decks animate with wall-clock CSS keyframes and expose no playhead, so frames cannot be sampled on a timer. Every animation is paused as it appears via an init script, and currentTime is driven explicitly per frame. That makes existing CSS-keyframe decks seekable with no authoring change. Each page contributes its measured animation duration followed by the run-wide --dwell hold; a page with no finite animation contributes a documented fallback rather than being skipped, and a measured duration is clamped so one runaway delay cannot produce an unbounded render.

Transitions are the viewer's own. Rather than compositing a crossfade in ffmpeg, the capture advances the deck in place and seeks the transition animations SlideTransitionLayer creates. The deck's declared transitions — including morph — appear in the file exactly as an audience sees them, and there is no second definition of "transition" to drift from the viewer's. The cost is that capture is one continuous browsing session rather than independent page loads; ffmpeg only concatenates.

Step-gated decks fall back to per-page output. A continuous session cannot cross a page with pending steps without walking its reveals. Such decks produce one MP4 per page, and the command says so rather than silently changing shape. Worth stating precisely, because an earlier draft of this CR got it wrong: a directly-loaded step page arrives with every step already revealed, so a fallback capture is missing no content — what it loses is the reveal motion and the inter-page transitions. The warning recommends authoring a loop variant and names what one entails.

Prerequisites are preflighted. playwright-chromium stays a devDependency reachable only through a dynamic import; ffmpeg is invoked as a subprocess and never vendored. Both are checked before any work, exiting 2 with a copy-pasteable install hint. Output is H.264 / yuv420p / 1920×1080, so it plays in browsers and system players rather than only in developer tooling.

Implementation notes

The readiness signal from 1weiho#379 must not be used here, and that is counter-intuitive. __OPEN_SLIDE_EXPORT_READY means every finite animation has finished — and a finished animation leaves document.getAnimations(), so seeking after readiness finds nothing to seek and produces the settled frame repeated N times. A valid MP4 of a still image, with no error. The PNG exporter is built to wait for motion to stop; video requires that it never start. Fonts are awaited directly instead.

Three related traps, each of which produced a plausible-looking wrong file before it was found:

  • A finished animation with a forwards fill stays in getAnimations(). A settled enter transition was being measured as the next page's content duration, and the per-frame seek then reset it to zero, replaying the transition underneath the page it had just joined. Settled and boundary animations are tagged, and every path that enumerates animations skips tagged ones.
  • SlideTransition.duration is not the boundary's length. runPhase resolves each phase as phase.duration ?? duration with its own delay, and morph carries its own timing, so a boundary sized from the declared value truncates any deck that overrides either. Boundaries are measured from the animations that actually appeared.
  • prefers-reduced-motion deletes every transition. player.tsx passes disabled={prefersReducedMotion} to the transition layer, whose disabled branch swaps pages with zero animations — a valid file with no boundaries and no error. The browser context pins reducedMotion: 'no-preference'.

Determinism is qualified, deliberately. Page and dwell frames are byte-identical across runs. Transition boundary frames are not: edge antialiasing on scale-animated compositor layers rasterises to one of two stable variants per run, measured at YMAX 93 / YAVG 0.16 on slide-transitions across 10 frames of 860. The acceptance criteria were split rather than weakened — byte-identity for page and dwell segments, a YMAX ≤ 160 / YAVG ≤ 1.0 perceptual bound for boundaries — and Risk 12 in the CR records that capturing the viewer's transitions is what introduced this. An encode-time crossfade would have been deterministic by construction; the trade was made knowingly.

Pure helpers are separated from orchestration. Duration resolution, frame counting, filename derivation, output-shape selection, and deck timeline planning are exported and unit-tested without Playwright or ffmpeg.

Incidental fixes

  • Malformed CLI flags exited 1 instead of 2. Commander only converts InvalidArgumentError into a code-2 exit — and its own default exit for a usage error is also 1, so the parser change alone was insufficient and a program-level exitOverride was needed too. This also corrects export, hence a separate patch changeset. The spec that should have caught it called validateFlags directly and never traversed Commander; the replacement drives run(argv) and fails 8 of 14 against the old code.
  • playwright-chromium was undeclared. feat(core): export slides as PNG from the viewer and the CLI 1weiho/open-slide#379's export dynamically imports it but does not list it, so a fresh clone cannot exercise that path. Added as a root devDependency here; it arguably belongs in feat(core): export slides as PNG from the viewer and the CLI 1weiho/open-slide#379.

Documentation

A CLI reference page alongside the other subcommands, a README entry, and a loop-variant.md reference in the slide-authoring skill for authoring decks meant to be recorded. That reference already existed, written before anyone had built a loop variant, and three of its claims did not survive doing so: that the closing transition belongs on the last page (transitions attach to the incoming page, so the last page never gets an outgoing one), that infinite animations suit ambient motion (the runtime remounts the outgoing page, restarting them at zero at every cut), and that a page with no finite animation costs only the dwell. It gains a "snapshot contract" section covering the failure modes that only appear in motion.

CR-0003 and its validation report follow the docs/cr/ convention from 1weiho#379. The iterate ledger is a new artifact type recording the last mile across 18 attempts, including the ones that failed. It references a demo deck used to exercise the capture which is deliberately not included in this branch, so several of its later entries describe code that is not in the diff.

Testing

Check Result
pnpm test 398 passed (26 files)
pnpm typecheck no errors
pnpm check exit 0
pnpm build clean

Manual verification: ffprobe confirms H.264 / yuv420p / 1920×1080 / 30fps on both output shapes; the built runtime bundle contains no static playwright-chromium reference; a step-gated deck warns, writes per-page files, and exits 0; determinism measured across three captures per deck as described above.

desek added 3 commits July 29, 2026 13:35
Adds `open-slide video`, which drives headless Chromium through the viewer and
writes a single MP4 per deck. Animations are paused from document start and
seeked by `currentTime` per frame, so existing CSS-keyframe decks are capturable
with no authoring change.

Inter-page transitions are captured from the viewer rather than composed at
encode time, which keeps one definition of a transition and makes the output
match what an audience sees. Step-gated decks fall back to one MP4 per page,
reported with guidance on authoring a loop variant.

Also fixes malformed CLI flags exiting 1 instead of 2. Commander only converts
InvalidArgumentError into a code-2 exit, and its own default usage exit is 1, so
this needs both the parser change and a program-level exitOverride. Applies to
`export` as well, hence the separate patch changeset.
Adds the CLI reference page alongside the other subcommands, and corrects the
loop-variant authoring reference, which was written before a loop variant
existed and carried three claims that did not survive building one.
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