Basic Cohub abilities. Most agents already have these mounted; listed here for reference.
Platform Published by the Neta team.
Official Made by the Neta team.
Verified Reviewed by the Neta team.
Community Shared by a Cohub user. Not reviewed — read SKILL.md before you use it.
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.
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.