Friction
Help documentation needs screenshots. Producing them by hand means driving the app, framing each screen, cropping, and — worst — re-doing all of it every time the UI changes. Meanwhile a tutorial already encodes exactly that walkthrough, and a render already takes the screenshots we want and then deletes them.
record writes a settled, full-viewport PNG per step to <workDir>/steps/<id>.png (record.ts:194), captured after the narration hold — the frame the viewer sees at the end of the step. It's gated on screenshots: wantContactSheet || options.debug (render.ts:79), consumed only by the contact sheet, and then the work dir is removed (render.ts:113).
So today the only way to get doc-ready stills is --contact-sheet --keep-work-dir and digging through .forge/. This issue makes that a first-class output instead of debris.
Proposal
A --stills option that exports the per-step frames as a durable, named artifact next to the video.
1. Durable destination. Copy steps/*.png to <output dir>/<name>-stills/NN-<step-id>.png, mirroring how GIF and chapter sidecars are emitted. Index-prefixed so docs order matches tutorial order.
2. An index sidecar. JSON (and/or markdown) mapping image → step id → narration. This is the part no generic screenshot tool can offer: the narration is already written alt text / figure captions. A docs author gets

essentially for free, and it stays in sync with the video's script.
3. Selection — "key" images. Default to all steps. Then:
--stills=<step-ids> (mirrors the existing --gif steps: shape) for a one-off subset, and
- a per-step
still?: true field on Step for tutorials that always want the same few frames.
Flag first; the per-step field can follow.
Design decisions worth settling up front
- The fake cursor is in the shot.
#__forge_cursor__ is a persistent DOM element (cursor.ts:16), so every still has the arrow parked on the step's focused control. For "click this button" docs that's a feature; for clean UI screenshots it isn't. Playwright's page.screenshot({ style }) can hide it per-shot, but it injects CSS mid-recording — small risk of a one-frame blip in the video. Wants a stills.cursor toggle, and a decision on the default (proposed: cursor on, matching the video).
- Callouts are not in the shot. The highlight ring self-removes ~200ms after the action (
callout.ts:28), long before the settled frame. So stills come out clean of rings. Getting callouts into stills would be a separate, larger change (re-draw + capture), explicitly out of scope here.
- No zoom. Zoom is an ffmpeg filter applied in post (
post/zoom.ts), so stills are always the full un-zoomed viewport. If docs want the zoomed crop, reuse computeZoomWindows to crop the PNG — small, but a follow-up.
- Before vs. after. Stills capture end-of-step state; docs often want the state before the click (the button you're about to press). Debug mode already writes
<id>-before.png / <id>-after.png, so a --stills=before,after variant is nearly free. Ship end-of-step first.
- 1× scale, video aspect.
deviceScaleFactor is 1 and the viewport is video-shaped (1920×1080). Fine for most docs, not retina-crisp. True 2× would need a separate post-record screenshot pass, since DSF also affects the video — propose punting.
- Partial output on failure. Completed steps already have settled screenshots on disk when a later step fails; the partial contact-sheet path (
render.ts:126) proves the pattern. Stills should degrade the same way — export what exists, never mask the StepError.
The fast path worth documenting
Because of #50, --phase record runs without TTS. That makes
forge render --phase record --stills
a full screenshot set with no TTS cost and no ffmpeg — the cheap "just give me the images" mode. Should be the documented recipe in the docs section.
Acceptance
forge render --stills leaves a directory of per-step PNGs next to the video that survives work-dir cleanup
- Each image is named so docs order is obvious, and an index sidecar carries step id + narration for alt text
forge render --phase record --stills produces the image set without TTS or ffmpeg
- A failed render still exports the stills for the steps that completed
- Documented in
docs/writing-tutorials.md, including the cursor caveat and the no-zoom/no-callout framing
Implementation sketch
Shaped like the contact-sheet PR (#9): a new pipeline/stills.ts (copy + index emit), option threaded types.ts → config.ts zod → main.ts flag → render.ts wiring (reusing the existing screenshots: gate), stillsPath/stillsFiles on RenderResult and the CLI summary, unit tests for naming + index generation. The risky parts — capturing settled frames, surviving partial failures — are already built and proven.
Motivated by wanting to reuse tutorial renders as the source of screenshots for help documentation.
Friction
Help documentation needs screenshots. Producing them by hand means driving the app, framing each screen, cropping, and — worst — re-doing all of it every time the UI changes. Meanwhile a tutorial already encodes exactly that walkthrough, and a render already takes the screenshots we want and then deletes them.
recordwrites a settled, full-viewport PNG per step to<workDir>/steps/<id>.png(record.ts:194), captured after the narration hold — the frame the viewer sees at the end of the step. It's gated onscreenshots: wantContactSheet || options.debug(render.ts:79), consumed only by the contact sheet, and then the work dir is removed (render.ts:113).So today the only way to get doc-ready stills is
--contact-sheet --keep-work-dirand digging through.forge/. This issue makes that a first-class output instead of debris.Proposal
A
--stillsoption that exports the per-step frames as a durable, named artifact next to the video.1. Durable destination. Copy
steps/*.pngto<output dir>/<name>-stills/NN-<step-id>.png, mirroring how GIF and chapter sidecars are emitted. Index-prefixed so docs order matches tutorial order.2. An index sidecar. JSON (and/or markdown) mapping image → step id → narration. This is the part no generic screenshot tool can offer: the narration is already written alt text / figure captions. A docs author gets
essentially for free, and it stays in sync with the video's script.
3. Selection — "key" images. Default to all steps. Then:
--stills=<step-ids>(mirrors the existing--gif steps:shape) for a one-off subset, andstill?: truefield onStepfor tutorials that always want the same few frames.Flag first; the per-step field can follow.
Design decisions worth settling up front
#__forge_cursor__is a persistent DOM element (cursor.ts:16), so every still has the arrow parked on the step's focused control. For "click this button" docs that's a feature; for clean UI screenshots it isn't. Playwright'spage.screenshot({ style })can hide it per-shot, but it injects CSS mid-recording — small risk of a one-frame blip in the video. Wants astills.cursortoggle, and a decision on the default (proposed: cursor on, matching the video).callout.ts:28), long before the settled frame. So stills come out clean of rings. Getting callouts into stills would be a separate, larger change (re-draw + capture), explicitly out of scope here.post/zoom.ts), so stills are always the full un-zoomed viewport. If docs want the zoomed crop, reusecomputeZoomWindowsto crop the PNG — small, but a follow-up.<id>-before.png/<id>-after.png, so a--stills=before,aftervariant is nearly free. Ship end-of-step first.deviceScaleFactoris 1 and the viewport is video-shaped (1920×1080). Fine for most docs, not retina-crisp. True 2× would need a separate post-record screenshot pass, since DSF also affects the video — propose punting.render.ts:126) proves the pattern. Stills should degrade the same way — export what exists, never mask theStepError.The fast path worth documenting
Because of #50,
--phase recordruns without TTS. That makesa full screenshot set with no TTS cost and no ffmpeg — the cheap "just give me the images" mode. Should be the documented recipe in the docs section.
Acceptance
forge render --stillsleaves a directory of per-step PNGs next to the video that survives work-dir cleanupforge render --phase record --stillsproduces the image set without TTS or ffmpegdocs/writing-tutorials.md, including the cursor caveat and the no-zoom/no-callout framingImplementation sketch
Shaped like the contact-sheet PR (#9): a new
pipeline/stills.ts(copy + index emit), option threadedtypes.ts→config.tszod →main.tsflag →render.tswiring (reusing the existingscreenshots:gate),stillsPath/stillsFilesonRenderResultand the CLI summary, unit tests for naming + index generation. The risky parts — capturing settled frames, surviving partial failures — are already built and proven.Motivated by wanting to reuse tutorial renders as the source of screenshots for help documentation.