Clips
A clip is a short animated recording of a terminal app,
next to its still shots: a few keys pressed, a list scrolled, a command typed. showcase record
writes the clips as animated WebP and GIF (MP4 on request), framed like the README images, and
showcase all records them too once a config has clips.
A first clip
Section titled “A first clip”import { defineConfig } from '@noctcore/showcase-kit';
export default defineConfig({ name: 'Rumi', target: { mode: 'tty', command: ['bun', 'run', 'src/index.tsx'], env: { RUMI_MOCK: '1' } }, ready: /resources \(\d+\)/, frame: { background: '#1e1b4b' }, shots: [{ id: 'resources', title: 'Resources' }], clips: [ { id: 'tour', title: 'Tour', caption: 'Moving through the resources, then the logs.', steps: [ { sleep: 1000 }, { keys: 'jjj' }, { sleep: 800 }, { keys: '{Tab}' }, { waitFor: 'logs' }, { type: 'api', delayMs: 120 }, ], }, ],});npx showcase recordWith the default outputs.clips, that writes assets/showcase/en/tour.webp and
assets/showcase/en/tour.gif.
How a clip runs
Section titled “How a clip runs”Each clip starts a fresh app, so it is the same from a clean start every time. For each clip and each language the kit:
- starts the app and waits for
ready, then thetarget.inputDelayMsgrace, then runssetup; - runs the steps while it records the screen;
- keeps recording for
tailMsafter the last step; - closes the app with
quitKey, then kills its process tree (also on a failure and on Ctrl+C).
A failed clip fails alone, like a failed shot: the other clips are still written, then the run fails with every failure listed.
steps is the timeline, a non-empty array. Each step does exactly one thing:
| Step | Does |
|---|---|
{ keys } |
Presses keys, with the same syntax as a shot’s keys: 'jjj', '{Tab}', ['{Down}', '{Enter}']. |
{ type, delayMs? } |
Types text as is (no {Key} names): all at once, or one character every delayMs. |
{ waitFor } |
Waits until the text (a substring or a RegExp) is on screen, for up to timeouts.shotMs (default 15000). |
{ sleep } |
Pauses, in milliseconds. |
A step with two kinds in it ({ keys: 'j', sleep: 500 }) is a config error: split it into two steps,
in the order they should run.
The frame clock
Section titled “The frame clock”The kit samples the screen on its own clock, once per frame, fps times a second (default 10,
from 1 to 50). The steps run in step with that clock, right after a sample:
- A key’s effect shows from the next frame on, as long as the app redraws within one frame (100 ms at 10 fps).
- A
keysortypestep takes at least one frame. - A
sleepis rounded to whole frames. - A slow
typeputs each character on the frame itsdelayMsfalls on. - A
waitForlooks at the recorded frames, so the frame it waits for is in the clip.
Frames that did not change are merged into one longer frame, and each distinct screen is rendered once. That makes two things true:
- Idle time is cheap. A tail or a pause on an unchanged screen is a single frame, whatever its
length or
fps. - Recordings repeat. On one OS, two recordings of the same clip of an app with a frozen screen give byte-identical files. Timing noise in the app can only move a change to a neighbouring frame; it never changes the clip’s length.
Keep fps at 10 unless the app animates on purpose: a higher rate only adds frames when the screen
changes that often. The limit is 50 because above it GIF frame delays drop under 20 ms, which
browsers play as 100 ms.
How a clip ends
Section titled “How a clip ends”The recording ends at whichever comes first:
tailMsafter the last step (default 1500 ms), rounded to whole frames. A trailingsleepruns out before the tail starts.durationMs(default 60000 ms), an upper bound on the length. If steps were still left, the kit warns and names how many.maxFrames(default 300), an upper bound on the frames, counted after unchanged frames merge. The kit warns, stops recording and writes what it has. Every distinct frame is held in memory until the clip is encoded, so raise it with care.
The app may exit during the tail, for example a CLI that prints and exits, or a last step that quits
the app: the clip then ends on its last screen, held for the rest of the tail. A waitFor that the
last screen already shows still passes after the exit, so a one-shot CLI can be recorded with
steps: [{ waitFor: 'done' }]. Exiting while any other step is still to run fails the clip, with the
last screen in the error.
Framing
Section titled “Framing”Clips are framed like the README images: the frame is rendered once
per clip and every frame of the terminal is put into it, so a clip matches the stills next to it. In
frame.title, {title} is the clip’s title (default: its id). frame.maxWidth applies to clips
too.
Formats
Section titled “Formats”formats takes any of 'webp', 'gif' and 'mp4', each at most once. The default is
['webp', 'gif']. WebP and GIF loop forever.
| Format | Encoder | Notes |
|---|---|---|
| WebP | sharp, lossless | Sharp text, full color, alpha. Small when the frame background is a solid color. |
| GIF | sharp, 256 colors | Plays everywhere. Delays are whole hundredths of a second, rounded on a running total so the length holds, and never under 20 ms. |
| MP4 | ffmpeg from PATH, H.264 (CRF 23), 30 fps |
Smallest for busy clips. No alpha: a transparent frame background turns black. Odd sides are padded by a pixel. |
GitHub READMEs show WebP and GIF with <img>. MP4 is opt-in: GitHub is not known to play a video from
the repository inline, so the README table links to it, and a portfolio site can use <video>.
ffmpeg
Section titled “ffmpeg”ffmpeg is needed only for 'mp4':
- It is found on PATH without a shell:
ffmpegon macOS and Linux,ffmpeg.exeon Windows (a.cmdwrapper does not count). Relative PATH entries (such as.) are skipped, so the binary never depends on the working directory. - Install it with
winget install ffmpeg,brew install ffmpegorapt install ffmpeg. - When a clip you record asks for MP4 and there is no ffmpeg,
recordstops before it starts any app or browser and says so. When none asks for MP4, it notes that MP4 was skipped. - An encode that takes over 5 minutes is stopped with an error that suggests fewer or smaller frames.
- Ctrl+C while ffmpeg runs stops it and removes its temporary folder.
A 10 s clip of the kit’s test TUI (120x32, 9 distinct screens, one key a second), framed, at DPR 2 (2496x1696), measured on Windows:
| Frame | WebP | GIF | MP4 |
|---|---|---|---|
| default gradient background | 375 KB | 361 KB | 262 KB |
solid #1e1b4b background |
91 KB | 219 KB | 208 KB |
style: 'none', transparent, no padding or shadow |
80 KB | 148 KB | 184 KB |
default gradient, maxWidth: 1200 |
245 KB | 159 KB | 102 KB |
An app whose screen changes on every frame is the other end: the same 10 s with 100 distinct screens was 2.5 MB of WebP, 11.8 MB of GIF and 7.7 MB of MP4, and took about two minutes to render and encode.
Keeping clips small and repeatable
Section titled “Keeping clips small and repeatable”- Freeze the app (see Terminal determinism): a spinner or a clock makes every frame distinct, which multiplies the size and the time.
- Use a solid
frame.background: a gradient does not compress losslessly and is most of a quiet clip’s WebP. - Keep
fpsat 10 unless the app animates on purpose. - Keep clips short, with a
tailMsjust long enough to read the last screen, and setdurationMsas a guard. - For GIF,
deviceScaleFactor: 1keeps files and encode times down.frame.maxWidthdepends on the background: it shrank the gradient clip in the table above, but scaling blends the terminal’s sharp pixel edges, so on a solid background it can make the files larger. The gallery’s clip grew from 121 KB to 351 KB of WebP and from 233 KB to 302 KB of GIF atmaxWidth: 1600. Measure before you keep it. - Record on one OS if committed clips must not churn, as with the stills.
Where clips go
Section titled “Where clips go”outputs.clips is the path template, relative to the config root. Default
assets/showcase/{lang}/{id}.{ext}.
- Tokens:
{lang},{id},{slug}and{ext}(the format:webp,giformp4). - It must contain
{id}and{ext}, and{lang}when the config has more than one language, or files would overwrite each other. It must end in.{ext}.
A clip’s id (letters, digits, - and _) must not repeat a shot id: clips and shots share output
folders. See the config reference for every output.
Commands
Section titled “Commands”showcase record records every clip in every language. --only tour,intro picks clips by id and
--langs en,de picks languages; an unknown id or language is an error that lists the known ones.
See the CLI reference.
showcase all captures, frames and exports the portfolio, then records the clips when the config
has any. Its --only takes shot ids and clip ids together: naming only clips skips the capture, and
naming only shots skips the recording.
showcase readme lists the clips after the shots in the README table,
captioned with caption (default: the title):
- an
<img>of the clip’s first image format informatsorder (the WebP with the default formats; list['gif', 'webp']to show the GIF instead), plus anMP4link in the caption when the clip has an MP4; - only a link, for a clip whose formats are just
['mp4'].
With outputs.readme: false the table lists only the clips.
Every clip key
Section titled “Every clip key”id, title, caption, alt, steps, fps, durationMs, maxFrames, tailMs and formats,
with types, defaults and limits, are in the config reference.
From code, record(await loadConfig(), { only, langs }) records and resolves with each clip’s files,
and encodeAnimation(frames, { format }) encodes frames you made yourself: see the
programmatic API.