---
name: salamsoft/scroll-world-openart
source: https://app.decimal.ai/s/salamsoft-scroll-world-openart@1/SKILL.md
source_sha256: 238d1961f7c6
---

# scroll-world-openart

Produces a landing page where **scroll drives a camera**: it dives from outside a scene
into its interior, then flies out and into the next scene, continuously, with no visible
cuts. The visuals are AI-generated (OpenArt); the page just scrubs pre-rendered video
by scroll position. This is the same technique behind Apple's scroll-through product
pages — the camera genuinely moves, scroll only drives time.

**What you generate:** N scene stills → N "dive-in" camera clips → N-1 "connector" clips
that join consecutive scenes seamlessly → a portable scrub engine that plays the whole
chain as one flight.

**The one rule that makes or breaks it:** seams must be *frame-identical*. Read
[Step 5](#step-5--connectors-architecture-b-only) before generating any connector. Getting
this wrong is the single most common failure and produces a visible "pop" between scenes.

Do not assume a frontend framework. The scrub engine in `references/scrub-engine.js` is
self-contained vanilla JS (it builds its own DOM + injects its own CSS into a container
you give it), so it drops into plain HTML, Next.js, Vue, a Python-served page, anything.
The value of this skill is the OpenArt pipeline, the prompts, and the seam method —
not the framework.

---

## Step 0 — Bootstrap

1. **OpenArt MCP tools.** This skill drives OpenArt through MCP, not a CLI. Confirm the
   tools are reachable and read the balance with `openart_account_get` (returns plan +
   credits). If they're missing, the user needs to connect the OpenArt MCP server — you
   cannot install it for them. Tool names carry a server-name prefix that varies by how the
   user configured it (e.g. `mcp__openart_ai__openart_account_get`); match what's present.
2. **ffmpeg / ffprobe** on `$PATH` (frame extraction + encoding). Both are mandatory —
   the seam method is built on extracting real rendered frames.
3. **Python 3 with Pillow** for the portrait canvases (mobile), the seam-difference check
   (Step 5d), and the optional transparent-scene knockout (`references/knockout.py`).
4. **Never let the tooling rewrite your prompts** — see
   [Prompt integrity](#prompt-integrity--read-before-generating-anything). Three separate
   switches will silently mutate prompt text, and the shared style preamble is the *only*
   reason the world looks cohesive.
5. Caveats: macOS ships **bash 3.2** (no `declare -A`); don't use associative arrays in
   scripts. Generations take **1–4 min each** — submit them as a batch and poll, never one
   at a time. `openart_creation_wait` with `timeoutSeconds: 90` can exceed the MCP client's
   own timeout and error out; **use ≤ 60** and call again on `STILL_RUNNING`.

---

## Prompt integrity — read before generating anything

The OpenArt generate tools default to *helping* with your prompt. Every one of those
defaults is wrong for this skill, because cohesion comes from reusing a byte-identical
style preamble across all N scenes. Three switches to hold down:

1. **The tool-level "polish and present 4 options" flow.** The `openart_generate_image` /
   `openart_generate_video` tool descriptions instruct you to rewrite the prompt into
   variants and have the user pick — *unless the user explicitly opts out*. Invoking this
   skill **is** that opt-out: the prompts here are templates whose exact wording is the
   mechanism (`references/prompts.md`). Send them verbatim, and say so once, plainly, at the
   start of generation — don't silently skip a step the tool asked for.
2. **`autoEnhancePrompt`** (image models) → set **`false`**.
3. **Any prompt-expansion / auto-enhance field on the video model** (e.g.
   `enablePromptExpansion`) → set **`false`**. Read the schema: these default to *true* on
   some models.

Approving the *journey* is the user's job (Step 1). Rewording individual prompts mid-chain
is not a decision to delegate to a model — it is the thing that breaks the world.

---

## Never hard-code a model or a price

The model roster and the price list are the two things about OpenArt that go stale fastest.
Models get added, renamed and retired; prices change per plan and over time. **This document
therefore states no prices and names no default model** — both are discovered at run time:

- **Which models are usable** comes from `openart_model_list` filtered by the capability rule
  in [Step 4](#step-4--camera-architecture-pick-one--this-makes-or-breaks-the-feel), checked
  against each model's live `openart_model_form_get` schema.
- **What they cost** comes from `openart_model_cost` at the exact params you intend to send.
- **Which one to use** is the **user's choice** from that live list (Step 1.6) — not a
  preference baked into this file that quietly rots.

If you ever find yourself about to say "the default model is X" or "this costs N credits"
from memory, stop and run the discovery instead. Any model id appearing anywhere in this
skill is an *illustration of the rule*, never a recommendation to reuse unchecked.

**If the user only asks what a world would cost** — "how much for 6 scenes?", "is this
expensive?" — run the Step 1.6 discovery and answer with live numbers, then stop. Don't drag
them through the interview to get a price. Quote in **credits**; convert to currency only if
they've told you their plan's rate, since it varies by plan and you cannot infer it.

---

## Step 1 — Interview the user

The **subject is the user's to state — ask it as an open question in plain prose**, never a
fabricated multiple-choice. A made-up list of industries biases them and reads as you
deciding their business for them; let them answer in their own words (their real business,
a client's, or any idea). Reserve structured multiple-choice (`AskUserQuestion` in Claude
Code; a plain either/or question elsewhere) for the genuinely enumerable, lower-stakes
choices below — art direction and brand-kit approach — and even there, signal they can go
their own way ("Other"). Ask only what you can't sensibly default. Cover:

1. **Subject** (ask openly, not multiple-choice) — "What should this world be about? Your
   business, a client's, or any idea — a word or a sentence is fine." Capture the
   industry/product + a one-line pitch (e.g. "a bubble tea company, from leaf to last
   sip"), and a brand name if they have one; otherwise you'll propose one below.
2. **Brand kit** — offer three paths, pick one:
   - The user hands you palette + name + tone directly.
   - You propose a palette + name and let them approve.
   - Derive one from an existing site they name (read it, pull colours/tone, play it back).
   Capture **4–6 named hex values**, a display name, and a tone word or two.
3. **Art direction** — default is "soft matte low-poly **clay diorama**, isometric,
   tilt-shift miniature, warm light." Offer alternatives (flat papercraft, glossy toy,
   claymation, neon night). Whatever is chosen becomes the shared **style preamble**
   reused verbatim in every scene prompt (this is what makes the world cohesive).
4. **The journey (sections)** — the ordered scenes the camera flies through. Propose a
   set derived from the subject's own value chain and let the user edit. 5–7 works well.
   Boba example: farms → pearl kitchen → flagship shop → delivery → community plaza →
   the hero product. Each section needs: a short subject description (what's IN the
   diorama), an eyebrow, a headline, one line of body, and 0–3 tag pills. The last
   section is usually the hero product + the CTA.
   Scene count is the single biggest lever on the bill (each scene adds a still, a dive and
   a connector), so once you reach Step 1.6 and have real per-clip prices, state the
   **per-scene marginal cost** — it's cheaper to cut a scene then than after they've fallen
   in love with the list.
5. **Mobile version — ALWAYS ask this; never silently generate both.** Ask as a
   two-option choice: *"Want a mobile-optimized version too? The mobile version is a second
   camera chain rendered natively in **9:16 portrait** — composed for phones, not a crop of
   the landscape film — which roughly doubles the credit spend (state the estimated
   number)."*
   Options: "Desktop only" / "Desktop + mobile (native 9:16 — ~2× credits)". The
   credit cost must be stated to the user, not just implied.
   What the answer gates:
   - **Yes** → render the parallel 9:16 portrait chain and ship it as the mobile variants
     (Step 6 / pipeline.md §6b): portrait start canvases → 9:16 dives + connectors
     frame-locked against their own renders → 720-wide `-m.mp4` encodes → `stillMobile`
     portrait posters. Wire `clipMobile`/`connectorsMobile`/`stillMobile` (Step 7); run
     the full mobile QA (Step 8). Budget ~2N-1 extra video gens + re-rolls.
     **Never ship the centre-crop as the mobile version by default** — if credits can't
     cover the portrait chain, say so and offer the crop encodes (pipeline.md §6) as an
     explicitly-labelled stopgap the user must approve.
   - **No** → skip the mobile encodes and wiring entirely. The engine's phone hardening
     (seek-coalescing, iOS priming, safe-area CSS) is always on regardless — that's not
     a "mobile version," it's just the page not breaking when a phone visits — so a
     desktop-only build still degrades gracefully.

6. **Render model + budget — discover, present, let them choose, then get a go.** Do this
   as one step: the price *is* the main thing distinguishing the options, so choosing a
   model and approving a spend is a single decision. **Never pre-select a model for them,
   and never quote a price from memory.**

   a. **Discover what qualifies.** `openart_model_list` → take every model offering an
      `image2video` mode → `openart_model_form_get(model, "image2video")` on each → keep
      only those whose schema exposes **both `startFrame` and `endFrame`** (the Step 4
      capability rule). Models missing `endFrame` cannot make connectors and are out.
   b. **Price each survivor live.** `openart_model_cost` with the *exact* params you'd send
      — the resolution and duration you intend, audio off. Do this for one dive (longer
      duration) and one connector (shorter), since duration drives price.
   c. **Compute the totals** for the user's N: `N stills + (2N−1) videos`, doubled videos if
      they opted into mobile, plus ~15% re-roll headroom. Read the balance with
      `openart_account_get` and warn if the total exceeds ~70% of it.
   d. **Present the qualifying models as a choice** (`AskUserQuestion`), each row showing
      its live per-clip cost, its build total for this journey, and a one-line character
      note from its `openart_model_list` description (realism, motion stability, speed).
      Order them cheapest-first — the price spread between models can be several-fold, and
      the cheapest qualifying model is genuinely the right default for a previz pass.
      Also state the **per-scene marginal cost** so they can trade scenes against quality.
   e. **Suggest the previz path unprompted when the balance reads tight**: run the whole
      chain on the cheapest qualifying model, approve the journey, then re-render on the
      chosen one. Because every qualifying model frame-locks, the previz is genuinely
      seamless and translates directly.
   f. **Get an explicit go** before generating anything.

   Same for **stills**: pick the image model from `openart_model_list` (favour one described
   as strong at illustration/detail with accurate prompt adherence), price it, and mention
   the alternative if a materially cheaper one would do. **One stills model for all N** —
   mixing sources reads as style drift, the same reason the video chain uses one model.

If the user names a specific model, honour it **only if it passes the capability check in
(a)**. This skill only ships seamless output, so a model that can't frame-lock is declined
with a one-line why — never silently substituted, and never shipped non-seamless.

Keep the scroll mechanic fixed (continuous fly-through) — that's the point of the skill.
See `references/prompts.md` for the intake checklist and copy structure.

---

## Step 2 — Generate the scene stills

One image per section, **all sharing the same style preamble** for cohesion. Default
image model chosen at Step 1.6 — favour one described as strong at illustration and prompt
adherence, returning a solid background (perfect for floating diorama "islands").

Prompt shape (full templates in `references/prompts.md`):

```
<STYLE PREAMBLE, identical every time>. On a plain solid <bg> background with a soft
contact shadow. <PALETTE hexes>. No text, no letters, no logos, centered, 3:2.
Subject: <what is in THIS diorama>.
```

- **Submit all N in one message**, then poll. Params:

  ```json
  { "model": "<chosen stills model>", "mode": "text2image",
    "params": { "prompt": "<verbatim template>", "imageCount": 1,
                "aspectRatio": "3:2", "resolutionTier": "2k", "quality": "high",
                "lockAspectRatio": true, "outputFormat": "png",
                "autoEnhancePrompt": false } }
  ```
- Each call returns `{status:"PENDING", historyId}` immediately. Collect the historyIds,
  then `openart_creation_wait(historyId, timeoutSeconds: 45)` on each; on `STILL_RUNNING`
  call again with the same id. Observed ~90 s per still at 2k/high; output 2016×1344.
- The completed result carries `resources[0].url` — a **public CDN URL**. Download it with
  `curl` for the local pipeline, but keep the URL: it feeds **straight into `startFrame`
  with no upload** (Step 4).
- A generation may fail transiently — re-roll that one individually; don't restart the batch.
- **Review the stills before continuing.** They must read as one cohesive world (same
  angle, palette, light). If one is off-style, regenerate it, optionally passing an
  approved scene as an `image2image` reference to lock style.

See `references/pipeline.md` for the exact batch procedure.

---

## Step 3 — (Optional) Float the scenes

If you want the dioramas to float over an atmospheric background instead of sitting in a
solid box, knock out the flat background to transparency with
`references/knockout.py` (border-connected flood fill — preserves interior colour that
matches the bg, e.g. cream walls). Then encode to webp. If you'd rather keep it simple,
just make the page background the same colour as the scene background and skip this.

These stills double as **video posters and lazy-load fallbacks**, so keep them.

---

## Step 4 — Camera architecture (pick one — this makes or breaks the feel)

How the camera moves *between* scenes is the single biggest quality lever. Two shapes;
pick by aesthetic.

### Video model — pick ONE for the whole chain

**This skill only ships seamless output**, so the only usable models are ones that can
frame-lock a seam: every chained clip must accept `startFrame`, and connectors also need
`endFrame`. That capability — not preference, not reputation — is the selection rule.

**The qualifying set is discovered, never memorised** (Step 1.6a). There is deliberately no
roster in this file: models come and go, and a hard-coded list would quietly start
recommending something retired or skipping something new and better.

```
openart_model_list                                   → models with an image2video mode
openart_model_form_get(model, "image2video")         → its live param schema
  keep  if schema has BOTH startFrame and endFrame   → can hold a seam, usable
  drop  if startFrame only                           → can start a shot, not land one:
                                                        architecture-A legs only, no connectors
  drop  if reference/element-only inputs             → can only condition, not continue
```

### Read the params off the schema, don't assume them

The same schema tells you how to call the model. These four rules are what the per-model
quirks actually reduce to, and they hold for models that don't exist yet:

1. **Any audio field → turn it off.** It may be named `generateAudio`, `generateSound`, or
   something new, and it frequently **defaults to true**. You mute in HTML and `-an` on
   encode, so an audio track is pure wasted spend. Some models emit a silent-to-you AAC
   track with no field to disable it — hence `-an` is mandatory regardless.
2. **Any prompt-rewriting field → turn it off.** `enablePromptExpansion`,
   `autoEnhancePrompt`, or similar. These default to *true* on some models and will mutate
   the style preamble that holds the world together (see
   [Prompt integrity](#prompt-integrity--read-before-generating-anything)).
3. **No `aspectRatio` field → the output follows the start frame.** Several models omit it
   entirely. This is *why* the 9:16 mobile chain composites a real portrait canvas
   (pipeline.md §6b) instead of requesting an aspect. If the schema *does* offer an enum,
   set it explicitly.
4. **Resolution enums are nominal.** A model labelled `"720p"` fed a 3:2 start frame may
   return 1200×800. Always `ffprobe` the download and encode what it actually reports —
   never upscale to match the label.

Check `duration` bounds on the schema too — they vary, and the dive/connector lengths you
picked have to fit inside them.

Rules:
- **One model for all chained clips.** Each renderer has its own motion/color/grain
  character; mixing models mid-chain keeps *position* continuity (frames still hand off)
  but the render-character shift reads as a subtle pop. The one sanctioned exception is
  a content-filter fallback for a single stubborn clip (Gotchas) — a slight character shift
  on one 5s connector beats a missing connector.
- The model is the **user's choice** from the discovered qualifying set (Step 1.6), not a
  default baked in here. Honour a stated preference **only if it qualifies**; if it
  doesn't, say so plainly and let them pick again — never ship a non-seamless build to
  satisfy a model request.
- **Re-check the schema if a run spans days.** Params and defaults change under you; the
  discovery in Step 1.6 is cheap, so repeat it rather than trusting a stale call.

### A) Continuous forward take — RECOMMENDED for grounded / realistic / walkthrough
One camera that only ever glides **forward**, first scene through last, as a single take.
Generate the legs **sequentially**: leg 0 from scene-0's still (glide forward into it);
then each leg's `startFrame` = the **previous leg's ACTUAL last frame** (extract with
ffmpeg, upload per Step 5b), prompt *"continue gliding smoothly FORWARD into [scene i],
never pulling back"* (or an expressive mid-leg move under the motion-handoff contract —
see **Camera grammar** below), and **no `endFrame`** — an end frame of a wide establishing
shot forces the camera to pull back, which is the #1 cause of stutter. Extract each leg's
last frame to feed the next. Result: every seam is frame-identical **and** the camera never
reverses. There are **no connectors** (skip Step 5c, but you still need 5a/5b) — the legs
ARE the journey. Wire each leg as a section clip with `connectors: []` and a small
`crossfade` (~0.08). Even without an `endFrame` the legs still arrive at distinct rooms
(the prompt steers the content). Cost: strictly **sequential** (can't parallelize) and
slower; build in re-rolls (3 attempts/leg).

### B) Dive-in + aerial connector — only for diorama / miniature / god's-eye worlds
A "dive into each scene" clip + a connector that pulls **up and out** and flies over to the
next scene (Step 5). The pull-out **reverses camera direction at every seam** (forward dive
→ backward pull-out). In a miniature/diorama world that reads as an intentional "zoom out
to the map, fly to the next island"; in a grounded first-person walkthrough it reads as a
jarring **rewind/stutter**. Use B only for the map-like aesthetic. When in doubt, use A.

### Camera grammar — the move should fit the concept (A is NOT "forward only")

"Forward only" is the *seam* rule, not the *leg* rule. The physics of the chain:

- **Position continuity** at a seam comes from the frame handoff (next leg starts from the
  previous leg's actual last frame).
- **Velocity continuity** at a seam means the camera must never *reverse across a seam* —
  that's the rewind stutter.
- **Inside a single leg the camera is free.** One leg is one continuous render — there is
  no seam to break mid-leg, so orbits, crane-ups, lateral tracking, even a push-in that
  eases back out are all safe *within* the clip. Reversals are only fatal *across* seams.

So give each leg an expressive move chosen from the scene's own logic, under a **motion
handoff contract**: every leg **ends by settling into a slow, steady forward drift** toward
the next destination (final ~1 s), and every leg **begins by continuing that same drift**.
Keep both clauses in the prompts verbatim (templates in `references/prompts.md`).

Pick the grammar from the concept:

| Concept / tone | Mid-leg move |
|---|---|
| Product / luxury retail | slow half-orbit around the hero object, then continue past it |
| Real estate / hospitality | steadicam glide through doorways; gentle crane-up in atria |
| Industrial / process / logistics | low lateral track alongside the line, foreground parallax |
| Travel / outdoors / campus | drone-style rise-and-reveal, then a descending swoop |
| Food / craft / detail-driven | push in close to the craft moment, ease back, carry on |
| Playful miniature (arch. B) | dives + aerial hops — the connector IS the grammar |

Honest costs: expressive mid-leg moves raise re-roll odds — the model can end a fancy move
in a state that isn't a clean forward drift. Mitigations: keep the final-second settle
clause verbatim; **eyeball each leg's last frame before chaining the next** (it should look
like a frame from a gentle forward glide — if not, re-roll before wasting the next leg);
budget ~1 extra re-roll per expressive leg. A plain forward glide stays the zero-risk
default — use it for legs where the scene itself is the show.

Two related pacing knobs live in the engine (Step 7): per-section `scroll` (more scroll
distance = longer dwell in that scene) and `linger` (the camera settles mid-scene exactly
while the copy peaks, then picks up speed toward the seam). Prefer expressive motion in the
*clip* and restraint in the *scrub mapping* — they compound.

And remember scroll is a scrubber: visitors can scroll **up**, so every move also plays in
reverse. That's free and expected — no extra work — but it's another reason seam velocity
must be consistent in both directions (a seam that reads fine forward reads as a stutter
backward too if velocity flips).

**For B**, one camera flight per scene: starts high/outside, descends into the interior,
structure opens. Model: the chain model chosen at Step 1.6. `startFrame` = the scene
still's CDN URL (no upload needed — it's already hosted).

- Use the **solid-background still** (not the knocked-out transparent one) as the
  start frame, so the video has a full frame.
- Prompt: "Single continuous cinematic camera move, no cuts. Begin high and far looking
  at the whole <scene> from outside … descend and fly inside toward <focal point> … the
  roof/walls gently open to reveal the interior. <style>, smooth graceful slow motion.
  No text." (Template in `references/prompts.md`.)
- Params: take them off the model's schema per the four rules above — your chosen
  resolution, `duration` ~8 for a dive, audio field off, prompt-expansion field off.
- Submit all N in one message, then poll. Re-roll individual failures. Keep the raw
  downloads — you need their frames next.

---

## Step 5 — Connectors (architecture B only)

Skip the *generation* (5c) for architecture **A** — the forward take has no connectors; its
legs already chain seamlessly. But **A still needs 5a/5b**, because each leg's start frame
is the previous leg's last frame.

The connector clips are what make the world feel *connected* instead of cut. A connector
flies from the end of scene i out and into the start of scene i+1. **Both of its
endpoints must be the ACTUAL RENDERED FRAMES of the neighbouring clips — never the
original diorama still.**

Why: every generation renders slightly differently. If a connector *ends* on a fresh render
of "the kitchen diorama," but the next dive clip *starts* on its own different render of
that same diorama, the two won't match and you get a pop at the seam. The fix is to hand
off the exact pixels:

```
For each connector between dive_i and dive_{i+1}:
  startFrame = the LAST frame extracted from dive_i's rendered video
  endFrame   = the FIRST frame extracted from dive_{i+1}'s rendered video
```

Now every seam is frame-identical on *both* sides:
`dive_i.end == connector.start` and `connector.end == dive_{i+1}.start`.

### 5a. Extract the boundary frames

From the rendered dives (not the stills):

```bash
ffmpeg -sseof -0.15 -i dive_i.mp4   -frames:v 1 -q:v 2 last_i.png        # interior of i
ffmpeg -ss 0      -i dive_next.mp4  -frames:v 1 -q:v 2 first_next.png    # establishing of i+1
```

### 5b. Upload them — the step with no CLI analogue

`startFrame`/`endFrame` take a **hosted URL**, so local frames must be uploaded first.
(Assets OpenArt itself generated are already on the CDN — pass those URLs directly and skip
this. Uploading is only for files that exist locally: extracted frames and portrait
canvases.)

```bash
# 1) openart_upload_sign { mediaType:"image", contentType:"image/png",
#                          size:<exact bytes>, filename:"last_i.png", purpose:"create-video" }
#    -> { signURL, accessURL, visualReference:{type,id,url,label} }
# 2) PUT the bytes (a plain PUT works at typical frame sizes; no chunking needed):
curl -X PUT -H "Content-Type: image/png" -H "Content-Length: $(stat -f%z last_i.png)" \
     --data-binary @last_i.png "<signURL>"        # expect HTTP 200
```

`size` **must** equal the real byte count (`stat -f%z`). The returned `visualReference` is
already the exact `{type, id, url, label}` shape `startFrame`/`endFrame` want — pass it
through unchanged. The asset is readable from `accessURL` immediately after the 200.

### 5c. Generate the connector

`duration: 5` is plenty. Connectors need `endFrame`, so the model must accept it — any
roster model does:

```json
{ "model": "<chosen chain model>", "mode": "image2video",
  "params": { "prompt": "<connector template>",
              "startFrame": <visualReference from last_i.png>,
              "endFrame":   <visualReference from first_next.png>,
              "resolution": "1080p", "duration": 5,
              "negativePrompt": "", "enablePromptExpansion": false } }
```

Connector prompt: "Single continuous camera move, no cuts. Pull up and back out of
<scene i>, rise into the sky, glide across the connected miniature world, and arrive
above <scene i+1>, beginning to descend toward it. Seamless flowing aerial transition.
<style>. No text." (Template in `references/prompts.md`.)

Insurance: the model lands *close* to the end frame but not always pixel-perfect, so the
engine still applies a **short crossfade** (a few frames) at each seam. Frame-matched
endpoints + a small crossfade = no visible cut. Never skip the actual-frame handoff and
rely on the crossfade alone; a big content jump can't be hidden by a crossfade.

### 5d. Verify the seam numerically — cheap, and it catches the #1 failure

Don't eyeball this. Extract the connector's first/last frames and compare against the
neighbours' frames with a per-pixel RMS difference (script in `references/pipeline.md`):

```bash
ffmpeg -ss 0        -i conn_i.mp4 -frames:v 1 -q:v 2 conn_first.png
ffmpeg -sseof -0.15 -i conn_i.mp4 -frames:v 1 -q:v 2 conn_last.png
# compare last_i.png vs conn_first.png, and conn_last.png vs first_next.png
```

A correct handoff lands around **RMS 3–8**. Unrelated frames score **~90+**. Anything above
~30 means the handoff didn't take — you passed a still instead of a rendered frame, or
crossed the two frames over. (Measured on a live 2-scene build: seam A **3.76**, seam B
**6.98**, unrelated-frame control **93.80**.) Do this before encoding — re-rolling one
connector is far cheaper than discovering the pop at Step 8.

---

## Step 6 — Encode for smooth scrubbing

Scrubbing = setting `video.currentTime` from scroll. Two things matter, and they are
often gotten wrong:

1. **Seekability, not keyframe density, is what makes scrubbing work.** Many static
   hosts (and `python -m http.server`) don't serve HTTP byte-range requests, which pins
   `video.seekable` to `[0,0]` and clamps *every* seek to frame 0 — the video looks
   frozen. The robust fix is to **fetch each clip as a `Blob` and play it from an
   in-memory object URL** (blobs are always fully seekable). The engine does this.
   Because of it, you do **not** need all-intra video.
2. **Don't shrink quality to get smooth seeks.** Encode at the **native resolution**
   (whatever `ffprobe` reports — don't downscale, don't upscale), `crf ~20`, a **small GOP**
   (`-g 8`) rather than all-intra (all-intra bloats an 8s clip to ~25 MB; GOP 8 is ~8 MB and
   scrubs fine via blob). Strip audio, add faststart, and a light `unsharp` counters video
   softness:

```bash
ffmpeg -i src.mp4 -an -vf "unsharp=5:5:0.8:5:5:0.0" \
  -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
  -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart out.mp4
```

`-an` is not optional: some models return an AAC track even when their schema has no audio
parameter to turn off.

Encode all 2N-1 clips (dives + connectors) with the same settings for uniform quality.

**Mobile encodes (only if the user opted in at Step 1.5).** The mobile version is
the **native 9:16 portrait chain** (pipeline.md §6b): portrait renders of every dive and
connector, encoded **720 wide (`scale=720:-2`), `-g 4`** (more keyframes = cheaper seeks —
phone decoders' seek cost scales with GOP length), crf 23 — wired as `clipMobile` /
`connectorsMobile`, with each portrait dive's first frame extracted as the section's
`stillMobile` poster (Step 7). The engine serves them automatically on phones and falls
back to the desktop clip when absent. The 16:9 centre-crop `encm()` encodes
(pipeline.md §6) are a **fallback only** — for when credits can't cover the portrait
chain — and shipping them must be called out to the user, never silent. If the user chose
desktop-only, skip this — the engine still hardens phone scrubbing regardless
(seek-coalescing, iOS priming), so the page degrades gracefully rather than breaking.

---

## Step 7 — Assemble the page

Copy `references/scrub-engine.js` (and, if you want a fully standalone page, the tiny
`references/index-template.html`) into the user's project — or adapt into their
framework. It's config-driven and self-contained:

```js
mountScrollWorld(document.getElementById('world'), {
  brand: { name: 'Pearl & Co.' },
  diveScroll: 1.3, connScroll: 0.9,          // viewport-heights of scroll per clip
  sections: [
    { id:'farm', label:'The Farms', still:'assets/farm.webp',
      clip:'assets/vid/farm.mp4',
      clipMobile:'assets/vid/farm-m.mp4',      // mobile opt-in only: native 9:16 render
      stillMobile:'assets/farm-m.webp',        // its first frame as the portrait poster
      scroll: 1.6, linger: 0.45,   // optional pacing: longer dwell + camera settles mid-scene
      accent:'#8FB98A', eyebrow:'From leaf to last sip', title:'It starts in the hills.',
      body:'…', tags:['Single-origin','Hand-picked'] },
    // …one per section; last may carry a `cta`
  ],
  connectors:       ['assets/vid/conn1.mp4','assets/vid/conn2.mp4',   /* … length = sections-1 */],
  connectorsMobile: ['assets/vid/conn1-m.mp4','assets/vid/conn2-m.mp4' /* … same length; mobile opt-in only */],
});
```

The engine handles: the ordered dive/connector chain, scroll→currentTime with rAF
smoothing, blob loading, lazy prefetch of nearby clips, frame-matched crossfades, pinned
per-section copy (first section greets on landing, last holds its CTA), a route rail,
`prefers-reduced-motion`, and mobile. **Pacing per section:** `scroll` overrides
`diveScroll` for that scene (more scroll = longer dwell) and `linger` (0–1, keep ≤ 0.6)
remaps time so the camera settles mid-scene — exactly while the copy peaks — then speeds
up toward the seam; seam frames are untouched (f(0)=0, f(1)=1). Give the hero and finale
scenes a higher `scroll` + some `linger`; keep transit scenes brisk. Theme it with CSS
variables (`--accent`, `--sw-bg`, `--sw-ink`, …) — the visual identity comes from the
generated clips, so the chrome stays quiet. See the header of `scrub-engine.js` for the
full config + CSS vars.

**On phones the engine adapts automatically** (coarse pointer or ≤860px): it serves
`clipMobile` / `connectorsMobile` when present, **coalesces seeks** (never queues a new
`currentTime` while the decoder is still seeking — this is what stops a fast flick from
freezing the clip), **keeps the still as a poster until the clip paints its first frame**
and **primes each video on first touch** (fixes iOS's blank-until-played video), drops the
drifting particles, ignores URL-bar-only resizes (no scroll jump), and uses safe-area
insets so copy clears the notch/home indicator. All of this hardening is on by default —
no config needed. The `clipMobile`/`connectorsMobile` encodes are the opt-in part
(Step 1.5): only wire them when the user asked for the mobile version.

For non-JS backends (Python/Rails/etc.): serve the assets and drop the engine `<script>`
into the rendered HTML; nothing about it is framework-specific.

---

## Step 8 — QA the seams (don't skip)

Step 5d already measured the seams numerically — that catches bad handoffs before you spend
an encode. This step catches *page-level* problems. Drive the page in a headless browser:

- Screenshot at scroll positions just before and just after each seam. The two frames
  must be near-identical. If they pop, you used the diorama still instead of the actual
  rendered frame (redo Step 5), or the crossfade band is too short.
- Check the console for errors, confirm `video.seekable.end(0) > 0` (blob working), and
  that `currentTime` tracks scroll across each clip's band.
- **Mobile — full checklist only if the user opted into the mobile version (Step 1.5).**
  For a desktop-only build, just sanity-check a phone viewport once: page loads, still
  posters show, nothing overlaps — the engine's hardening covers graceful degradation.
  For the mobile build (do this on a real phone or an emulated one, portrait + landscape):
  - Emulate a phone viewport **with CPU throttled 4–6×** and scroll fast — the clip should
    track without freezing (the seek-coalescing + `-m.mp4` encodes are what make this hold).
  - Confirm the first scene shows immediately (its still is the poster) and the video takes
    over the instant you scroll — no blank/black scene (the iOS priming fix). Test iOS Safari
    specifically; it's the one that goes blank if this regresses.
  - Verify the `-m.mp4` variant is actually served on mobile (Network panel), and the
    heavy master on desktop. The mobile clips must be **natively portrait**
    (`videoWidth < videoHeight` — not a downscaled 16:9 file), and the `stillMobile`
    posters must be served and match each portrait clip's first frame (no
    landscape→portrait flash when the video paints).
  - Slowly scroll so the URL bar collapses — the page must **not jump** (height-only resizes
    are ignored on touch). Rotate the device — layout should recompose cleanly.
  - Only if the crop **fallback** shipped (no credits for the portrait chain): portrait
    crops a 16:9 clip to its centre — confirm the focal subject still reads, and remind
    the user this is the stopgap, not the mobile version.
- Check reduced-motion (should fall back to the stills, no video, no particles).

---

## Gotchas (hard-won)

- **Your prompt got rewritten** → one of the three switches in
  [Prompt integrity](#prompt-integrity--read-before-generating-anything) was left on: the
  tool-level "polish into 4 variants" flow, `autoEnhancePrompt` (images), or
  a prompt-expansion field on the video model (**defaults true** on some). The style
  preamble must go through byte-identical or the world stops looking like one place.
- **Seam pop** → connector endpoints were the diorama stills, not the neighbouring
  clips' actual frames. Always extract real frames (Step 5). Measure it (Step 5d): a good
  seam is RMS 3–8, a broken one is 30+.
- **Seam stutter / camera "jumps backward"** → even with frame-matched seams, if the
  camera *velocity reverses* (forward dive, then a connector that pulls back out) it
  reads as a rewind. This is inherent to architecture B. For any grounded walkthrough use
  architecture A; see Step 4.
- **`openart_creation_wait` errors out instead of returning** → `timeoutSeconds: 90`
  can exceed the MCP client's own request timeout. Use **≤ 60** and call again when it
  returns `STILL_RUNNING`. `openart_creation_get` is a cheap non-blocking status peek.
- **Paying for audio you immediately strip** → audio fields (`generateAudio`,
  `generateSound`, …) **default to true** on several models. Set them false. Some models
  return an AAC track with no field to disable it — `-an` at encode is mandatory either way.
- **`startFrame` rejected / "invalid image"** → you passed a local path. It needs a hosted
  URL. OpenArt-generated assets already have one (`resources[0].url`); local files must go
  through sign + PUT (Step 5b), and the `size` you sign with must be the exact byte count.
- **Output isn't the aspect you asked for** → several image2video models have **no
  `aspectRatio` field**; the output follows the **start frame** (verified: a 1080×1920
  portrait canvas returned 1080×1920). This is why the mobile chain composites a real
  portrait canvas (pipeline.md §6b) rather than asking for 9:16. Check the schema — some
  models do take an explicit enum.
- **Resolution enum is nominal** → a model labelled `"720p"` fed a 3:2 start frame returned
  1200×800. `ffprobe` the download and encode what it reports; never upscale.
- **Frozen video / stuck at frame 0** → `seekable=[0,0]`; the host isn't serving byte
  ranges. Use blob URLs (engine does).
- **Huge files** → you used all-intra. Use `-g 8` + blob instead.
- **Soft / low quality** → you downscaled or over-compressed. Encode native res,
  crf ≤ 20, add `unsharp`. Video is inherently softer than the stills — keep the stills
  as the lite fallback for max fidelity.
- **A clip gets refused by the content filter** → re-roll first (often non-deterministic);
  then strip trigger words and add "empty, unoccupied, no people, no figures, architectural,
  tasteful"; then regenerate just that clip on a *different* roster model with the same
  start/end frames — a different provider's filter often passes what another blocks. Expect
  a slight render-character shift on that one clip; for a 5s connector behind a crossfade
  that usually beats the last resort: set the connector slot to `null` — the engine
  crossfades that seam directly (optional connectors), so the page still completes.
- **Dark / custom theme** → the engine wraps its default tokens in `@layer sw`, so a
  page-level `:root` / `.sw-root { --sw-bg; --sw-ink; --sw-accent; --sw-font-* }` block
  wins cleanly (no specificity hacks). `--sw-ink` is your primary **text/heading** colour;
  the **accent** fills the primary button and active nav. For a dark theme, set `--sw-bg`
  dark and `--sw-ink` light — the copy scrim and title shadow follow `--sw-bg` automatically.
- **Phone scrub stutters / freezes on a fast flick** → the full-res master is too heavy for a
  phone decoder and seeks pile up. Ship the `-m.mp4` mobile encodes (720p, `-g 4`) and wire
  `clipMobile`/`connectorsMobile` (Step 6/7). The engine already coalesces seeks; the lighter
  encode is the other half. Still choppy on a low-end device? Tighten GOP (`-g 2` / all-intra).
- **Blank / black scene on iOS (desktop was fine)** → an iOS Safari quirk: a muted video that
  was never played won't paint a seeked frame. The engine fixes this by keeping the still as a
  poster until the clip paints and priming each video on first touch — so **don't** hide the
  still on `loadedmetadata` or strip the `playsinline`/`muted` attributes if you adapt the
  engine into a framework.
- **Page jumps while scrolling on mobile** → something is re-running layout on the URL-bar
  show/hide `resize`. The engine ignores height-only resizes on touch; if you ported it, gate
  your resize handler on a width change (keep the `orientationchange` path for rotation).
- **Copy hidden behind the URL bar / notch on mobile** → use the engine's safe-area-aware
  bottom offset (`env(safe-area-inset-bottom)` + `dvh`); make sure the page's
  `<meta viewport>` includes `viewport-fit=cover` (the template does).
- **Portrait crops the scene** → a 16:9 clip on a tall phone shows only its centre — which
  is why the mobile version is the native 9:16 chain (§6b), never the crop. If you're seeing
  this on a mobile build, either the crop fallback shipped (call it out to the user) or the
  9:16 encodes aren't actually being served (check `videoWidth < videoHeight`). Keeping each
  scene's focal subject centred (prompts.md) still matters for the desktop film itself.
- **Seam pop only where you "saved credits"** → you swapped models mid-chain, or used a
  model with no `endFrame` where a connector needs one. One model for the whole chain; the
  cheapest qualifying model still frame-locks, so saving money never costs you the seam.
- **White-box scenes** → most illustration models return a solid bg; either match the page
  bg to it or knock it out (Step 3).
- **bash 3.2** on macOS → no associative arrays in scripts.
- **Connector grabs the wrong scene's frames** (or errors on a frame that doesn't exist
  yet) → the array loop ran in **zsh** (macOS default interactive shell), where arrays are
  1-indexed, not bash's 0-indexed. Keep every array-driven chain step in a `#!/bin/bash`
  script run via `bash script.sh` — never inline array loops in the interactive shell.

## References

- `references/prompts.md` — the intake checklist, style-preamble pattern, and every
  prompt template (scene still, dive, connector) with fill-in slots.
- `references/pipeline.md` — the full run procedure (generate → extract frames → upload →
  connectors → verify seams → encode → mobile), with bash-3.2-safe scripts for the local
  ffmpeg work.
- `references/scrub-engine.js` — the portable, config-driven scrub engine (builds DOM +
  injects CSS; blob-seek, lazy load, seam crossfade, copy, route rail, reduced-motion, and
  phone hardening: mobile encodes, seek-coalescing, iOS priming, safe-area, no-jump resize).
- `references/index-template.html` — a minimal standalone page that mounts the engine.
- `references/knockout.py` — border-connected background knockout for floating scenes.