Configuration
screenci.config.ts is where project defaults live. Keep it small at first,
then add settings when you have a concrete need such as a shared baseURL, a
different video directory, a rendering default, or a Playwright integration
option.
ScreenCI builds on Playwright’s config model, so most normal Playwright config still works here. For the Playwright side of the file, see Configuration.
screenci.config.ts lives inside the self-contained screenci/ directory that
init creates, and paths like recordingDir and envFile are resolved relative
to it. ScreenCI couples to your app only through a baseURL (and optional
storageState). It does not need to live in, or share dependencies with, the
app it records, which is what keeps it isolated in a monorepo.
Playwright options in screenci.config.ts (like baseURL) merge with per-file
video.use() overrides. Record and render options are not set in the config:
they are declared per video with video.recordOptions() and
video.renderOptions(), starting from the system defaults. Any value you omit
falls back to those defaults.
Common full config
This example focuses on the ScreenCI-specific options you are most likely to add first. Most normal Playwright config still works in the same file.
import { defineConfig } from 'screenci'
export default defineConfig({ // Used to identify the project in ScreenCI. projectName: 'my-product', // Load SCREENCI_SECRET and related env vars from this file. envFile: '.env', // Look for *.screenci.ts files here. recordingDir: './recordings',
test: { // Keep local test runs paced like real recording runs. mockRecord: false, },
record: { // Upload successful recordings even if some files fail. upload: 'passed-only', },
use: { // Shared base URL for page.goto('/path') style navigation. baseURL: 'https://staging.screenci.com', },
// ScreenCI currently records with Chromium, so start with a Chromium project. projects: [{ name: 'chromium' }],})Use this as a menu, not a template you must fill out. Most projects only need
projectName plus one or two shared defaults. Record and render options are not
set here: declare them per video with video.recordOptions(...) and
video.renderOptions(...) (see Capture defaults and
Rendering defaults).
Config areas
Project identity
projectNameidentifies the project in ScreenCI.envFilepoints to the file that holdsSCREENCI_SECRETand other local runtime variables your ScreenCI workflow needs.- If
envFileis configured, ScreenCI loads it automatically. - If
envFileis omitted, ScreenCI falls back to the project.env. envFileis resolved by evaluating the config the same way Playwright does, so it can be dynamic (e.g. a ternary that picks.env.localwhenSCREENCI_ENVIRONMENT === 'local'and.envotherwise).
For example, keep SCREENCI_SECRET there. Your ElevenLabs key is not stored in
your env file: add it on the Secrets page in the app instead (see
Narration).
Example: .env file
A typical local env file looks like this:
SCREENCI_SECRET=sc_live_your_project_secretYOUR_PRIVATE_SECRET=your_own_app_secretCommon cases:
SCREENCI_SECRETauthenticatesscreenci preview,screenci export,screenci info, and public visibility commands.- Any other variables (for example
YOUR_PRIVATE_SECRET) are yours to use inside your own app or test setup. ScreenCI reads them from the env file intoprocess.envlike any normal environment variable, but never transmits them.
What ScreenCI sends to the service
Only SCREENCI_SECRET is ever sent to the ScreenCI service, as the
X-ScreenCI-Secret header on upload and command calls, to authenticate your
project. Your ElevenLabs key is never sent from your machine: it is stored
encrypted in the app and used server-side at render time.
No other environment variable is forwarded. Your app secrets, database URLs, and any other entries in the env file stay on your machine. ScreenCI does not store raw API keys from your env file.
The uploaded recording.mp4 is a screen capture, so secrets that are visible
on the page would be uploaded with it. To keep on-screen secrets out of the
recording, mask them with
redact, which
hides the content in the browser before the frame is captured. List
always-secret elements under recordOptions.redact to mask them from the first
frame.
Keep adding local runtime secrets here as needed. screenci.config.ts only
points to the env file. The actual secret values belong in .env or whatever
file you set via envFile.
File locations
recordingDircontrols where ScreenCI discovers*.screenci.tsfiles.- ScreenCI also maps Playwright
testDirto this directory automatically.
Recording behavior
test.mockRecord: truemakesscreenci testkeep recording-like pacing by default. This is the config equivalent ofscreenci test --mock-record.record.upload: 'passed-only'uploads successful recordings even if another one failed.record.upload: 'all-or-nothing'skips uploads when any recording fails.enableCaptureAudio: truelaunches the browser in audio mode for the whole run so videos can capture system audio (Linux only). Pair it with the per-videorecordOptions.captureAudioswitch; see Screen audio.
Capture defaults
Declare recordOptions per video with video.recordOptions(...):
aspectRatioqualityfpsperformance(see below)encoder(see below)captureAudio(trueor{ gain }): capture system audio for this video; requires theenableCaptureAudioconfig switch. See Screen audio.redact: CSS selectors masked from the first frame so on-screen secrets never enter the recording. See redacting sensitive content.
import { video } from 'screenci'
video.recordOptions({ // Capture landscape video. aspectRatio: '16:9', // Record at 1080p unless a file opts into something else. quality: '1080p', // Use 60 fps for smoother cursor and animation capture. fps: 60,})('My video', async ({ page }) => { await page.goto('/')})These values determine the recording viewport, so they are the supported way to control recording size.
Recording encoder
recordOptions.encoder selects how the screen capture is encoded:
'fast'(default) is the lightest possible encode. It never falls behind the capture stream, so it is the safe baseline on any runner. (When the encoder falls behind it drops frames and shortens the recording.)'sharp'is tuned for text-heavy UI, so labels, code, and small type stay crisp. It uses a little more CPU; on most machines it still encodes above realtime.
The init-scaffolded config opts into 'sharp' locally and keeps 'fast' in
CI, which is the recommended setup:
video.recordOptions({ // Lightest encode on constrained CI runners; full quality locally. encoder: process.env.CI ? 'fast' : 'sharp', // default: 'fast'})('My video', async ({ page }) => { /* ... */})Recording performance
recordOptions.performance controls how many output frames screenci skips
between cursor and scroll dispatches while recording. On a busy page or a slow CI
runner, each dispatch queues behind the page’s own work, so dispatching on every
frame can stall an interaction. The cursor is re-drawn at render time from a
single move event, so skipping cursor frames does not make it choppy; scroll is
real footage, so skipping scroll frames does.
Pass an object of frame-skip counts to tune each stream independently
(0 = every frame). Intervals are derived from the recording fps:
video.recordOptions({ // Defaults: dispatch the cursor sparingly (render-time), scroll every frame. performance: { mouseFrameSkip: 5, scrollFrameSkip: 0 },})('My video', async ({ page }) => { /* ... */})By default the cursor skips 5 frames (~10fps at 60fps), since it is re-drawn at render time; the scroll skips none (every frame), since it is real footage. Lower the cursor’s skip only if a flow depends on hovering elements along the cursor’s path.
Scroll dispatches are paced on an absolute timeline: the browser round trip for each dispatch overlaps the frame budget instead of adding to it, so latency does not lower the effective scroll frame rate. When a round trip exceeds the budget (a heavy page or slow runner), screenci drops frames and keeps the scroll on schedule rather than stretching it. Scroll progress is computed inside the page at the moment each position is applied, and an in-page animation-frame loop supplies per-paint smoothness whenever the browser allows it. While that loop is running, dispatches back off to a sparse watchdog so the animation costs almost nothing on a low-resource machine; if the browser freezes the loop, dispatches take over at full frame rate so the scroll still finishes on schedule.
Rendering defaults
Declare renderOptions per video with video.renderOptions(...) when you want
consistent output styling. It also accepts a per-language form,
video.renderOptions({ default: {...}, fi: {...} }), so one language can differ
from the rest:
output.backgroundrecording.size,recording.roundness,recording.dropShadowrecording.clip(crop the recording at render time, see below)narration.corner,narration.padding,narration.size,narration.roundness(0 = square, 1 = circle; defaults to 0.2)mouse.size,mouse.style('white'or'black'cursor)mouse.image(custom cursor image, see below)mouse.motionBlurandzoom.motionBlur(motion blur strength, see below)
Cropping the recording
recording.clip shows only a region of the recorded screen in the final video,
following Playwright’s clip shape. The recording is always captured at the
full configured resolution and the crop is applied at render time, so you can
change or remove the clip and re-render without re-recording. Coordinates are
CSS pixels of the recording viewport (top-left origin):
video.renderOptions({ recording: { clip: { x: 200, y: 120, width: 960, height: 600 }, },})('My video', async ({ page }) => { /* ... */})The recording tile takes the clip’s aspect ratio, and cursor movement and zoom follow the clipped region. The clip is also editable visually on the Editor page (a crop selection on top of the video preview).
Custom mouse
By default the cursor is the built-in arrow, coloured by mouse.style
('white' or 'black'). To use your own cursor graphic instead, point
mouse.image at a local image, relative to your config directory:
video.renderOptions({ mouse: { image: './assets/my-cursor.png', size: 0.05 },})('My video', async ({ page }) => { /* ... */})The image is uploaded alongside the recording, and drawn in the video
output. It replaces the built-in cursor entirely, so mouse.style
is ignored when image is set. A few things to know:
- Use a PNG.
- The image’s top-left corner is the pointer hotspot, matching the built-in cursors.
mouse.sizescales it (as a fraction of the output height, aspect ratio preserved), andmouse.motionBlurand the click animation still apply.
Motion blur
ScreenCI adds screen.studio-style motion blur so fast cursor moves and camera pans/zooms smear naturally instead of jumping frame to frame.
mouse.motionBlurblurs the cursor along its path.zoom.motionBlurblurs the camera viewport during pans and zooms.
Both take a value from 0 to 1 and default to 0.5. The value is the shutter
open time as a fraction of one output frame interval: 0 disables the effect,
1 is a full-frame shutter (maximum smear). The blur is adaptive, so slow or
static frames cost nothing. The two settings are independent: you can blur the
camera without blurring the cursor, or the reverse.
video.renderOptions({ mouse: { motionBlur: 0.5 }, zoom: { motionBlur: 0.5 },})('My video', async ({ page }) => { /* ... */})Reuse the same render options across videos for branding and layout consistency, then override only the files that need a different look.
The web Editor is the source of truth for render and record options. Values
declared in code (per video with video.renderOptions(...) and
video.recordOptions(...)) are the starting point, and web edits override them.
Omit them entirely to start from the system defaults. See
Editor.
Example: shared use defaults
Use use for Playwright options that multiple videos share, such as a common
baseURL for navigation:
import { defineConfig } from 'screenci'
export default defineConfig({ projectName: 'my-product', envFile: '.env', use: { baseURL: 'https://staging.example.com', },})Record and render options are not set here. Declare them per video, and reuse the same object across files when you want a shared baseline:
import { video } from 'screenci'
video .recordOptions({ aspectRatio: '16:9', quality: '1080p', fps: 60 }) .renderOptions({ narration: { corner: 'bottom-left', size: 'medium', }, output: { background: { backgroundCss: 'linear-gradient(135deg, #0f172a 0%, #1d4ed8 100%)', }, }, })('My video', async ({ page }) => { await page.goto('/')})Playwright integration
ScreenCI passes through most normal Playwright config such as:
timeoutreporterworkersfullyParallelwebServerprojects
That means you can keep using familiar Playwright options like baseURL,
storageState, trace, launchOptions, webServer, and browser-specific
projects in the same file.
For the Playwright side of the config model, see Configuration.
webServer in CI
When your videos navigate to a locally-running app, use a static serve command
in CI instead of the dev server. The dev server’s dependencies live in the root
node_modules, which the generated CI workflow does not install by default.
webServer: { command: process.env.CI ? 'npm run preview' : 'npm run dev', cwd: '..', // path from screenci/ to the project root url: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173', reuseExistingServer: !process.env.CI, timeout: 120_000,},use: { baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',},You also need to add root-app install and build steps to .github/workflows/screenci.yaml.
See Recording your own app in the CI setup guide.
Per-file overrides
Use video.recordOptions() / video.renderOptions() when one file needs
different defaults. They return a chainable builder, so chain them into the test
registration (or into video.narration(...) and the other builder methods):
import { video } from 'screenci'
video .recordOptions({ // Switch this file to portrait output. aspectRatio: '9:16', // Capture at a higher resolution for this specific video. quality: '1440p', fps: 60, }) .renderOptions({ narration: { // Move narration away from UI that appears in the lower-right corner. corner: 'top-right', }, })('Portrait walkthrough', async ({ page }) => { await page.goto('/dashboard')})Reach for these builder methods when a single script has a different layout or
output format than the rest of the project. For plain Playwright options
(such as colorScheme) use video.use() the same way.
Default values
| Option | Default |
|---|---|
test.mockRecord |
false |
recordingDir |
'./recordings' |
record.upload |
'passed-only' |
recordOptions.aspectRatio |
'16:9' |
recordOptions.quality |
'1080p' |
recordOptions.fps |
60 |
recordOptions.encoder |
'fast' |
timeout |
1800000 |
actionTimeout |
30000 |
navigationTimeout |
30000 |
ScreenCI-managed behavior
ScreenCI still owns a small set of Playwright behavior:
| Setting | Value | Reason |
|---|---|---|
retries |
0 |
Retrying would overwrite the video |
testMatch |
**/*.screenci.* |
Scopes Playwright to video test files only |
testDir |
recordingDir |
ScreenCI discovers videos from this path |
It also rejects viewport in use or project use, because ScreenCI derives
viewport dimensions from recordOptions.
Everything else should stay problem-driven. Add config only when it helps a real workflow.