Guide: make a site¶
← README · Why Scroll Studio · What's new · Examples · Guide: make a site · Styles · Scenes · Licence
Make your own¶
studio new harbour-hotel --example atelier-sfumato # or start blank: studio new harbour-hotel
# edit projects/harbour-hotel/site.yaml (or use studio ui), drop files in inputs/
studio storyboard harbour-hotel # every shot's real first frame, before any film renders
studio build harbour-hotel --draft
studio snapshot harbour-hotel --at top --at studio:0.5 # screenshots + contact sheet
studio build harbour-hotel && studio record harbour-hotel # final build + an MP4 scroll-through
A spec is a theme, a nav and a list of sections. Scenes are the scroll-driven visuals; blocks (hero, intro,
product, features, stats, timeline, strip, orbit, quote, faq, cta, gallery, logos, signup) are the content
between them.
Set layout: stack and every section becomes a full-screen panel that slides up over the one before it (see
Hushwell); surface: dark | light | accent recolours a single section:
name: Harbour Hotel
theme: { preset: dusk } # night, brass, paper, lab, cosmos, studio, dusk, ink, blueprint
sections:
- id: rooms
type: parallax
steps:
- { intro: true, title: "A room above <em>the water.</em>" }
- { title: Morning, image: { generate: "Hotel terrace above a harbour at sunrise, photoreal" } }
- { title: Evening, image: inputs/terrace-dusk.jpg }
- type: cta
title: Stay <em>a while.</em>
button: { label: Book, href: "https://example.com" }
studio schema prints every field. Inputs can be project files or https URLs; images can be generated locally
from a prompt (generate:). Builds are cached per stage, so editing copy never re-renders a film. A theme can
pin the display face's optical size (display_optical_size: 40): variable serifs like Fraunces grow more
expressive at huge sizes, and a pinned size keeps headlines calm.
A site can carry plain pages beside the scroll page, for the privacy policy, support or press: Markdown files set
in the site's theme, served at /<slug>/ and linked from the footer (nav: true adds them to the nav too). Raw HTML in a page's Markdown is shown as text rather than run, and links must be http(s), mailto:, tel:, a relative path or a #anchor.
pages:
- { slug: privacy, title: Privacy, source: pages/privacy.md }
- { slug: support, title: Support, source: pages/support.md, nav: true }
A product block's phone can play a screen recording instead of showing a screenshot: device: { video: inputs/clips/write.mp4, image: inputs/write.jpg } (muted, looping; the image is the poster). pose: picks the composition so a run of product panels doesn't repeat one: beside (default), rise (centred under centred copy, cut off by the panel's bottom edge), close (larger, cut off below), lean (tilted out of the lower corner), pair (with a second phone, behind: { video: …, image: … }, the two swapping places on a loop), turn (turned in perspective towards the copy). stats: [{value, label}] shows big figures under the body instead of tags. A hero takes a device: too, rising from the bottom edge under the copy. theme.display_em: bold sets <em> words as bold italics in the text's own colour instead of the accent colour. A features card takes an image: (a file or generate:): an illustration on top of the card, with the words under it, so a row of cards reads as a set of pictures instead of three empty boxes. scroll: { speed: 1.2 } paces the smooth scroll, anchor scrolls and scroll-driven reveals (1 is the default; 1.2 is a fifth faster).
Any section can sit on a full-bleed backdrop: (in the stack layout, the panel's own), with surface: picking the
text colour. A backdrop can be a file, a URL, a generate: prompt, or a gradient: rendered on the spot with no
model: colour stops, glows, stars and grain, enough for colour cards, dusk skies and night seas behind type.
- type: hero
surface: dark
backdrop:
gradient:
colors: ["#2A1B2E", "#8A4B5A", "#D98C7A"]
glows: [{ x: 0.5, y: 0.95, color: "#F2B48C", radius: 0.5, strength: 0.6 }]
stars: 120
title: Your words stay <em>on your phone.</em>
Placing words¶
Every scene's copy is placed and timed the same way, in every layout. A step says where it sits with at, when it is on
screen with show, and how it arrives and leaves with enter and exit. Leave them out and the layout decides, as it
always has (Margin uses all of them):
- id: desk
type: diorama
layout: overlay
places: [{ name: Notebook, kind: notebook }, { name: Laptop, kind: laptop }, { name: Coffee, kind: mug }]
steps:
- { title: "Your best ideas are <em>in the margins.</em>", intro: true } # the layout's own place
- title: Write <em>anywhere</em> on the page.
at: { grid: "8 / span 5", row: top } # a slot on the 12-column grid: columns 8 to 12, near the top
enter: wipe
- title: Pick up <em>where the pen stopped.</em>
at: bottom-left # a named spot
enter: type-on
exit: fade
- title: Nothing pings. <em>Nothing nags.</em>
at: { x: 0.56, y: 0.58, w: 0.36, align: right } # a box on the stage, 0-1 of its width and height
show: [0.62, 0.9] # on screen from 62% to 90% of the way through the scene
enter: scale
atis a named spot (left,right,center,top,bottom,top-left,top-right,bottom-left,bottom-right;autolets the layout decide), a box ({x, y, w, h, align}: its top-left corner, width and optional height, in 0-1 of the stage), or a grid slot ({grid, row, align}: columns as CSS writes them,"9 / span 4"or"1 / 7", on the theme's 12 columns between the gutters;row: top | middle | bottom). Overlay scenes take all three; incardsthe card goes there; insplitthe copy keeps its column, soattakes a spot (its side and height). On a phone a box or a grid slot reads where the layout puts every caption. In a photoparallax, a step without animageof its own is another caption over the photo before it, so several captions can take their own places over one picture.show: [from, to]is the window of the scene's progress the step is on screen for (overlay scenes). Scenes that time their own copy (inside,light,diorama,codesteps withstate.file,filmsteps withstate.beat) fill it in for steps that leave it out. Insplitandcardsa step shows while it scrolls past, for itslength.enterandexit:fade,rise,wipe,type-on,scaleornone. Over a stage they play as the window opens and closes; on a card they follow its place on the screen as it scrolls in and out. With reduced motion only the fade is kept.
Every layout shows the whole caption: kicker, title, body, fact, button and hint (an intro step's "scroll
to begin" line), with surface for its colours over a lighter or darker picture.
Any block as a step. A step is a caption unless it says block:. With block: stats, quote, features,
timeline, cta, gallery, product or faq it shows that block, written with the block's own fields beside
block:, and is placed with at, timed with show and moved with enter and exit like any other step:
steps:
- { title: "Your best ideas are <em>in the margins.</em>", intro: true }
- block: stats
items:
- { value: "0", label: "notifications, ever" }
- { value: "100", unit: "%", label: "of it works offline" }
at: { grid: "2 / span 7", row: bottom }
enter: rise
- block: quote
text: "I stopped losing the <em>half-thoughts.</em>"
who: A reader in the beta
at: { x: 0.07, y: 0.16, w: 0.38 }
show: [0.7, 0.9]
In split and cards the block is the card. A style that draws a block its own way draws it that way in a scene too,
for the types its markup works for there (scene_blocks in the style's style.yaml); the others are drawn with the
default markup in the style's colours and fonts.
Fields that only make sense for a section of its own (nav_label, backdrop, transition) are refused on a step.
Specs from before 1.5 keep building the same page: place: top-right is at: top-right, at: [0.3, 0.46] is
show: [0.3, 0.46], layout and side are named arrangements of the stage and the copy column, and split and cards
lengths are each step's share of the scroll. Where a scene frames its subject beside the copy (an inside stop's part,
a diorama place), the subject takes the side the copy's at leaves free, unless the scene's side (or the place's)
says otherwise.
Landing page pieces¶
The small pieces most current landing pages are made of come with the engine, drawn in the theme's tokens (and a surface's), so they follow a preset, a style or a brand like everything else. Larkspur uses all of them and builds on a laptop with no GPU.
sections:
- type: hero
align: left
frame: card # the hero as a rounded card on a border in the theme's accents (wide screens)
badge: { tag: New, text: Field notes are here, href: "#news" } # a pill above the headline; or just words
title: Notes that <em>write themselves.</em>
body: One sentence that says what it is and who it is for.
buttons:
- { label: Start free, href: "#join", icon: arrow } # the primary, with an arrow
- { label: Watch the film, href: "#film", icon: play, look: ghost } # a ghost secondary with a play icon
rating: { score: 4.9, text: from 860 reviews } # stars filled to the score, and its source
signup: { action: "https://forms.example.com/notes", placeholder: you@company.com, button: Join the beta }
stats: # a row of figures parted by thin rules
- { value: 93%, label: of notes kept }
- { value: "12", label: languages }
logos: # a strip of partner logos in one colour
title: Used by teams at
items:
- { name: Northwind, image: inputs/logos/northwind.svg }
- { name: Halden } # no image: the name as a wordmark
- type: stats
look: divided # the same quiet row of figures as a section of its own
items: [{ value: 3.1x, label: faster drafts }, { value: "40", label: languages }]
- type: logos
title: Partners
items: [{ name: Quanta, image: inputs/logos/quanta.svg }, { name: Orbitly }]
- type: signup
title: Hear about the <em>next release.</em>
action: mailto:hello@example.com
button: Keep me posted
note: One letter a month.
- Buttons take
icon: arrow | play | external | noneandlook: solid | ghost, wherever a button is drawn by the default markup (hero, product, intro, cta, a caption's button, the nav's). Left out, a button is the one the engine has always drawn.externalopens a new tab and says so to screen readers. badgeis words, or{text, href, tag, icon}: a link with an arrow, a filled tag in front ("New") or adot. A scene's step takes one too, above its headline, and the layout model places it with the step (at,show,enter,exit).rating:score,out_of(5),text, an optionalhref, andstars: falsefor the score alone.signupis a plain HTML form: it posts to theactionyou name (a newsletter's or a form service's URL;method, the fieldnameandhiddenfields are there for what the service expects) or, withmailto:, opens an email. There is no server; the browser checks the address. The field has a real label (label) for screen readers.logos: SVGs or transparent PNG/WebP files are shown as silhouettes in the theme's text colour (tint: originalkeeps their colours); a picture without transparency is shown in greys; a logo with no image is set as a wordmark.frame: cardworks on a hero and on any scene (overlay and cards frame their stage, split its picture). On a phone the section stays full-bleed.
A hero that uses any of these is set in the kit's type scale and spacing (a calmer headline, a measured lede, the pieces
in a steady rhythm); a hero that uses none looks as it always has. Every style renders the pieces: a style with its own
hero markup places them with base.hero_kit(b, e) in its own layout, and keeps its own badge and buttons.
Themes and brands¶
Everything visual is a token, and a theme is a stack of token sets, each laid over the one before:
- the engine's defaults
- the
preset(engine/themes.yaml) - the
style(engine/styles/<name>/style.yaml) - the brand,
theme.from - what you set on
theme:itself (colors,fonts,radius,mode, and the tokens below), which always wins
A brand comes from a web page, a saved page, a logo or a brand.yaml in the project. Its colours, fonts (Google Fonts
families), button radius and logo are read once and kept in .build/brand/; the logo becomes the nav logo when
nav.logo is not set. Missing colours are derived from the ones found, with the presets' contrast rules.
theme:
style: bauhaus
from: https://example.com/ # or brand/logo.svg, brand/home.html, brand/brand.yaml
studio brand my-site --from https://example.com/ reads the brand afresh and writes its tokens into site.yaml, where
they can be edited; the site then builds without reading the page again:
theme:
style: bauhaus
from:
mode: light
colors: { bg: "#fbf7ef", bg2: "#f1ede4", ink: "#1d2a24", accent: "#0b6e4f", accent2: "#f2a541", accent_ink: "#ffffff" }
fonts: { display: Fraunces, body: Inter }
radius: 10px
logo: brand/logo.svg
source: https://example.com/
A brand on a style. The style keeps its experience (its scroll mechanic, layout, transitions and pointer) and
wears the brand: the brand's palette and faces replace the style's own, in its stylesheet, its templates and what its runtime draws (a poster's primaries, a sign's tubes). A style can
require tokens in its style.yaml: most keep the light or dark room their drawing is made for, so a light brand on
neon-glow is rebuilt dark around its accents, and a few keep the face their mechanic is built on (pixel-art's pixel
fonts, neon-glow's tube lettering). Under a style the brand's accents keep the contrast the style's own accents have
with what they meet (the background, the words set on them), because styles draw words and shapes in them directly. theme.colors and theme.fonts on a style mean what they always have: they
change the theme's tokens, which the style reads where it uses them; from: is what reskins the style itself.
Fonts of your own. A font role can be a file in the project instead of a Google Fonts family; it is served from the site like the Google faces are:
theme:
fonts:
display: { file: fonts/Brand-Display.woff2 }
body: { file: fonts/Brand-Text.woff2, weight: 400 }
mono: IBM Plex Mono
Spacing, type, shade and motion. Each defaults to what the stylesheet has always used, so a theme that sets none looks as it did. Presets and styles may set them too.
theme:
space: { gutter: 24px, max: 1280px, caption: 560px } # defaults: clamp(20px, 4vw, 64px), 1600px, min(680px, 82vw)
type: { scale: 1.1, body: 18px } # headline sizes x1.1; running text (17px)
shade: { strength: 0.6 } # the shade over scene pictures (1)
motion: { ease: "cubic-bezier(.3, .7, .2, 1)", enter: 0.3, follow: 0.08 } # how blocks arrive (0.2), smooth scroll (0.1)
grain: 0.2 # film grain over the page (0, none)
phone: 820 # one phone width for the stylesheet and the runtime
Remake a landing page¶
studio remake reads a live landing page and writes a new project that tells it as a scroll site. The page is opened
in headless Chromium (Playwright; a plain request when Chromium is not installed), and the parts that are page furniture
are left out: the nav, the page footer, cookie banners, popups, hidden and screen-reader-only text, tracking pixels.
What remains is cut at the page's <section>s, or at its section headings when it has none, and each part becomes the
block its shape calls for:
| On the page | In site.yaml |
|---|---|
| The h1 with its eyebrow, subhead, buttons and largest picture | The opening scene: that picture as a photo parallax with the headline over it |
Questions (<details>, a <dl>, headings ending in ?) |
faq |
A <blockquote> or a testimonial |
quote, with who said it |
| Figures with labels (12k+ teams, 99.9% uptime) | stats |
| Numbered steps (01, 02, Step 3) | timeline |
| Cards or a list under a heading | features (plus an intro when the heading has copy of its own) |
| Three or more photos | strip |
| A heading with copy and one picture | product, its picture beside the copy, short list items as tags |
| A short heading with a button | cta |
| A heading with copy | intro |
The page's own words are kept, emphasis included, and every button keeps its link: links to a part of the page point
at that section here, the rest at the original site. The page's description becomes a line of kinetic type after the
opening when it says something the hero does not, and the site ends on a call to action with the hero's button. A
row of client logos is left out. The pictures (photos only, at most 12, scaled to 2560 px) are saved in inputs/, and
the brand is read with theme.from and written into site.yaml as tokens you can edit. --style puts any of the
styles on the brand. With a GPU here the opening can be a film whose one shot starts from the hero picture; --no-gpu
keeps it a photo parallax, which needs only the CPU. Every section builds with storyboard stand-ins first:
studio storyboard my-remake --level sketch.
Only public pages are read. The site's robots.txt is asked first, for the page and for every host a picture comes
from, under the user agent ScrollStudio-remake; a page it disallows is refused. Nothing is uploaded anywhere. The
words, pictures and brand belong to the site's owner: a remake is for your own use, or for use with their permission,
and the command says so when it finishes. Claude can do the same in conversation with the MCP tool remake_site.
Direction boards: pitch it before you build it¶
A studio does not start with the build. It shows the client three or six different ideas of what the site could be, side
by side, and the client points at one. studio directions does that from the site's own spec:
studio directions my-site # six directions: board.html + board.png in my-site/directions/
studio directions my-site --n 4 --seed 7 # another seed, another set
studio directions my-site --brief "calmer, more editorial, for people who travel slowly"
studio directions my-site --serve # the boards in the browser, a Pick button on each
studio directions my-site --pick c # write direction C into site.yaml (a minimal diff; backup kept)
studio directions my-site --undo # put the file back as it was before the last pick
studio directions my-site --reroll d # another idea in place of one direction
studio storyboard my-site # now every image redraws in the picked direction's imagery
Each direction is a complete art direction, not a colourway: a concept line; a palette; a type pairing
(Google Fonts the engine loads and self-hosts); one of the engine's styles (the experience: what scrolling does, how
sections hand over; or the default editorial layout); an imagery treatment, which is how every generated image and film
prompt is phrased (medium, light, lens, mood, colour in words) so the whole page looks like one shoot; motion notes
(page, camera, type) and the pace; and the voice that suits it. Its board shows the hero and two or three key sections
as storyboard stills (Z-Image Turbo, the same generator and seeds in every direction, so what differs is the direction),
set in that direction's palette and type, with the palette, faces, style and imagery written out under them. The page is
directions/board.html (open it, or --serve it like the storyboard editor), and directions/board.png is the same page
as one contact sheet.
They differ in kind. The set is built from schools of design (a naturalist's plate book, the Swiss grid, golden-hour film photography, neon noir, drafting-table blueprint, a risograph zine, a picture book, soft clay, silver noir, art deco, cut paper, a travel poster, a Dutch still life, Y2K chrome, an arcade, a terminal, a quiet product studio, reportage, wabi-sabi, an old atlas, cel animation, Memphis pop), picked greedily so each next one fits the subject and is far from those already chosen. A distance measure (kind of picture, experience, colour, type, what the pictures are made of) then checks every pair; a pair closer than 0.5 is one idea twice, and the weaker of the two is replaced. The board header shows the closest pair, how many kinds and experiences the set spans, and what was regenerated. Directions are listed from closest to today's site to a different site.
There is no text model, so the writer is deterministic. The engine has no local language model (ComfyUI's models/LLM
holds Florence-2, a captioner, and Gemma is only LTX's text encoder), so ideas are not generated: they are composed from a
curated, combinable vocabulary (engine/directions/vocab.py: schools, palettes, type pairings, imagery treatments,
photographers' lighting such as Rembrandt, Vermeer window light or Penn's north light, and which engine styles give each
school the right experience), chosen to fit the subject and mood words found in the site's copy and the brief, and combined
by a seed. The same brief and seed give the same boards. Add a school by adding data; tests check every palette's contrast,
that every font loads and every style exists. Nothing is sent anywhere and there are no API keys.
The optional directions: block in the spec is the brief and the constraints:
directions:
brief: "Quiet and handmade, never precious; a working studio, not a gallery."
count: 6
seed: 4
moods: [handmade, warm, calm] # also read from the brief: calm, warm, bold, playful, technical, raw, nostalgic ...
keep: [colors] # parts of today's look every direction keeps: style, colors, fonts, imagery
styles: { avoid: [pixel-art, ascii-art] } # or only: [none, halftone, sand]; none = the default layout
fonts: { avoid: [Press Start 2P] } # or only: [Fraunces, Karla ...]
palettes: { avoid: [pink], brand: ["#c2612a"] } # brand colours become every direction's accent
stills: 4 # stills per board: the hero, then key sections
A pick writes the direction into site.yaml and nothing else: theme (style, mode, colours, fonts, display weight and
emphasis, radius), imagery and scroll.speed. Only what differs from what the site already resolves to is written, comments
and layout stay, the old file is kept in directions/backups/, and an edit that would make the spec invalid is refused.
Copy is never touched (a direction has a voice note, but the writer cannot write copy, so there is nothing to apply).
imagery: is a spec field on its own, so you can write it by hand:
imagery:
medium: 35mm film photograph, Kodak Portra 400, fine grain
light: low golden-hour sun, long soft shadows
lens: 35mm, eye level, shallow depth of field
mood: unhurried, sun-warmed, quietly luxurious
palette: honey, terracotta and sea blue
It is added to every prompt the engine sends to the image and video models (a generate: image, a beat's frame and clip, a
take's keyframes and segments), and with restyle on (the default) the phrases it replaces in the original prompts
(photorealistic, shot on a medium format camera, golden hour) are dropped first. Unset, nothing is added and no cache
key changes. Example: Kiln & Kettle. MCP tools: directions, direction_reroll,
direction_pick, direction_undo.
Art direction: a medium¶
The strongest landing pages open on one illustrated picture in one clear medium, with a clean layout over it and calm
space where the headline sits. imagery.medium can name a medium instead of describing one, and then the engine draws
every generated picture on the page in it, in the brand's colours:
theme:
from: { colors: { bg: "#f6f1e7", ink: "#16203a", accent: "#2f4fd8", accent2: "#ff6c8f" } }
imagery:
medium: risograph # risograph, toile, engraving, gouache, watercolour, halftone, ink
sections:
- type: hero
align: left # the headline's side is kept calm
title: Cash flow, <em>with the wind behind it.</em>
image: { generate: "Paper planes and a big kite above a small city of rooftops", seed: 7 }
Each medium is a tested local recipe in two halves (engine/medium.py). The prompt sent to Z-Image Turbo opens with
the medium's words, its inks named in colour words ("two-colour riso print in cobalt blue and fluorescent pink"). Then
a deterministic post-process runs over the still on the CPU (a few seconds, cached), and that is what makes the page
read as one hand:
| Medium | Inks from the theme | What the post-process does |
|---|---|---|
risograph |
accent and accent2 | Separates the picture into the two inks (a flat tint stays one ink; inks meet only in a blend or a dark overprint), screens every tint with the drum's clumped grain, moves the second drum a few pixels off register, prints on the paper |
toile |
accent, darkened until line work shows | One ink on cream: the picture's tone becomes how much ink, unevenly inked in the plate's grain |
engraving |
the dark (text on a light page) | One ink, sharpened line work, higher contrast, on paper |
halftone |
the dark and the accent | A real AM dot screen: the tone in dark dots at 45 degrees, the colour in accent dots at 15, a period of about 1/210 of the width |
gouache |
dark, accent, accent2 and the paper | Mean-shift flattening into matte shapes, colours pulled toward the brand's, brush streaks and paper tooth |
watercolour |
dark, accent, accent2 | The picture's white becomes the paper, pigment pools at the edge of each wash and granulates, colours lean toward the brand's |
ink |
the dark and the accent | Black brush work where the ink is loaded, the washes in the accent |
The paper is the theme's background when it is pale, else a warm off-white. On a dark site, imagery.paper: bg with light inks ([ink, accent]) prints on the dark itself: the print becomes a negative, the light inks laid opaque where the picture is light, like a screen print on dark stock (halftone, engraving, toile, ink and risograph). A model asked for a print often draws the
sheet's margin too; it is cut away so the picture runs to the edges. Set the inks yourself with imagery.inks (#hex or
theme roles: accent, accent2, ink, bg, bg2, dark) and imagery.paper; imagery.grain (0-1) sets how much paper and
ink texture. The other imagery: parts (light, mood, palette in words) still apply.
Which pictures. The site's medium reaches every generate: image (heroes, backdrops, product shots, strips,
feature cards, galleries, parallax photos, a film beat's first frame; a film's clip is phrased in the medium's words but its frames
are not re-processed). A file you supply is left as it is, unless it asks: any image takes its own medium:, which
also works on a file (the post-process only), and medium: none leaves one image out.
backdrop: { generate: "A strip of riverside landscape along the bottom edge", medium: toile, calm: center }
Calm space for the headline. calm: left | right | center | top | bottom on an image keeps that side quiet. The
model is asked for it (the subject on the other side, one flat colour where the words go, the horizon low), and the
result is measured: the share of that side covered by edges at the size of a headline's letters, plus its tonal
spread, at 320 px wide so paper grain and dot screens count as tone. Above 0.06 the side is busy: a generated picture
is mirrored, then regenerated with the next seeds (seed + 1000, + 2000), and the calmest is kept; the build says what
it did, and when nothing passed. A file is only checked. A hero in a named medium gets calm: from its align (left,
or center), sets its words light or dark from what that side turned out to be, and trades the heavy shade for a light
scrim on the headline side only. A full-bleed picture with a calm side is printed at 2560 px wide, the width the page serves it at, so a phone's narrow crop shows the medium's own grain and dots rather than a small print blown up. On a phone, a hero's picture and a parallax photo with calm: left or right lean their narrow view toward the calm side, only as far as the words need to sit on calm space (measured at build); a parallax photo without a calm side is shown from its middle, as before. Product shots in a medium are shown as whole prints (no knockout, no feathered edge).
Pages and caches built before this are untouched: a site without a named medium sends the same prompts and builds the
same pictures. Example: Kitewell, a risograph fintech hero with a toile closing band,
built from the raw stills in its inputs/ so it needs no GPU.
Storyboard first¶
A film costs an hour or more to render (LTX-2.3 or Wan 2.2, SeedVR2, a Cycles product pass over every frame), so
look at it before you pay for it. studio storyboard shows every shot of the page at a fraction of the cost, through
the same stages and caches the full build uses:
studio storyboard my-site --level sketch # seconds, no ComfyUI: a take's grey world, cached stills, prompt cards
studio storyboard my-site # stills (default): the real first (and end) frame of every shot
studio storyboard my-site --level animatic # the stills in motion, timed cuts and captions -> animatic.mp4
studio storyboard my-site --level animatic --motion --only door # plus a tiny LTX preview of one shot's motion
studio build my-site --plan # what a build would render, and about how long it takes here
studio build my-site --only door,enter # render just those beats (or sections); the rest keep their cache
studio storyboard my-site --edit # edit every shot in the browser, regenerate one image at a time
It writes <project>/storyboard/ (not committed, like dist/):
site/: the real page, built with the storyboard's frames standing in for every film that isn't rendered yet (stills that dissolve into one another as you scroll; with a slow camera move atanimatic), with the real copy, layout and style, and a corner badge so it is never mistaken for the finished site.sheet.jpg(one image an agent can read) andstoryboard.html: a card per shot (a beat, a take, a product still, a block's image) with its frames, prompt, motion, caption, length, the fidelity of its frames (final, stills, sketch, placeholder), its status (new, changed since the last storyboard, unchanged, locked, rendered) and the full render estimate on this machine, calibrated from the seconds every stage records; the total is at the top.animatic.mp4atanimatic: a rough cut with the page's captions in its own fonts and the trailer's end card.
Nothing is thrown away. A beat's still is the very image the beat is animated from (same input, same cache); a
take's frames are its blockout and keyframes stages, painted over the depth of the blockout the film is
generated along. So an approved frame is the film's first frame and the full build only adds motion. What only the
storyboard makes (a take's sketch, low-sample product stills and the product over its keyframes, motion previews,
placeholder cards) is cached apart, keyed by its samples, and never read by a full build.
Edit one beat and storyboard again: only that shot is redone, in seconds, and the sheet marks it. Editing one of a take's keyframe prompts repaints only that keyframe. When a shot is right, lock it:
beats:
- id: door
prompt: ...
motion: ...
locked: true # never re-rendered by storyboard or build, even if its prompt or settings change
locked: true works on a beat, a take and any scene section; the build logs locked, kept the existing render for
each stage it keeps (a locked shot that was never rendered is rendered once). studio build prints its plan and
estimate before any heavy GPU work; with confirm_over = 30 in studio.toml it asks first when a build would run
longer than that many minutes (--yes skips the question; without a terminal it stops instead of waiting).
Edit it, one image at a time¶
studio storyboard my-site --edit [--port 8971] opens the storyboard as an editing workspace (a local server; the same
page is /sb/<project> in studio ui). Three tabs: Shots, Notes and Play. A card shows what you judge by:
the image or images with their variants, the caption, a What should change? box, Regenerate / Re-run / Revert and
Lock / Approve. The exact prompts, edit instructions, motion, seeds and strengths are behind Advanced on each card
(and the shared steps behind their own fold), closed until you open them; each browser remembers what it opened.
- Say what should change. Type a note in plain words on a card ("warmer light", "she should look older", "less
grain"), or on the Notes tab for the whole storyboard (pacing, tone, copy, order, anything). Notes are an inbox
in
<project>/storyboard/notes.json; the page only collects them and shows each one's status (waiting for Claude, Claude is on it, done), Claude's reply, and what it led to (the fields edited, the variants added). An agent does the interpreting: the MCP toolstoryboard_noteslists the open notes with each shot's current values and image paths (studio storyboard my-site --notesprints the same for an agent without MCP); it acts withset_shot_field,regenerate_shot_image,pick_shot_variantor by editingsite.yaml, looks at the result, and callsresolve_notewith a short reply. The page refreshes by itself when the spec, a variant or a note changes, wherever the change came from, and Not quite sends a note back with a follow-up. - Edit any field (under Advanced). Caption, prompt, motion, seconds, seed, trim, strength, a take's keyframe prompts and motion
segments, an edit's instruction, a product still's scene and view. Save writes the change into
site.yamlin place: only the edited text changes, so comments, column alignment, quoting and line endings stay as you wrote them (a change a text splice cannot make, such as switching a source, falls back to ruamel's round-trip dump with the file's own indentation). The spec is validated first: an invalid value is shown on the card and nothing is written. The previous text of every save is kept in<project>/storyboard/history/(the last 30; Undo last save restores it). - Regenerate, Re-run, Revert, per image. Regenerate renders one more variant of just that image with a new seed; Re-run renders it with the same seed after you edited its text; Revert goes back to the image that was in use before. Only that image's stage runs (a beat's start or end image, an edit, a take keyframe, a product still), through the code and cache digests the full build uses, as a background job with its progress on the card. Videos, restores and product passes are never started from here. Locked shots refuse, and one job runs per image at a time; jobs share ComfyUI with whatever else is queued on it.
- Variants. Every render is kept under
<project>/storyboard/variants/<shot>/<image>/and shown as a strip of thumbnails on the card. Click one to make it current: its seed (and text) are written intosite.yaml, and it is put back in the build's cache if that was cleaned, sostudio buildreproduces exactly that image. - Shared steps. A chain of edits (a generated base image, a relight edit, a crop edit per shot) is shown the readable way: each card holds only what is its own, with the full chain behind an expander, and the steps several shots share are listed once at the top, where one edit reaches every shot that uses them ("Re-run its shots" redraws them).
- Lock and Approve. Lock sets
locked: truein the spec. Approve is storyboard-only state in<project>/storyboard/state.json: a badge on the card, listed as "not approved" on the sheet and bystudio build --plan, and read as "changed since approval" once the shot is edited again. -
Safe beside other editors. If
site.yamlchanges on disk while the page is open (you, an agent, git), the page notices, reloads the values and says which; a save whose fields changed under it is refused instead of overwriting. -
Play the whole thing. The Play tab plays
animatic.mp4(every shot at its length, with the page's captions) with a timeline of shot markers: click one to jump to that shot; the shot on screen is named and its card is highlighted. Rebuild animatic cuts it again as a background job from the stills and clips that exist now (it renders nothing new: no ComfyUI, no Blender; an image not drawn yet is a placeholder card) and the player says when the cut is out of date and which shots changed since. The stand-in page (stills scrolling like the real one) and, oncestudio buildhas run, the real built page play in a frame with an auto-scroll that runs top to bottom (speed, pause, scrub; it waits out the page's loader).
Agents drive the same loop through the MCP tools storyboard_notes, resolve_note, storyboard_shots,
set_shot_field, regenerate_shot_image (returns the new image's path), pick_shot_variant and approve_shot. Update sheet in the page rebuilds sheet.jpg and the stand-in
page from the edited spec and what is rendered so far (a sketch-level storyboard: no ComfyUI, images not drawn yet show as
cards); studio storyboard my-site does the same at stills or animatic level.
Check a page before you ship it¶
studio inspect scores a built film; studio inspect my-site --page checks the built page. It serves dist/, opens it in
headless Chromium at 1440x900 and at a 390x844 phone (2x), with reduced motion on so nothing is mid-fade, and stops at each
section's key positions: a block at each screen of its height, a scene at each of its captions or steps. At every stop it
takes the screenshot a visitor sees, then the same page with every word hidden (and with each scrim hidden), and measures
the real pixels behind the words. Nothing needs a GPU or a network, only Playwright's Chromium (playwright install chromium).
studio build my-site
studio inspect my-site --page # a table, then the findings with their fixes
studio inspect my-site --page --section hero --json
| Check | What it measures | Flagged when |
|---|---|---|
| Text over a picture | the busy-ness (0-100: edge density and detail) of the picture behind each headline, caption and button, and the WCAG contrast of the words against those pixels, typical and on the worst tenth | contrast needs 4.5:1 (3:1 for text of 24 px, or 18.66 px bold): under 90% of that is a fail, just under it or under 80% of it on the worst tenth of the pixels is a warn; busy-ness 60 or more; the page reads only because of a scrim (under 3:1 without it) |
| Type | headline against body size, characters per line of body copy, headline lines on a phone | ratio under 1.6; lines over 80 characters, or under 35 in a paragraph of four lines or more; headlines over 5 lines (7 is a fail) |
| Pictures | the picture's own pixels against the pixels its box needs (<img> and CSS backgrounds), a face cut by the frame edge, words across a face |
blown up more than 1.3x (a fail past 2x); under 95% of a face inside the crop; text over a face |
| Layout | horizontal overflow, the first button of each hero against the fold, text over text | any overflow or overlap; a button below the fold (a fail) or cut by it (a warn) |
Each finding names the section, the size, the numbers and the fix in the spec's terms: move a caption to the calmer side
with place: right (the report measures the left and right thirds of the shot and says which is calmer), give a step
surface: dark, set the scene shade: strong, regenerate the picture with calm space where the words go, use a bigger
size: for a generate: image, shorten a headline or lower theme.type.scale. The report is
.build/inspect/page/report.json (every finding, the text over pictures with its numbers, every picture with its upscale)
next to one contact sheet per size, sheet-desktop.jpg and sheet-phone.jpg: each screenshot framed green, amber or red,
with a box on every finding. The command exits 1 when anything fails, so a showcase page can be gated on it:
studio inspect showcase --page && studio publish showcase --out site.
What it does not check: pictures a player draws itself (parallax photos, films, 3D scenes) have no size to measure, so only elements and CSS backgrounds are checked for blow-up and crop. The face check is OpenCV's bundled frontal-face detector: it finds upright faces looking at the camera and misses profiles and tilted heads. The words' own text shadow is hidden while measuring, so the contrast is the picture's, not the shadow's. The check is a second pair of eyes, not a replacement for looking at the sheet.
Launch trailers¶
studio trailer my-site cuts a launch film from what the site already rendered, in 16:9 and 9:16, with no extra
generation: dist/trailer/trailer-16x9.mp4, trailer-9x16.mp4 and a poster beside each (H.264, yuv420p, CRF 18,
faststart; ready for X, TikTok, Reels and Shorts). trailer: {} derives the cut: a hook shot, the middle of the
story, the product reveal, feature callouts and an end card, each captioned with the site's own headlines.
trailer:
bpm: 100 # every cut lands on the beat grid
music: inputs/track.mp3 # optional, local; trimmed, faded and loudness-normalised (-14 LUFS)
url: stillwater.example # end card: name, tagline, call to action, address, disclaimer
embed: true # a "Watch the launch film" button on the opening section
shots: # optional; leave out for the derived cut
- { section: take, step: 0 } # the footage under a step, captioned with it
- { beat: laugh } # a beat of a story film
- { card: "One take. <em>No cuts.</em>", beats: 4 } # type on the theme background
- { section: order, beats: 6 } # any other section, recorded scrolling
- { product: three-quarter, callouts: [750 ml, 24 h cold] } # a product still (needs site.product)
The 9:16 cut is reframed, never letterboxed: in a take the crop follows the product through the take's own camera
(take.py --pass track), cuts the reveal where the product is biggest in frame and pushes in on it as far as the
source resolution allows; other footage follows a saliency-guided crop; recorded sections are recorded at phone size.
Captions, callouts and the end card are set in the theme's own fonts and colours (HTML in headless Chrome, so <em>
reads as it does on the site), animate on the beat, and keep inside the TikTok/Reels safe areas. Every stage is
cached, so a re-run costs nothing until something changes; studio trailer my-site --plan prints the cut.
Client work¶
studio copy export my-site # my-site/copy.md: every visible line of text, ready to send
studio copy import my-site copy.md # apply the client's edits (prints a diff; --dry-run to preview)
studio brand my-site --logo inputs/logo.png --mode light # theme colours from the logo, contrast-checked
studio brand my-site --from https://example.com/ # a brand's colours, fonts, radius and logo into theme.from
The copy deck is plain Markdown: one ### key per field, the text under it. Imports keep site.yaml's comments and
layout and refuse anything that would make the spec invalid. A rebuild after an import re-renders only what the
text touches.
With Claude¶
Open the repo in Claude Code and ask for a site. The scroll-studio MCP server (.mcp.json) gives Claude tools
to create projects, edit specs, build, take snapshots and look at them, record videos and cut launch trailers; the scroll-site skill
gives it the workflow. Any MCP client can use python -m engine.mcp_server.
How it's built¶
engine/: Python. Spec (spec.py), scene builders (scenes/), compiler and themes (compile/), backends for ComfyUI, Blender and depth (backends/), CLI, server, MCP server, snapshot and record tools.runtime/: TypeScript. The scroll core (Lenis, sticky stages, step cards, HUDs) plus one lazily loaded player per scene type. Built intoengine/compile/static/runtimeand copied into every site.ui/: React. The Studio app.- Every model, with its size and licence, is listed in
engine/models.yaml.