Documentation
Automating Release Videos
How we record every Pattern demo with WebReel and turn the clips into a branded release video with fframes. One command records, one renders.
A good release video shows the thing actually working. Not a mockup, not a screenshot on a slide, but the real block responding to a real click. That's what makes people want to try it.
Making one by hand is slow, though. You open a screen recorder, record each demo until you get a clean take, drag the clips onto a timeline, add a title card, nudge everything into place and export. That's an afternoon. We ship Pro Patterns every month, so it was an afternoon every month.
So we stopped editing release videos and started writing them.
Two tools do the work:
- WebReel records the demos. It drives a real browser through a scripted series of clicks, with an animated cursor, click sounds and zoom, and writes one MP4 per demo.
- fframes builds the video from code. It's a Rust library where every frame is an SVG tree, rendered on the GPU through Skia and encoded with FFmpeg. Our branding, layout and animation live in a source file, not an editor project.
Now a release video is one command to record, one command to render, and a single data file for the copy. Here's the September release, made entirely by the pipeline in this guide:
If the embed doesn't load, watch it on YouTube.
Each demo is also a square clip on its own. Here's one for Jev Search Filter, straight from WebReel:
If the embed doesn't load, watch the short on YouTube.
Recording demos with WebReel
One config per demo
Each Pattern gets a JSON config that describes what to click and what to wait for. Here's the one for Jev Action Gate:
{
"$schema": "https://webreel.dev/schema/v1.json",
"outDir": "~/Videos/webreel",
"baseUrl": "${RECORD_BASE_URL}",
"viewport": { "width": 1080, "height": 1080 },
"defaultDelay": 600,
"sfx": { "click": 1, "key": 1 },
"videos": {
"jev-action-gate": {
"url": "/view/jev-action-gate?theme=blue&mode=dark",
"waitFor": { "text": "Jev Action Gate" },
"zoom": 1.35,
"fps": 60,
"steps": [
{ "action": "pause", "ms": 1200 },
{ "action": "click", "text": "Read the logs" },
{ "action": "wait", "text": "Passes through.", "timeout": 30000 },
{ "action": "pause", "ms": 2500 },
{ "action": "click", "text": "Drop the staging database" },
{ "action": "wait", "text": "Stays shut.", "timeout": 30000 },
{ "action": "pause", "ms": 3000 }
]
}
}
}None of these values are arbitrary.
The viewport is a square, 1080×1080. The clip ends up in a square card in the release video, and a square clip also works as a standalone social post, so one recording gets used twice.
baseUrl comes from an environment variable, so the same config records against localhost:4000 or a preview deployment.
We record /view/..., not the catalog page. The catalog page has a preview guard that swallows the first click, and a demo whose first click does nothing looks broken.
waitFor targets the demo's heading, so the steps only start once the page has hydrated.
Click, wait, pause
Every AI interaction in a demo follows the same three beats:
{ "action": "click", "text": "Needs a reply" },
{ "action": "wait", "text": "rows fit.", "timeout": 30000 },
{ "action": "pause", "ms": 2500 }The click starts the interaction. The wait holds until the result is on screen. The pause gives the viewer time to read it.
The wait is the one that matters most. It's tempting to swap it for a fixed pause, but live model latency ranges from 2 to 45 seconds. A fixed pause is either too short, and the recording moves on before the answer lands, or too long, and every clip has dead air. Waiting on the result text keeps the recording honest and lets it move on as soon as the answer is there.
We also start every clip with a 1.2 s pause and end it with a 3 s pause. Without them, clips open and close mid-motion, and the cuts in the final video feel abrupt.
To write steps for a new demo, open its *-demo.tsx and look for two things:
- The visible labels on its chips and buttons. These are your
clicktargets. - The string that only appears once a result lands. This is your
waittarget.
Gotchas we hit
| Gotcha | Fix |
|---|---|
| Text matching picks the smallest element containing the text, so labels like "Filter" can click the wrong thing. | Use a selector for ambiguous labels. |
A wait on text that's already visible passes instantly. | Wait on a string that only appears after this step's result. |
type was unreliable in webreel 0.1.4. | Drive demos with chip and button clicks. |
| The schema rejects unknown fields. | Run webreel validate before you record. |
Recording in batches
Start the app, then record three demos at a time:
pnpm v4:dev
printf '%s\0' apps/v4/webreel/jev-*.config.json \
| xargs -0 -n 1 -P 3 -I {} env RECORD_BASE_URL=http://localhost:4000 \
npx -y webreel@latest record -c "$PWD/{}" --verbosePass absolute config paths, because npx resolves relative paths from the package directory.
Every run spends real AI Gateway tokens. If you see 429s, drop to -P 1. Record demos that call third-party APIs (Exa, Firecrawl) on their own.
You end up with one MP4 per Pattern, plus a thumbnail.
Building the release video with fframes
An editor project is fine for a one-off. For something you make every month, it becomes a problem. The settings live in someone's head, and each release looks a little different from the last. fframes lets us treat the video like the rest of our code: versioned, reviewable, and the same every time.
Setup
brew install pkg-config ffmpeg x264 x265 opus nasm ninja
cargo install --locked cargo-fframes
cargo fframes new jev-release --template multi-scene --format landscape --fps 30 --yes
cd jev-release && cargo build --releaseOn a Mac, the default backend is Skia on Metal. The first build downloads prebuilt Skia and takes about a minute.
The release is data
This is the idea the whole setup rests on. Everything that changes between releases lives in one file:
pub const DEMOS_DIR: &str = "~/Videos/webreel";
pub const RELEASE: Release = Release {
kicker: "Pro patterns, September 2026",
title_lead: "New ",
title_pixel: "Jev blocks.",
subtitle_lead: "Twelve live demos.",
subtitle_rest: " Thresholds stay in your code.",
end_url_lead: "All twelve at ",
cta: "Open a block",
url: "aisdkagents.com/patterns",
// ...
};
pub const BLOCKS: [Block; 12] = [
Block {
scene: "ActionGate",
name: "Jev Action Gate",
blurb: "Auto-run an action only when it looks safe.",
detail: "Your code owns the threshold",
video: "jev-action-gate.mp4",
clip: (0.0, 12.5),
},
// ...
];The video derives everything else from this file. The scene count, each scene's length, the NEW 03/12 counter, the progress bar and the total duration all follow from BLOCKS.
Want to trim a demo? Change its clip. Want to add one? Add a Block. There's no timeline to drag around.
Scenes
The video is a list of scenes:
- Title card: kicker, headline and subtitle.
- One demo scene per block: name, blurb and threshold badge on the left, the recording on the right.
- End card: CTA and URL.
A demo scene takes its duration from the clip and plays the recording frame-synced, audio included:
impl Scene for DemoScene {
fn duration(&self) -> Duration<'_> {
Duration::Seconds(self.block.seconds())
}
fn overlap(&self) -> Overlap {
Overlap::Previous(0.4) // cross-fade
}
fn audio(&self) -> AudioMap<'_> {
AudioMap::from([AudioTrack::new(self.block.video, Second(0.)..Second(0.4 + self.block.seconds()))
.offset(self.block.seek())
.fade_in(0.4)
.fade_out(0.4)])
}
fn render_frame<'a>(&'a self, frame: Frame, ctx: &FFramesContext<'a, '_>) -> Svgr<'a> {
let video = frame.get_synced_video_frame(ctx, self.block.video, &SyncVideoFrameInput {
start_from: self.block.seek(),
looping: false,
editor_fallback_image: None,
});
// ...left column text, plus the frame as an <image> clipped into a rounded card
}
}The recordings aren't compiled into the binary. They're read at runtime from DEMOS_DIR and merged with the embedded fonts:
let fonts = JevReleaseMedia::prepare()?;
let folder = MediaDirectory::read_folder(demos_dir())?;
let demos = folder.process_media_source()?;
let media = CombinedMediaProvider::from([&fonts as &dyn MediaProvider, &demos]);Branding in code
The brand is a handful of constants taken from our own design tokens in globals.css:
- Colors: muted background, white card, near-black ink.
- Fonts: the Geist family, plus one Geist Pixel moment per view.
- Shadow: the site's soft two-layer shadow, defined as an SVG filter.
- Entrance: the site's blur-in rise, a 0.6 s cubic-bezier.
This is the part that pays off most over time. Because the brand is code, every release video matches the site and the one before it, and nobody has to remember which shadow we used last month.
One font gotcha: fframes resolves fonts by family name. Geist SemiBold quietly fell back to Regular until we renamed the families inside the font files ("Geist Display", "Geist UI") with fontTools.
Speeding up clips
Real-time demos are honest, but they're slow to watch. At real speed, our 12-demo release ran over three minutes.
fframes has no playback-rate API, so we pre-encode a 2× copy of the recordings with FFmpeg:
for f in "$SRC"/*.mp4; do
ffmpeg -y -i "$f" -filter_complex "[0:v]setpts=0.5*PTS[v];[0:a]atempo=2.0[a]" \
-map "[v]" -map "[a]" -c:v libx264 -crf 18 -c:a aac "$OUT/$(basename "$f")"
doneThen set CLIP_SPEED = 2.0 in blocks.rs. The clip values stay in original-recording seconds, and scene lengths are divided by the speed. The release went from 3:02 to 1:34.
Checking the video without watching it
Watching a 90-second video after every change gets old quickly, and by the tenth viewing you stop noticing a clipped line of text. The fframes CLI is built for review without playback, by people and by coding agents:
R() { cargo run --release -- "$@"; }
R timeline # scenes, frame ranges, every audio track
R inspect --fail-on warning # missing fonts, clipped text, invalid SVG, panics
R strip all -n 16 # contact sheet of the whole video as one PNG
R frame ActionGate@4s,EndScene@2s # full-size PNGs for detail checks
R preview # real-time GPU player with soundA contact sheet from strip shows the whole video at a glance. inspect catches problems you'd struggle to see at all. For example, a progress bar that starts at width 0 becomes an invalid SVG rect, so we clamp it to a 0.5 px hairline.
Snapshot tests lock the look in place:
FFRAMES_UPDATE_SNAPSHOTS=1 cargo test --release # accept after reviewing the PNGs
cargo test --release # fails if a frame driftsThe release loop
Each release now looks like this:
- Ship the Patterns.
- Add a WebReel config for each new demo. Copy an existing one, set the URL and write the click, wait, pause steps.
- Record all the configs in batches of three.
- Edit
blocks.rs: the release copy and oneBlockper demo. - Check and render:
R inspect --fail-on warning && R strip all -n 16
R render -o ~/Videos/jev-demos-2026-09.mp4The 12-demo, 1:34 video renders in about 12 seconds on an M-series Mac. The afternoon is gone.
Same engine, different videos
Once the brand lives in code, new formats are cheap.
From the same setup we built a 30-second explainer for aisdkagents.com. It uses a different visual system: blueprint paper, yellow tags and Geist Mono. Its centerpiece is an animated "how Jev answers" diagram, where a request flows from your app into Jev and the scored options fill in as bars. That video uses no recordings at all, just six scenes of SVG and springs.
What's next
- CI recording. Run WebReel against the preview deployment on every Patterns PR, so the clips exist before merge.
- Generate
blocks.rsfrom the registry. The block names and blurbs already live in our registry metadata. - A music bed. fframes has audio ducking built in (
duck_under_voice), so a track can sit under the demo sounds. - Portrait cuts. The square recordings drop into a 1080×1920 layout for social with one more scene file.
The hard part was never rendering
The goal wasn't to automate video for its own sake. It was to make release videos cheap enough that we'd actually make one every time.
What surprised us is that rendering was never the hard part. The hard part was deciding what each demo should show. Once that's written down as click, wait, pause, the video takes care of itself.
On this page
Recording demos with WebReelOne config per demoClick, wait, pauseGotchas we hitRecording in batchesBuilding the release video with fframesSetupThe release is dataScenesBranding in codeSpeeding up clipsChecking the video without watching itThe release loopSame engine, different videosWhat's nextThe hard part was never renderingai sdk blocks.