scroll-world-video

Officialneta-search/scroll-world-video
Use it in Cohub

Paste this into your agent's chat — it fetches the skill and follows it.

Use the skill: scroll-world-video. Run `npx -y @neta-art/neta-search-cli@latest skills use neta-search/scroll-world-video --via site` and follow what it prints. Read its complete output, redirecting it to a temporary file first if necessary, and resolve relative paths from the skill directory it prints.
Use in Neta Studio Open it in Neta Studio and see what people made with it.
Install from a terminal
npx -y @neta-art/neta-search-cli@latest skills use neta-search/scroll-world-video
Summary

Turn your world into a scrolling flight — scroll down and the camera dives through one scene into the next, no cuts.

  • Creates a landing page where scrolling moves a camera through your world, diving into each scene and flying smoothly into the next.
  • Comes as a ready web page with a hero still, scene visuals, short title and body text, and an optional call to action on the last scene.
  • Starts from a single master view of your world so every scene shares the same look, lighting, and angle.
  • Works best for a world with a clear sequence of places or stages, like a business's journey from raw material to finished product.
  • You can choose a visual style (clay diorama, neon night, photoreal) and get a desktop experience, with an optional lighter mobile version.
SKILL.md
# scroll-world-video

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.
Same technique as Apple's scroll-through product pages — the camera genuinely moves, scroll
only drives time. Visuals are generated with `neta generate image` / `neta generate video`
(async submit + `generate status` polling); the page just scrubs
pre-rendered video by scroll position.

**What you produce:** 1 master world still → N scene stills (derived from the master for a
shared look) → N dive clips → N−1 connector clips joining consecutive scenes → a portable
scrub engine that plays the 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)
before generating any connector. Getting it wrong is the most common failure and produces a
visible pop between scenes.

**Two hard rules:** never invent SVG or placeholder art if generation fails — stop and tell
the user. Never ship a non-seamless build to satisfy a model request.

No frontend framework assumed. `references/scrub-engine.js` is self-contained vanilla JS
(builds its own DOM, injects its own CSS into a container you pass), so it drops into plain
HTML, Next.js, Vue, or a server-rendered page. The value here is the generation method, the
prompts, and the seam method — not the framework.

---

## Parameters — the single source of truth

Every number the build needs. **Steps reference this table; they do not restate it.** Change
a value here and nowhere else. Rationale and measurements: `references/measurements.md`.

| What | Value | Notes |
|---|---|---|
| Still aspect | **16:9** (`size=1536x1024`) | Must equal the clip frame — the still is the video poster |
| Still model | `gpt-image-2` | Set via `NETA_DEFAULT_IMAGE_MODEL` — the gemini default rejects `size=1536x1024` |
| Video model (final) | `seedance-2-0` | Set via `NETA_DEFAULT_VIDEO_MODEL`; first + optional last frame |
| Video model (previz) | `seedance-2-0-fast` | whole chain, validates journey + seams |
| Video model (moderation fallback) | `kling` | single stubborn clip only |
| Dive duration | **6s** | 8–12s only for path/walkthrough builds |
| Connector duration | **5s** | 7–10s if the two scenes are far apart |
| Video ratio / audio | `ratio=16:9`, `generate_audio=false` | |
| Encode | `crf 23`, `-g 8`, native res + **native fps**, `-an`, `unsharp`, faststart | Never crf 20, never lower fps — both measured to *grow* the file |
| Mobile encode | `640×360`, `-g 4`, `crf 26` → `*-m.mp4` | Beta, only if the user opted in |
| Scene count | **3 to start** | Recommended for load weight, not a cap — propose it with the reason (Step 1.4) |
| Byte budget | **≤25 MB desktop / ≤6 MB mobile** at 3 scenes | ~+9 MB per extra scene (2 clips) |
| `diveScroll` | 1.3 diorama · **1.0–1.2 path** | Viewport-heights of scroll per clip |
| `connScroll` | 0.9 | |
| `linger` | **0** default · ≤0.25 hero only | >0 on a normal scene is the #1 cause of a "static" build |
| Crossfade | ~0.08 | Insurance on top of frame-matched seams |

---

## Step 0 — Bootstrap

1. **Tools:** `neta` (on PATH, or bundled at `/mods/neta/neta`), `ffmpeg`, `ffprobe`,
   `python3`, `convert` (ImageMagick). This env has `convert` but **not** `cwebp`; Pillow may
   be missing (`pip install --user Pillow` only if you need `knockout.py`).
2. **Pin the models — neta selects by ENVIRONMENT, not a `-m` flag.** The defaults are wrong
   for this pipeline, so export both before anything else:
   ```bash
   export NETA_DEFAULT_IMAGE_MODEL=gpt-image-2      # default is gemini, which rejects size=1536x1024
   export NETA_DEFAULT_VIDEO_MODEL=seedance-2-0     # seedance-2-0-fast for previz
   ```
   Then confirm what they actually accept — neta is declaration-first, so ask it rather than
   assuming ids or limits:
   ```bash
   neta generate image params      # data.parameters, data.referenceImageInput.supported
   neta generate video params      # duration min/max, image roles + max
   ```
   **Only pass parameters that appear in the declaration.**
3. **Submit/poll protocol — the big behavioural difference.** `neta generate` stores results as
   a **work** and generates **asynchronously**; it writes no local file. Submit with `--async`,
   poll `generate status <taskRunId>` until `data.asset.status == "ready"`, then `curl`
   `data.asset.url` into `$WORK`. Three rules that are easy to get wrong and costly when you
   do:
   - **Never run `generate reconcile` in the submit turn** — `--async` already queued a
     platform-side reconcile; racing it can mark the artifact **failed**.
   - **Poll only with `generate status`** (read-only; it also settles the local artifact).
   - **Never submit video without `--async`** — nothing is written until completion and the
     result can be lost. Use `--async` for images too, so the pipeline is one mode.
4. **Confirm the batch with the user before video.** State the planned clip count
   (`N` dives + `N−1` connectors, or N sequential legs for architecture A) and the models.
   Video is the expensive, slow part. Fold this into the single Step 1 round — it never costs a
   turn of its own. When the Step 1 gate leaves nothing to ask (every item given, or the user
   handed you the call: "you decide", "just finish it"), state the batch in one line and keep
   building in the same turn; don't end the turn on a confirmation. A fixed journey alone does
   not clear the gate.
5. **Workspace layout — the site is a Web Work.** Once the Step 1 gate is cleared, declare it
   before the first generation with
   `node /mods/neta/neta work init web works/web/<slug> --title "<work title>"` (contract:
   `create-web-work` skill) and build straight into that directory.
   ```bash
   PROJECT=works/web/<slug>            # the user-facing site = the Web Work dir
   WORK=/tmp/scroll-world-video        # scratch: prompts, raw gens, frames
   ASSETS="$PROJECT/assets"
   mkdir -p "$WORK" "$ASSETS/vid"
   NAMES="farm shop cup"               # ordered section ids, last = hero
   ```
   If the session names a directory for intermediate files, use it as `$WORK` instead of `/tmp`
   — it survives a restart. Keep `$WORK` outside the Web Work dir (it must not be published).
6. **Keep a progress log so an interrupted build resumes, not restarts.** A full build runs for
   a long time across many async jobs, and a turn can be cut off mid-run. Append one line to
   `$WORK/progress.log` per milestone: the slug, the plan (scenes, architecture, mobile yes/no),
   each submitted `taskRunId`, and each asset once downloaded. At the start of any follow-up
   turn on the same build, read that log first: poll pending `taskRunId`s with
   `generate status` (never resubmit a job that may still be running), skip finished assets,
   and don't re-read references or re-check params you already logged. The turn still ends
   at Step 8d — published Work + card — or at an explicit blocked status.

Run flow with copy-paste commands: `references/pipeline.md`.

---

## Step 1 — Interview the user

Ask only what you can't sensibly default, **in one round, once** — and skip anything the
opening ask already answers.

**The gate — run it before any generation or `work init`.** Mark items 1–4 below *given* or
*missing* against the opening ask itself:

- **Subject** — given if the ask says what the world is about; "for this world" / "this
  world's places" names it.
- **Brand kit** — given only if the ask states palette + name + tone, or points to a website
  to infer them from. Source artwork, a visual reference, a style word or the world's own
  colours are not a kit: they tell you where to *draw* your proposed palette from, but the
  page's display name and tone are still unstated. Deriving a kit yourself is a proposal,
  not an answer. This is the item most often skipped, so mark it honestly.
- **Art direction** — given if the ask names a style (clay diorama, neon night, photoreal…)
  or source artwork to follow.
- **Journey** — given if the ask lists the ordered scenes. A camera rule ("walk forward,
  no cuts") or "this world" is not a journey.

What the world already holds (its places, its `visualStyle`, its covers) feeds your
*proposal*; it does not answer an item for the user. Naming the world is not delegation —
only an explicit hand-off ("you decide", "just finish it", "直接做完") is.

- **All given, or delegated** → no question; take the defaults below for anything left and
  build in the same turn (Step 0.4).
- **Anything missing** → send ONE message covering every missing item (for brand kit, art
  direction and journey: your concrete proposal for them to approve or edit, built from the
  world), plus mobile if unstated and the batch line from Step 0.4. Then **end the turn**:
  no generation, no `work init`, until they answer. Never re-ask an item the ask already gave.
  Their reply clears the gate (anything still unanswered takes your proposal); build through
  to Step 8d without asking again.
- **Only the brand kit missing** is still a round, not a judgement call. Keep it short:
  one line with the name, 4–6 hexes and tone you propose (say what they come from), plus the
  batch line, then end the turn. That is a quick approval, not a re-ask of art direction,
  and "the direction is clear enough" does not skip it.

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. Reserve structured
either/or for the genuinely enumerable choices below, and signal they can go their own way.

1. **Subject** (open question) — "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 ("a bubble tea company, from leaf to last sip"), and a brand name if they have one.
2. **Brand kit** — either they hand you palette + name + tone, or you propose and they
   approve. Capture **4–6 named hex values**, a display name, a tone word or two. If they
   only have a website, read it with your browsing tools and infer.
3. **Art direction** — default "soft matte low-poly **clay diorama**, isometric, tilt-shift
   miniature, warm light." Alternatives: flat papercraft, glossy toy, claymation, neon night,
   photoreal architectural. Whatever is chosen becomes the shared **style preamble**, reused
   **verbatim** in every scene prompt. This is what makes the world cohesive.
4. **The journey** — the ordered scenes the camera flies through. Propose a set derived from
   the subject's own value chain and let the user edit.

   **Propose 3 to start, and say why.** The gate loads every clip before the page reveals
   (Step 7), so the scene count *is* the visitor's wait. Put that in front of them rather than
   deciding it for them — 3 scenes is a recommended starting point, not a cap:

   > "For performance I'd suggest starting with 3 scenes — the page preloads every clip before
   > it opens, so 3 keeps the first load light (~25 MB). Each extra scene adds roughly 9 MB to
   > that wait. Here's the journey I'd propose: farms → shop → the cup. Happy to go wider if
   > you want more beats."

   Adapt the wording, keep the three moves: **the number, the reason, and the door left open.**
   If they ask for more, do it — just restate the added weight once (`+9 MB` per scene) so the
   choice is informed, and re-check the byte budget in Step 6.

   Each section needs: a short subject description (what's IN the diorama), an eyebrow, a
   headline, one line of body, 0–3 tag pills. The last section is usually hero product + CTA.
5. **Mobile version (beta) — ALWAYS ask unless the request already says; never silently
   generate both.** A stated "desktop only" / "with a mobile version" is the answer — don't
   re-ask. If the user delegated without saying, build Desktop only and say so. State the beta
   disclaimer explicitly, don't imply it: *"Mobile support is in beta — the scroll-scrub
   mechanic is desktop-native; on phones you get lighter encodes and engine hardening, but
   portrait crops the 16:9 frame and low-end devices may still stutter."* Options: "Desktop
   only" / "Desktop + mobile (beta)".
   - **Yes** → produce `-m.mp4` encodes (Step 6), wire `clipMobile`/`connectorsMobile`
     (Step 7), run the full mobile QA (Step 8).
   - **No** → skip the 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 the page not breaking when a phone
   visits, so a desktop-only build still degrades gracefully.

**Video model is not an interview question** — default silently per the Parameters table.
Honor a stated preference **only if it can frame-lock seams** (accepts a first/last frame).
A model that can't is declined with a one-line why, not substituted in.

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

---

## Step 2 — Stills (master first, then scenes)

**1. Master world still — the cohesion anchor.** ONE isometric still of the whole world using
the style preamble, so every scene inherits one coherent look.

**2. Per-scene stills, derived from the master.** One per section, all reusing the same
preamble verbatim, seeded from the master so palette/angle/light lock.

Generate at 16:9 (Parameters). This is not cosmetic: the still is the video's poster, so a
mismatched ratio means poster and first frame are different compositions and the scene
**visibly jumps** when the video takes over. `qa.sh` flags any non-16:9 still.

- Run the stills **serially with per-item retry**, not all N at once — parallel image gens
  flake with transient connection errors and abort the batch.
- A generation may fail transiently; re-roll that one, don't restart the batch.
- **Photoreal exception:** generate each scene `text-to-image`, do **not** derive from the
  master — a real-room reference clones the same room (`prompts.md`).
- **Review the stills before continuing.** They must read as one world (same angle, palette,
  light). Re-roll off-style ones; the master reference already locks the look.

Commands: `pipeline.md` §1.

---

## Step 3 — (Optional) Float the scenes

`gpt-image-2` returns a solid background. To make dioramas float over an atmospheric page
instead of sitting in a box, knock the background out to transparency with
`references/knockout.py` (border-connected flood fill — preserves interior colour that
matches the bg, e.g. cream walls), or just make the page background the same colour and skip
this. ImageMagick fuzz-floodfill fallback in `pipeline.md` if Pillow is missing.

Skip entirely for photoreal — those scenes are full-bleed.

These stills double as **video posters and lazy-load fallbacks**, so keep them. **Dive first
frames must use the solid-background still**, never the transparent webp.

---

## Step 4 — Camera architecture (pick one)

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

**Model rule:** only models that can **frame-lock a seam** are usable — every chained clip
takes a **first frame**, and connectors also need a **last frame**. That capability, not
preference, is the selection rule. Skip anything whose image input is reference-only: it can
*condition* a generation but not *continue* a shot, so it physically cannot hold a seam.
**One model for the whole chain** — each renderer has its own motion/colour/grain character,
so mixing mid-chain keeps position continuity but the character shift reads as a subtle pop.
The only sanctioned exception is a single-clip moderation fallback.

### A) Continuous forward take — for grounded / realistic / walkthrough

One camera that only ever glides **forward**, first scene to last. Legs are generated
**sequentially**: leg 0 from scene 0's still; each later leg's **first frame = the previous
leg's ACTUAL last frame**, and you pass **only that one image, no last frame**. A last frame
of a wide establishing shot forces the camera to pull back — the #1 cause of stutter.

No connectors (skip Step 5); the legs ARE the journey. Wire each leg as a section clip with
`connectors: []` and the default crossfade. Cost: strictly sequential (can't parallelize) and
slower; interiors trip the content filter, so budget ~3 attempts per leg.

> **Be honest about what A is.** It's a chain of separate renders joined by actual-frame
> handoffs plus a short crossfade — **not** one uninterrupted model-rendered long take. Seams
> are position-continuous, but colour/grain can shift slightly leg-to-leg, so it reads as
> "one connected flight," not "one seamless 60s shot." If the user wants a single continuous
> film (auto-playing, not scroll-driven), that's a different deliverable — say so.

### B) Dive-in + forward-glide connector — default, works for any world

A dive clip per scene + a connector between each pair, built from the REAL boundary frames
(Step 5). The engine plays dive1 → conn1 → dive2 …, so scrolling off the end of a dive kicks
its transition. **Keep the connector a low, continuous FORWARD glide.**

> **At the default 3 scenes, B leaves only 2 connectors** — the workable floor, since the
> connectors are what carry the "one connected flight" read. It still works. But if a 3-scene
> B build feels more like three clips than one flight, architecture **A** is the better fit at
> that size: 3 legs, 3 clips total (vs. 5), fewer generations, and the legs chain directly.

> **Never** prompt a connector to pull up / rise into the sky / arrive above / go to a map
> view. That **reverses camera direction at the seam** (forward dive → backward pull-out) and
> reads as a jarring rewind when scrubbed, *even with frame-perfect seams*. If two scenes are
> far apart, give the connector a longer duration — never a faster or bigger move.

### Camera grammar — the move should fit the concept

"Forward only" is the **seam** rule, not the **leg** rule:

- **Position continuity** at a seam comes from the frame handoff.
- **Velocity continuity** means the camera must never *reverse across a seam*.
- **Inside a single leg the camera is free.** One leg is one continuous render — no seam to
  break mid-leg, so orbits, crane-ups, lateral tracking, even a push-in that eases back out
  are all safe. Reversals are only fatal *across* seams.

Give each leg an expressive move from the scene's own logic, under a **motion handoff
contract**: every leg **ends by settling into a slow steady forward drift** (final ~1s) and
**begins by continuing that drift**. Keep both clauses verbatim (`prompts.md`).

| 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 cost: expressive moves raise re-roll odds, since the model can end a fancy move in a
state that isn't a clean forward drift. Mitigations: keep the 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 is the zero-risk default.

Scroll is a scrubber, so visitors can scroll **up** and every move also plays in reverse.
That's free and expected — and another reason seam velocity must be consistent both ways.

### Previz → final

Run the **whole chain on the fast tier first** to validate the journey, seams, and motion.
Because previz is seamless too, it translates directly. **Then decide whether you actually
need to upgrade — often you don't;** treat the fast tier as a *candidate final*. Upgrade a
clip when it shows a concrete defect, not on principle (defect list + measurements:
`measurements.md`).

**Dive prompt shape (B):** starts high/outside, descends into the interior, roof/walls gently
open to reveal it. Use the **solid-background still** as the first frame, no last frame.
Templates: `prompts.md`. Commands: `pipeline.md` §2.

---

## Step 5 — Connectors (architecture B)

Skip for architecture A. Connectors are what make the world feel *connected* instead of cut.

**Both endpoints must be the ACTUAL RENDERED FRAMES of the neighbouring clips — never the
original still.** Every generation renders slightly differently, so if a connector ends on a
fresh render of "the kitchen diorama" while the next dive starts on its own different render
of it, you get a pop. Hand off the exact pixels instead:

```
connector first frame = LAST frame extracted from dive_i's rendered video
connector last  frame = FIRST frame extracted from dive_{i+1}'s rendered video
```

The model interpolates between them, so every seam is frame-identical on both sides:
`dive_i.end == connector.start` and `connector.end == dive_{i+1}.start`.

**Extract frames exactly.** Never use `-sseof` — it seeks by *time* and can land several
frames early depending on GOP alignment. Those few frames are the entire seam (measured: one
clip's seam SSIM fell 0.957 → 0.43). Count frames and select `n−1`; helper in `pipeline.md` §3.

**Insurance, not a substitute:** the model lands *close* to the last frame but not always
pixel-perfect, so the engine applies a short crossfade at each seam. Never skip the
actual-frame handoff and rely on the crossfade alone — a big content jump can't be hidden by
it. Where a true frame match is impossible, hide that one join with a fast push/whip at
assembly time instead, or set the connector slot to `null` (the engine crossfades that seam
directly) so the page still completes.

Prompt: forward glide only, no pulling back. Template: `prompts.md`. Commands: `pipeline.md` §4.

---

## Step 6 — Encode for smooth scrubbing

Scrubbing = setting `video.currentTime` from scroll. Two things matter:

1. **Seekability, not keyframe density.** 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 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, which is why you do **not** need all-intra video.
2. **Encode per the Parameters table.** The two counter-intuitive rules — never crf 20, never
   lower the fps — both make generated video *larger*, measured. Don't relitigate them
   without reading `measurements.md`.

Encode all clips (dives + connectors) with identical settings for uniform quality.
Commands: `pipeline.md` §5, mobile §6. Then run `references/qa.sh` before assembling.

---

## Step 7 — Assemble the page

Copy `references/scrub-engine.js` (and `references/index-template.html` for a standalone
page) into the project, or adapt into the user's framework. Config-driven:

```js
mountScrollWorld(document.getElementById('world'), {
  brand: { name: 'Pearl & Co.' },
  diveScroll: 1.3, connScroll: 0.9,
  sections: [
    { id:'farm', label:'The Farms', still:'assets/farm.webp',
      clip:'assets/vid/farm.mp4', clipMobile:'assets/vid/farm-m.mp4',  // mobile beta only
      scroll: 1.6,                 // optional per-section dwell override
      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', /* … length = sections−1, nulls allowed */],
  connectorsMobile: ['assets/vid/conn1-m.mp4', /* … same length; mobile beta only */],
});
```

The engine handles: the ordered dive/connector chain, scroll→currentTime with rAF smoothing,
blob loading, lazy prefetch, frame-matched crossfades, pinned per-section copy, a route rail,
`prefers-reduced-motion`, and phone hardening. It returns `{ destroy() }` — call it on
unmount in any SPA. It emits `sw:sectionchange` so host chrome can track the active chapter
without polling scroll. Optional per-section `status` and `facts` render when present.

### iOS: why the phone path looks the way it does

Three behaviours in the engine exist because of measured iOS Safari failures. If you port the
engine into a framework, **keep all three** — each one on its own reproduces "only the first
clip scrubs, everything after it is a static image."

1. **One shared `<video>` on phones** (`singleDecoder`, default true). iOS's
   simultaneous-decode budget is 1–2 elements, and every clip here is a blob — fully
   resident, no range requests. Past the budget the extra elements never reach
   `readyState >= 2`, so their scenes stay posters and clips start working at random as iOS
   evicts others. One element moves between scenes instead; it also keeps the gesture
   authorisation iOS granted it, and peak memory stops scaling with clip count. Desktop is
   unaffected (one element per segment — crossfades need both sides of a seam decoded).
2. **Reveal on many events, gated on `readyState >= 2`.** iOS completes seeks *without*
   firing `seeked`, so a `{once:true}` `seeked` listener never fires and the poster never
   lifts. Listen to `loadedmetadata`/`loadeddata`/`canplay`/`playing`/`seeked`/`timeupdate`
   and prove a frame is compositable instead.
3. **The rAF loop reads `scrollY` itself.** iOS delivers `scroll` sparsely during inertia; a
   target refreshed only by the event handler goes stale, so the clip converges and *stops*
   while the finger is still moving. Verified by dispatching no scroll events at all:
   before, `currentTime` held at 0.00; after, it tracked to 8.45.

Two related notes: `eps` is **absolute seconds** (0.06 phone / 0.02 desktop), not a fraction
of duration — as a fraction, longer clips needed ~24px of finger travel per seek and felt
stiffer late in the journey. And a **Cohub Work loads the page in an iframe without
`allow="autoplay"`**, so every prime `play()` is rejected (measured `prime=20 ok=0 fail=20`).
That is survivable because the reveal path doesn't depend on `play()` succeeding — but it
removes the decoder warm-up, which is why the readyState-gated reveal carries the page there.

**Already ruled out, don't re-investigate:** keyframe density (measured ~1 per 0.17 s — plenty
dense for scrubbing) and `play()` rejection as a *cause* of stalled scrubbing. One open lead:
Cloudflare Rocket Loader rewrites script tags on the Work host but not on a static host,
which changes script execution timing.

**The first section's `title` becomes the page `<h1>`** — the engine owns all copy, so that's
the only place one can come from. An `<h1>` in a loading overlay does not count; the overlay
gets removed and the document goes headless.

Theme with CSS variables (`--sw-bg`, `--sw-ink`, `--sw-accent`, `--sw-font-*`). The engine
wraps its defaults in `@layer sw`, so a page-level `:root` / `.sw-root` block wins cleanly
with no specificity hacks. `--sw-ink` is the primary text colour; the accent fills the primary
button and active nav. For dark themes set `--sw-bg` dark and `--sw-ink` light — the copy
scrim and title shadow follow automatically. Full config + vars: header of `scrub-engine.js`.

### Pacing

Two knobs: per-section `scroll` (more distance = longer dwell) and `linger` (camera settles
mid-scene while the copy peaks, then speeds toward the seam; seam frames untouched). Prefer
expressive motion in the *clip* and restraint in the *scroll mapping* — they compound.

| Content type | leg duration | `diveScroll` | `linger` |
|---|---|---|---|
| Clay diorama / showcase | 6s (8s only if the move needs it) | 1.3–1.6 | 0–0.25 (hero only) |
| Path / journey / photoreal walkthrough | 8–12s | 1.0–1.2 | **0** |

`diveScroll` 1.3 is tuned for diorama showcases. **For a path build, lower it** — a long
`diveScroll` spreads the clip's frames across more scroll distance, so each scroll unit
advances `currentTime` less and the flight feels sluggish. Denser is livelier.

### Page-chrome anti-patterns — read before writing the host HTML

The engine is driven by **document scroll** and injects a tall `.sw-track` for distance. The
host page must not fight that:

- **NEVER set `overflow: hidden` or `height: 100%` on `html`/`body`.** That kills document
  scroll — `scrollY` stays 0, nothing scrubs, and there is **no error**, so it looks like a
  dead page. This is the most common "nothing happens" bug.
- To hide the scrollbar use `scrollbar-width: none` + `::-webkit-scrollbar{display:none}` —
  hide the bar, keep the scroll. Never lock scrolling to remove the bar.
- Don't wrap the mount in a fixed-height / `overflow:auto` container either.

### The preload gate — required when the flight is the page's main content

`references/preload-gate.js` holds the page behind a progress overlay until assets are
resident, then hands the engine blob URLs.

**The test: does scrolling the page advance the camera?**

- **Yes — the flight owns document scroll → the gate is REQUIRED.** The visitor's scroll *is*
  the content, so a stall mid-flight stalls the only thing on offer, with nothing else to read
  while it recovers. This is the normal documented mount. Chrome around the flight — topbar,
  route rail, CTA, a footer after the track — does **not** demote it to secondary.
- **No — the flight is a bounded module** inside a page whose scroll belongs to ordinary
  content (copy, pricing, forms), one band among several → then it's a product decision, and
  the engine's lazy load + still posters degrade gracefully. Be aware this mount isn't what
  the engine ships for: it listens on `window` scroll and injects a full-viewport fixed stage,
  so scoping it into a component is deliberate work this skill doesn't document. If you're
  here, say so out loud rather than assuming it.

**What it loads: everything, before it reveals.** No tiering, no `gateCount` knob. Every
still and every clip must be resident before the page is shown. A partial gate only
guarantees the *first* scene — on a slow connection a later scene sits on its poster while
its clip streams, which is exactly the stall the gate exists to prevent, and on a
main-content build there is nothing else on the page to look at meanwhile.

**Honest cost:** the visitor waits for the whole asset set before seeing anything (~25 MB at
the recommended 3 scenes, ~+9 MB per scene beyond that), shown as a progress bar. That makes
the Step 6 byte budget a *gate-wait* number, not just a bandwidth line — and it's why Step 1.4
proposes 3 scenes with the reason attached. Shorten dives before raising CRF. Under
`prefers-reduced-motion` the engine never loads clips, so the gate preloads only the stills;
that's correctness, not tiering.

It also handles what's easy to get wrong and silent when you do: mirroring the engine's own
mobile test (so you don't preload one encode and fetch the other), skipping video under
reduced motion, retry-with-backoff, and rejecting a **truncated transfer** (HTTP 200 with a
short body — otherwise a permanently black clip sails through the gate). Pass `font` if you
use a custom display face, so the overlay paints in it rather than flashing a fallback.

Because the gate loads everything before mounting, there is no background streaming after
reveal and so no duplicate-download collision with the engine's lazy load — every clip is
already a blob when the engine mounts, and it attaches those directly. If you write your own
*partial* preloader instead, you must implement the engine's `resolveClip(url)` handshake:
return the pending promise for URLs you own, `null` for everything else. Never return a
promise that can resolve to nothing without falling back, or a failed background download
leaves that scene stuck on its still.

---

## Step 8 — QA

**Run `references/qa.sh <project-dir>` first.** One pass measures still aspect ratios, real
bitrate and *measured* GOP (proves `-g` applied), seam SSIM, motion density, and the byte
budget. It reads clip order from your `index.html` config rather than guessing from filenames.

It **hard-fails only on unambiguous errors** (missing file, wrong aspect, GOP that didn't
apply) and prints the perceptual numbers without a verdict — the floors aren't calibrated
across enough projects yet, and a fabricated threshold would stop you looking. Reference
ranges: `measurements.md`.

Then the two checks a script can't do:

### 8a. Motion density — does it actually feel like flight?

**The check people forget, and why a build "looks static" with no error.** A seam-correct
chain can still scrub like a slideshow.

- **Per-clip travel.** Extract three frames per clip (first, ~50%, last) and compare. Each
  must show an *obvious* change in framing / depth / position. If a clip barely moves, re-roll
  it with a stronger forward prompt (distance covered + parallax, no slow-motion language)
  before touching anything else.
- **Scrub density.** Scroll at normal speed. The camera should advance continuously through
  every scene, with no stretch where you scroll and the frame sits still.
- **Fix order when it looks static:** (1) raise `duration` + strengthen the forward prompt;
  (2) remove `linger`; (3) lower `diveScroll`/`scroll`; (4) only then a higher model tier.
  Never reach for copy or CSS first — it's clip travel and scroll mapping, not chrome.

### 8b. Seam continuity

Drive the page in a headless browser:

- Screenshot just before and just after each seam. The two frames must be near-identical. If
  they pop, you used the still instead of the actual rendered frame (redo Step 5), or the
  crossfade band is too short.
- Check the console, confirm `video.seekable.end(0) > 0` (blob working), and that
  `currentTime` tracks scroll across each clip's band. If `scrollY` never moves, the host page
  locked scrolling (see anti-patterns).
- **If the gate is wired (main-content build):** the overlay appears, its bar advances to
  100%, it reveals, and each clip appears **exactly once** in
  `performance.getEntriesByType('resource')` — a second entry means something re-fetched
  after the gate.
- Check reduced-motion (falls back to stills, no video, no particles).

### 8c. Mobile

**Desktop-only build:** sanity-check one phone viewport — page loads, posters show, nothing
overlaps. The engine's hardening covers graceful degradation.

**Mobile beta (real or emulated phone, portrait + landscape):**
- CPU throttled 4–6× and scroll fast — the clip must track without freezing.
- First scene shows immediately and the video takes over the instant you scroll, no
  blank/black scene. **Test iOS Safari specifically** — it's the one that goes blank if the
  priming fix regresses.
- Confirm `-m.mp4` is actually served on mobile and the full master on desktop (Network panel).
- Scroll so the URL bar collapses — the page must **not jump**. Rotate — layout recomposes.
- Portrait centre-crops the 16:9 clip; confirm the focal subject still reads. If a hero
  subject sits off-centre and gets cut, recompose it (`prompts.md`) or generate a 9:16 variant
  for that scene.

### 8d. Deliver — publish, then hand over the Work itself

The deliverable is **one Web Work** (the scroll page), not loose clips or a local folder.

1. Publish: `node /mods/neta/neta work publish web works/web/<slug>`. Confirm the result is
   `ok:true`, `status: "ready"`, with a `previewUrl`. A publish that errors or times out is not
   a delivery — re-run it before you tell the user anything is ready.
2. Hand over with the `interaction-components` skill: a `link` card whose target is
   `{ "type": "work", "workId": "<slug>" }` (the same slug you passed to `work init` /
   `work publish`), with the work title as `presentation.name` and the `previewUrl` in
   `fallback`. **Never hand over only a bare URL in prose** — the card is what opens the Work
   in Studio; a next-steps card does not replace it.
3. **Blocked before the page can exist** (credits, every clip rejected): don't publish a
   placeholder (hard rule above). If you already ran `work init`, settle it with
   `node /mods/neta/neta work fail web works/web/<slug> --error "<reason>"`, then say plainly
   it is blocked and what unblocks it (for credits, the billing card from
   `interaction-components`).

---

## Troubleshooting index

Symptom → where the fix lives. Each step already carries the reasoning; this table only routes.

| Symptom | Go to |
|---|---|
| Seam pop | Step 5 — endpoints must be rendered frames, not stills |
| Seam pop *despite* real frames | Step 5 — you used `-sseof`; count frames instead |
| Seam stutter / camera jumps backward | Step 4 — velocity reverses at the seam; use arch. A, or a forward-glide connector |
| Seam pop only where you "saved" | Step 4 — you swapped models mid-chain, or used a first-frame-only model for a connector |
| Poster jumps when video takes over | Parameters — still isn't 16:9 |
| Encoded files bigger than source | Parameters — crf 20; use 23–24 |
| Dropped fps and the file grew | Parameters — expected; keep native fps |
| Huge files | Step 6 — all-intra; use `-g 8` + blob |
| Soft / low quality | Parameters — you downscaled; encode at native resolution, never upscale |
| Frozen video / stuck at frame 0 | Step 6 — `seekable=[0,0]`; blob path (engine does this) |
| "Looks static" / poster deck | Step 8a — clip travel + scroll mapping, in that fix order |
| Whole page dead, no console error | Step 7 anti-patterns — host locked document scroll |
| Page has no `<h1>` | Step 7 — first section's `title` is the `<h1>` |
| Phone stutters on a fast flick | Parameters — ship `-m.mp4`; tighten `-g` further if needed |
| Blank / black scene on iOS only | Step 7 — keep the still poster until the clip paints; don't strip `playsinline`/`muted` |
| Only the first clip scrubs on iOS, rest are static images | Step 7 — decoder budget; the engine's `singleDecoder` must stay on |
| iOS clip lags the finger then holds a frame | Step 7 — the rAF loop must read `scrollY` itself, not wait for `scroll` events |
| Page jumps while scrolling on mobile | Step 7 — gate resize handlers on width change, not height |
| Copy behind the URL bar / notch | Step 7 — safe-area offsets; viewport needs `viewport-fit=cover` |
| Portrait crops the scene | Step 8c — centre the focal subject, or a 9:16 hero variant |
| Content filter rejects a clip | Playbook below |
| Parallel image gens flake | Step 2 — run stills serially with per-item retry |
| Clips download twice / heavy tab | Step 7 — a custom *partial* preloader must hand over `resolveClip`; the shipped gate loads everything up front |
| Flight stalls mid-scroll on a main-content build | Step 7 — the gate is required there, and it must gate every clip, not just the first |
| Finished but the user got no Work card | Step 8d — publish, then a `link` card targeting the Work |
| Updated the site, old build loads | Stale CDN copy — publish a new version, don't overwrite a served path (publish method: `create-web-work` skill) |
| Audio you didn't want | Parameters — `generate_audio=false` |

### Content-filter playbook

The video filter flags innocuous clips, especially **bedroom, pool, spa/wellness** contexts
and words like "bed", "pool", "waterfall", "wine", "swim". It's partly prompt wording, partly
the reference frames. In order:

1. **Re-roll.** Often non-deterministic; passes on the 2nd–3rd try.
2. **Strip trigger words**, add "empty, unoccupied, no people, no figures, architectural,
   tasteful". Soften proper nouns and franchise-adjacent names.
3. **Regenerate that one clip on `kling`** (`export NETA_DEFAULT_VIDEO_MODEL=kling`) with the
   same first/last frames — a
   different filter often passes what the default blocks. Expect a slight render-character
   shift; on a 5s connector behind a crossfade that's usually acceptable.
4. **Set the connector slot to `null`** — the engine crossfades that seam directly and the
   page still completes.

Never fall over to an uncensored model to dodge a block; it won't bypass copyright/IP filters
anyway. Budget extra time for these on interiors and real-estate content.

---

## References

- `references/pipeline.md` — the run flow: every `neta generate` call, the submit/poll helpers,
  and copy-paste bash for
  download, frame extract, encode, mobile encode. **Start here when building.**
- `references/prompts.md` — intake checklist, style-preamble pattern, and every prompt
  template (scene still, dive, leg, connector) with fill-in slots.
- `references/measurements.md` — the evidence behind every default (CRF, fps, GOP, duration,
  budgets, tier choice, QA reference ranges). Read before changing a parameter.
- `references/scrub-engine.js` — the portable config-driven engine (`mountScrollWorld`):
  blob-seek, lazy load, seam crossfade, copy, route rail, reduced-motion, `destroy()`,
  `sw:sectionchange`, `resolveClip` handshake, phone hardening.
- `references/preload-gate.js` — progress-gated preloader; loads **every** still and clip
  before reveal. **Required when the flight is the page's main content** (Step 7); a product
  decision only for a bounded secondary module.
- `references/qa.sh` — asset QA: still aspect, bitrate, measured GOP, seam SSIM, motion
  density, byte budget. Run before assembling.
- `references/index-template.html` — minimal standalone page that mounts the engine.
- `references/knockout.py` — border-connected background knockout for floating scenes.