Files
screenci__screenci/docs/video-script-basics.md
T
2026-06-29 16:17:56 +03:00

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

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.ts file
  • change test(...) to video.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:

  • duration to control how fast the zoom moves
  • easing to control motion feel
  • amount to control how tightly ScreenCI zooms in
  • padding to keep more space around the target area
  • centering to bias framing within the viewport
  • preZoomDelay and postZoomDelay to 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():

  • duration
  • easing
  • amount
  • padding
  • preZoomDelay
  • postZoomDelay
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