12 KiB
Video Script Basics
ScreenCI videos use the same syntax, guides, and general best practices as Playwright tests. The main difference is that video scripts usually do not need assertions, and instead focus on viewer-facing behavior such as narration and camera movement.
Need a faster way to get a first draft? Generate one with Playwright codegen (or let a coding agent do it, see Agent integration), then come back here to refine the script structure and ScreenCI-specific APIs.
You will learn
- how to generate a first draft with codegen
- how a ScreenCI video differs from a Playwright test
- how to configure ScreenCI
- which core ScreenCI APIs to use
Generate a first draft with codegen
Writing a script from scratch is optional. Playwright's test generator (codegen) lets you click through the exact flow in a browser and have the actions written for you. Run:
npx playwright codegen https://your-app.example.com
This opens a browser window and the Playwright Inspector. As you click, type, and navigate, it generates Playwright actions for the flow. The output is not a ScreenCI video yet. To turn it into one:
- copy the generated code into a
recordings/<flow>.screenci.tsfile - change
test(...)tovideo.narration(...)(...) - add narration through
video.narration({...})(see Core ScreenCI APIs)
Then follow the usual screenci test and screenci record flow. If you only
have a deployed URL and want this automated, point a coding agent at it with the
playwright-cli skill, see Agent integration.
ScreenCI video vs Playwright test
The generated starter video keeps the same Playwright-style structure, but it
uses a ScreenCI-instrumented page and locators so visible interactions are
captured with the right metadata for recording. See Page
Instrumentation. Use it as the baseline shape for
most ScreenCI videos, then adjust the visible flow, narration cues (via
video.narration), and zoom
behavior for your specific walkthrough.
import { autoZoom, hide, video, voices } from 'screenci'
// The default voice (how narration is spoken) for every language.
video.use({ renderOptions: { narration: { voice: { name: voices.Sophie } } } })
// Localized narration cues by language, plus a brand intro overlay. The fixture
// exposes narration markers and overlay controllers to the body.
//
// The logo image (recordings/assets/logo.png) is gitignored: it is uploaded to
// the ScreenCI backend on the first record and reused on later runs (CI
// included), so the binary does not need to be committed.
video
.overlays({
logo: { path: './assets/logo.png', fill: 'recording', durationMs: 2000 },
})
.narration({
en: {
docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.',
},
es: {
docs: 'Aqui es donde encontrar la documentacion de ScreenCI [pronounce: screen see eye].',
},
})('How to find docs', async ({ page, narration, overlays }) => {
// Run setup without showing these actions in the final recording.
await hide(async () => {
await page.goto('https://screenci.com/')
await page.waitForLoadState('networkidle')
})
// Open with a brief brand intro card before the walkthrough begins.
await overlays.logo(2000)
// Play the matching narration line for this step.
await narration.docs()
// Automatically zoom into interactions so they are easier to follow.
await autoZoom(async () => {
await page.getByRole('link', { name: 'View Documentation' }).click()
})
})
You can define multiple video() calls in the same file, or create multiple
.screenci.ts files under recordings/.
Configure ScreenCI
Project-wide defaults such as projectName, recordingDir, baseURL, and shared
recording or rendering options live in screenci.config.ts.
import { defineConfig } from 'screenci'
export default defineConfig({
// Used to identify this project in ScreenCI.
projectName: 'my-product',
})
See Configuration.
Core ScreenCI APIs
hide(), speed(), and time()
Timeline helpers that decide whether a step is removed from the final video or
just retimed. Not every automated step belongs in the recording: use hide()
for non-viewer setup the viewer does not need to watch, such as signing in,
accepting cookies, waiting for app state, or opening the right screen before the
visible flow begins.
// hide(): run the step but remove it from the output.
// Use for navigation, sign-in, waiting, or dismissing banners.
await hide(async () => {
await page.goto('/reports')
await page.getByRole('button', { name: 'Accept cookies' }).click()
})
// speed(): keep the step visible, but play it faster or slower.
// 1 = real time, 0.5 = half-speed (2x longer), 2 = 2x speed (half as long).
await speed(0.5, async () => {
await page.getByRole('button', { name: 'Preview invoice' }).click()
})
// time(): keep the step visible and make it occupy an exact duration.
// Here the visible block lasts exactly 1000ms in the output.
await time(1000, async () => {
await page.getByRole('tab', { name: 'Analytics' }).click()
await page.waitForLoadState('networkidle')
})
// Nesting rules:
// - hide() may sit inside speed() or time(), but not inside another hide().
// - speed() and time() may not be nested inside each other or themselves.
// - Narration cue audio is not retimed; these only remap the recording timeline.
API reference: hide(). See also Page Instrumentation for how visible actions are captured.
Positions: holding narration and overlays until a point in the video
Narration cues and overlays can take a string position so they land at an
absolute point in the finished video, instead of a relative duration. This is
handy in long stretches (for example a recorded playback) where hand-computing
page.waitForTimeout deltas is brittle.
// Narration: start the line and hold its window until the position.
await narration.intro('0:10') // until 10 seconds in
await narration.outro('56%') // until 56% through the video
// Overlays: keep the (static) overlay on screen until the position.
await overlays.tip('0:10') // until 10 seconds in
await overlays.tip('2s') // seconds (fractions allowed: '5.51s')
Accepted forms: '<n>s' seconds, 'm:ss(.f)' / 'h:mm:ss(.f)' timecodes, and
'<n>%' percentages. A bare number stays a relative length (overlay duration in
ms). Positions are resolved against the finished render, so they are correct
against the actual video, and narration audio is never cut (the window extends to
let a line finish). Percentages are not supported on .mp4 or animated overlays,
whose length is fixed. See Narration and
Overlays.
autoZoom()
Use autoZoom() when the camera should follow a visible interaction
automatically. See Camera and Zooming.
Common options:
durationto control how fast the zoom moveseasingto control motion feelamountto control how tightly ScreenCI zooms inpaddingto keep more space around the target areacenteringto bias framing within the viewportpreZoomDelayandpostZoomDelayto add breathing room before or after the zoomed sequence
await autoZoom(async () => {
await page.getByRole('button', { name: 'Create project' }).click()
})
API reference: autoZoom()
zoomTo()
Use zoomTo() when you want exact manual framing, and resetZoom() when you
want to return to the default view afterward. See Camera and
Zooming.
zoomTo() accepts either:
- a locator, when you want framing tied to a real UI element
{ x, y }, when you want to frame an exact point manually
Common options for zoomTo() and resetZoom():
durationeasingamountpaddingpreZoomDelaypostZoomDelay
await zoomTo(page.getByTestId('pricing-card-pro'))
await page.getByRole('button', { name: 'Upgrade' }).click()
await resetZoom()
API reference: zoomTo(), resetZoom()
video.narration(), video.values(), and video.overlays()
Use the per-feature builders to attach narration cues, localized strings, and
overlay controllers to a video. Each builder returns the same chainable video,
so you can combine them and end with a (title, body) call. The body receives
the fixtures matching the features you declared: narration markers (timing
only), values field values, overlays controllers, and the active language. See
Narration.
video.narration({...}) accepts either form:
- an object keyed by language (
en,es,fi, ...) of cue name to text = per-language narration - a flat object of cue name to text (for example
{ intro: 'Hi' }) = shared across all languages - an array of cue names (for example
['intro']) = name-only cues where Studio (the web editor) owns the text
Other parts of the spec:
- chain
video.values({...})for localized strings injected into the page - chain
video.overlays({...})to declare overlay controllers (see below) - short, sentence-sized cues instead of paragraph-sized narration blocks
Voice is configured separately as a render option in renderOptions.narration
(via video.use(...) or screenci.config.ts), with a default voice and
per-language voices overrides.
import { video, voices } from 'screenci'
// Voice is a render option (how narration is spoken).
video.use({
renderOptions: { narration: { voice: { name: voices.Sophie } } },
})
// Localized narration cues by language.
video.narration({
en: { intro: 'Open settings and review the billing details.' },
es: {
intro: 'Abre la configuracion y revisa los detalles de facturacion.',
},
})('Billing walkthrough', async ({ page, narration }) => {
// Play the full cue before continuing.
await narration.intro()
// Or use start/end when narration should overlap with the visible actions.
await narration.intro.start()
await page.goto('/settings')
await page.getByRole('button', { name: 'Open billing' }).click()
await narration.intro.end()
})
Prefer one sentence per cue. Split longer narration into separate named cues and place them where they belong in the flow. That gives you cleaner overlap control, makes revisions less brittle, and should save API cost when a TTS provider such as ElevenLabs only needs to regenerate one changed sentence.
To control which languages are recorded, chain video.languages(...) (accepts
'studio', an array of language codes, or { languages, mode }). For example,
video.narration({...}).languages({ mode: 'shared' }) records a single shared
narration track instead of one per language.
Overlays
Use video.overlays({...}) to declare overlay controllers for a video. The
controllers are exposed through the overlays fixture in the body:
import { video } from 'screenci'
video.overlays({
logo: { image: 'logo.png', position: 'top-right' },
})('Branded intro', async ({ page, overlays }) => {
await overlays.logo.show()
await page.goto('/dashboard')
await overlays.logo.hide()
})
For Studio-owned overlays (declared by name, with the web editor owning their
content), pass an array of names: video.overlays(['logo']). You can combine
this with the array form of narration, for example
video.narration(['intro']).overlays(['logo']).
To let Studio own the render options for a video, declare it through
use({ renderOptions: 'studio' }).
API reference: voices