scroll-reel
Deterministic frame-stepped recordings — scroll reels, choreographed tours, scripted interactions, and social formats.
scroll-reel is the workhorse generator. By default it captures frame-stepped: it drives
a virtual clock, screenshots each frame, and pipes them to ffmpeg — so output is frame-accurate,
crisp (supersampled by deviceScaleFactor), parallelised across workers, and byte-identical
run-to-run.
{ name: "home", url: "https://your-site.com", generator: "scroll-reel" }Core sizing
| Option | Meaning |
|---|---|
width / height | Viewport size in CSS px. Default 1280 × 800. |
fps | Output frames per second. Default 30 (max 120). |
duration | Clip length in ms (when not using choreography/autoSections/routes). Default 6000. |
deviceScaleFactor | Render scale — higher captures crisper, then downscales into the video. Default 2 (max 4). |
easing | Scroll easing: linear, easeInOutCubic (default), easeInOutQuad, easeOutCubic, easeInOutSine, easeInOutExpo, easeOutQuint. |
crf | x264 quality, 0–51 (lower = better/larger). Default 18. |
fileName | Output filename. Defaults to <slug(asset name)>.mp4. |
Timing & navigation
| Option | Meaning |
|---|---|
startDelayMs | Dwell at the top before scrolling (ms). Default 500. |
endDwellMs | Dwell at the bottom after scrolling (ms). Default 800. |
waitUntil | Navigation wait: "load", "domcontentloaded", "networkidle" (default), or "commit". |
waitForSelector | Optional CSS selector to wait for (visible) before recording — e.g. a hero section. |
Capture mode
| Option | Meaning |
|---|---|
capture | "frames" (default) deterministic frame-stepping; "realtime" records live — a fallback for time-based hero animations or autoplay video. |
workers | Parallel render contexts for "frames" (default ≈ half the cores). |
frameFormat | Intermediate frame format for "frames": "jpeg" (default) or "png" (lossless). |
Choreography, auto-sections, variants, cards, annotations, aspect, and extra outputs are
frames-only; realtime ignores them (with a warning). Scripted actions and focus
record realtime by nature.
Motion & cinematography
options: {
choreography: [
{ to: "#hero", holdMs: 1200 },
{ to: "#features", holdMs: 1500 },
{ to: "100%", durationMs: 1000 },
],
kenBurns: { scaleTo: 1.06 }, // slow zoom over the clip
loop: "boomerang", // play forward then back → seamless loop
}choreography: [{ to, durationMs?, holdMs?, easing? }]— replaces the single sweep with an authored sequence.tois a0..1number, an"NN%"string, or a CSS selector to bring into view. Per step:durationMsdefault1200,holdMsdefault800. Clip length becomesstartDelayMs+ Σ(step travel + hold) +endDwellMs.autoSections: true | { minHeightFraction?, selector?, holdMs?, durationMs?, maxSections?, constantVelocity? }— auto-detect the page's sections and pan/hold through them within a fixeddurationMsbudget. Defaults:minHeightFraction0.5(max2),holdMs700,durationMs12000,maxSections8,constantVelocitytrue. Ignored ifchoreographyis set.kenBurns: { scaleFrom?, scaleTo?, easing?, originX?, originY? }— slow zoom (folds automatically underloop: "boomerang"so it stays seamless). Defaults:scaleFrom1,scaleTo1.08,originX/originY0.5.loop: "none" | "boomerang"— boomerang plays forward then back for a seamless loop. Default"none".
autoSections is the hands-off alternative to choreography — it finds the page's sections and
paces a pan/hold through them inside a fixed time budget:
options: {
autoSections: {
durationMs: 12000, // total budget — the whole pass fits inside it
holdMs: 700, // dwell on each detected section
maxSections: 8, // cap how many it stops on
minHeightFraction: 0.5, // ignore sections shorter than half the viewport
},
}Clean capture
Suppress real-site noise so frames are clean and deterministic:
- Page:
hideSelectors: [],injectCss,clickSelectors: [](best-effort consent dismissal),hideScrollbars(defaulttrue),pauseAnimations(defaultfalse),freezeClock(defaultfalse— pinDate.now/performance.now/Math.random). - Network:
blockTrackers(defaulttrue— aborts common analytics/ads/session-replay),blockHosts: [],blockResourceTypes: [](e.g.["media", "font"]). - Settling:
settlePerFrame(default on; off in--draft) waits for fonts + in-view images each frame, bounded bysettleMaxMs(default250).
options: {
// Hide chrome that shouldn't be in the shot
hideSelectors: ["#cookie-banner", ".intercom-launcher"],
clickSelectors: ["#onetrust-accept-btn-handler"], // best-effort consent dismissal
injectCss: ".promo-bar { display: none !important }",
// Determinism
freezeClock: true, // pin Date.now / performance.now / Math.random
pauseAnimations: true, // hold CSS animations/transitions on their first frame
// Network
blockTrackers: true, // default — aborts analytics/ads/session-replay
blockHosts: ["widget.example.com"],
blockResourceTypes: ["media"], // skip heavy autoplay video, etc.
}Variants — one config, many assets
options: {
colorScheme: "both", // → <name>-light and <name>-dark
viewports: [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844, deviceScaleFactor: 3 },
], // → <name>-desktop, <name>-mobile
}colorScheme: "light" | "dark" | "both"(withthemeClassto toggle a CSS-class theme).viewports: [{ name, width, height, deviceScaleFactor? }].
The viewport × colour-scheme matrix is emitted as separate assets (<name>-<suffix>).
Output formats & framing
aspect: "16:9" | "9:16" | "1:1" | { width, height }withfit: "cover" | "contain"(default"cover") andpadColor(default#0b0b0f, used by"contain") — reframe for social (e.g.9:16reels).outputs: ("mp4" | "gif" | "webp" | "poster")[](default["mp4"]) — each becomes its own asset;gifFpstunes the GIF/WebP frame rate (defaultmin(fps, 15), max50).intro/outro: { title?, subtitle?, background?, color?, durationMs?, fadeMs? }— a fade-in title card / end card. Defaults:background#0b0b0f,color#ffffff,durationMs1500,fadeMs400.annotations: [{ text?, ring?, spotlight?, atMs?, untilMs?, position? }]— timed captions, a highlight ring around a selector, or a spotlight that dims everything else. Defaults:atMs0,untilMsend of clip,position"top" | "bottom" | "center"(default"bottom").
options: {
aspect: "9:16",
outputs: ["mp4", "gif", "poster"],
intro: { title: "Acme", subtitle: "Botanik" },
annotations: [{ text: "Real-time data", ring: "#chart", atMs: 1000, untilMs: 3000 }],
}Scripted interaction & element focus
Records realtime (interactions and their animations are time-based).
options: {
cursor: { color: "#e91e63" },
actions: [
{ do: "click", selector: "#menu-button" },
{ do: "hover", selector: ".dropdown a:first-child" },
{ do: "type", selector: "input[type=search]", text: "shoes" },
],
}actions: [{ do, selector?, x?, y?, text?, to?, durationMs?, holdMs? }]wheredois"move" | "click" | "hover" | "type" | "scrollTo" | "wait"— a scripted tour with a syntheticcursor: { show?, size?, color? }. Per action:durationMsdefault700,holdMsdefault600. Cursor defaults:showtrue,size22,color#0b0b0f.focus: { selector, padding?, actions?, holdMs? }— capture a single component (optionally trigger it first), cropped to its box. Defaults:padding24,holdMs2000.
options: {
// Crop to one component, triggering its toggle before the hold
focus: {
selector: "#pricing-card",
padding: 32,
actions: [{ do: "click", selector: "#billing-annual" }],
holdMs: 2500,
},
}Setting actions or focus emits a single asset and records realtime: variants
(viewports / colorScheme: "both"), cards (intro / outro), annotations, aspect, and
extra outputs are skipped — actions logs a warning if you set any of these.
Multi-page tour
options: {
routes: [
"https://site.com",
{ url: "https://site.com/pricing", autoSections: true },
{ url: "https://site.com/contact", durationMs: 2000 },
],
}routes captures each page as a frame-stepped segment and concatenates them into one reel;
aspect and extra outputs apply to the final tour. Variants are not expanded —
viewports and colorScheme: "both" are skipped (a warning is logged), so a route tour emits
a single asset. Per-route objects accept choreography / autoSections / durationMs.
Every option also has hover docs in
pro-visu.config.ts— the authoring types are generated from the validation schema, so the editor always matches what the tool accepts.