# Scroll World — SDTK layout pack

A complete landing-page layout whose opening act is a **scroll-scrubbed camera
flight**: scroll position drives a pre-rendered clip's `currentTime`, so the
visitor flies through the shot at their own pace. Then the page keeps going,
because a landing page has to sell something once it has your attention.

Signature: `scroll-scrub` (tier T1). Engine: 13 KB, zero dependencies.

```
index.html               the layout
scroll-scrub-engine.js   the engine (unmodified — same file the kit ships)
assets/act1.mp4          960x520  desktop clip
assets/act1-m.mp4        768x416  mobile clip (native render resolution)
assets/act1.webp         poster, extracted from frame 0 of act1.mp4
```

Open `index.html` by double-clicking it. No server, no build, no install.

---

## 1. It is complete without the video

Delete every `.mp4` and the page still reads correctly: the poster stays, the
copy still lands, the sections below are untouched. Same under
`prefers-reduced-motion`, and same with JavaScript disabled — the sticky stage
releases and the whole thing becomes an ordinary stacked page.

**The footage is an upgrade, never a dependency.** That is what makes this
shippable before any video exists.

---

## 2. Why the clip is small on purpose

The clip here is 960×520, not the 1536×832 master it came from. That is not a
budget compromise — the small one is measurably **smoother**.

Seek cost is dominated by decode work, and a scrub issues a seek on every frame.
We drove real scroll in a real browser and counted how many of the clip's 121
frames actually reached the screen:

| clip | bytes | frames delivered per second |
|---|---:|---:|
| 1536×832 master | 5.90 MiB | 22.0 of 24 |
| **960×520 (this pack)** | **1.94 MiB** | **24.0 of 24** |

Longest stall at any scroll speed — including scroll **reversal**, the expensive
case, because seeking backwards always re-decodes from the previous keyframe —
was **33 ms**, at startup. There is no perceptible freeze.

### Reproducing this

Do not take the numbers on our word. Measure `currentTime`, not pixels:

```js
// once per requestAnimationFrame, while driving window.scrollTo yourself
samples.push({ t: performance.now(), ct: video.currentTime, seeking: video.seeking });
```

A freeze **is** `currentTime` failing to advance while the user keeps scrolling.
Count consecutive samples where it did not move; that run length, times your rAF
interval, is the stall in milliseconds.

**Do not measure this with screenshots.** A screenshot-per-step harness captures
while the decoder is mid-seek, so consecutive images are the same frame and the
measurement invents freezes that were never on screen. We watched a harness
report MAFD 0.021 — near-total freeze — on a page that was in fact running fine.

One more trap: on this page the longest run of unchanged `currentTime` is
**by design**. `linger: 0.45` holds the scrub mid-scene so the copy can be read.
Set `linger: 0` before you measure, or you will file a bug against a feature.

---

## 3. Bringing your own footage

Replace `assets/act1.mp4`, extract a fresh poster, done. But encode for
**scrubbing**, not for streaming — these settings are the difference between a
clip that scrubs and one that stutters:

```bash
# desktop
ffmpeg -i master.mp4 -an -vf "scale=960:520:flags=lanczos,unsharp=5:5:0.6:5:5:0.0" \
  -c:v libx264 -preset slow -crf 26 -pix_fmt yuv420p \
  -g 4 -keyint_min 4 -sc_threshold 0 -movflags +faststart assets/act1.mp4

# mobile — a separate encode, not a downscale of the file above
ffmpeg -i master.mp4 -an -vf "scale=768:416:flags=lanczos,unsharp=5:5:0.6:5:5:0.0" \
  -c:v libx264 -preset slow -crf 26 -pix_fmt yuv420p \
  -g 4 -keyint_min 4 -sc_threshold 0 -movflags +faststart assets/act1-m.mp4

# poster — MUST come from the clip you ship, or the scene visibly jumps
# when the first real frame paints
ffmpeg -ss 0 -i assets/act1.mp4 -frames:v 1 -c:v libwebp -quality 82 assets/act1.webp
```

Hard requirements: **no audio track**, `yuv420p`, `+faststart`, and a closed
short GOP (`-sc_threshold 0`). A clip that violates these works on the author's
laptop and stutters or goes black on a visitor's phone.

**Never widen `-g` to shrink the file.** The short GOP is what makes scrubbing
work; trading it away breaks the only feature this layout has. Raise `-crf`
instead.

### Verify before you ship

```bash
ffprobe -v error -show_entries stream=index,codec_type,codec_name,width,height,pix_fmt \
  -of csv assets/act1.mp4          # exactly ONE line, codec_type=video

python3 -c "d=open('assets/act1.mp4','rb').read(4096); print(d.find(b'moov') < d.find(b'mdat'))"
                                    # must print True — that is faststart
```

### If you generate footage, check you encoded the render you meant to

Generation pipelines leave rejected takes on disk next to accepted ones. Before
encoding a master from a frame directory, prove the frames match the render you
approved:

```bash
ffmpeg -y -loglevel error -i approved.mp4 -frames:v 1 /tmp/ref0.png
ffmpeg -hide_banner -i frames/up_000.png -i /tmp/ref0.png \
  -lavfi "[0:v]scale=1536:832:flags=lanczos,format=gray[a];[1:v]format=gray[b];[a][b]ssim" \
  -f null - 2>&1 | grep "All:"
```

`All:` ≥ 0.95 means same footage. This is not hypothetical — building this pack,
that check returned **0.285** and caught a frame directory belonging to a take
that had been rejected and quarantined. Without it the pack would have shipped
the wrong film.

### Encode from the master, not from a delivered file

Re-encoding an already-compressed clip makes the second pass spend bits
preserving the first pass's noise. Going back to the mezzanine gave us **better
quality and a smaller file** at identical settings:

| source | SSIM vs reference | bytes |
|---|---:|---:|
| from the delivered 5.9 MiB clip | 0.943 | 2,251,937 |
| from the CRF-14 master | **0.965** | **2,036,843** |

---

## 4. Tuning the layout

In `index.html`, `HERO_CONFIG`:

| key | meaning |
|---|---|
| `scroll` | viewport-heights of scroll this scene occupies. Higher = slower flight. |
| `linger` | 0..1 — where mid-scene the scrub settles so copy can be read. |
| `crossfade` | seam dissolve width between scenes, in viewport-heights. |
| `accent` | per-scene token; `onProgress` mirrors it to `:root`. |

More acts: add entries to `scenes`. The engine cross-dissolves between them, so
they do **not** need to be frame-continuous — useful, because most generation
services cannot hold a pixel-exact seam between clips.

Re-theme by editing `:root`, or add a `[data-palette="…"]` block. Every colour on
the page resolves from a token; nothing is hard-coded.

---

## 5. The caveat we are not hiding

On a portrait phone, `object-fit: cover` crops a landscape clip to roughly the
**middle 25% of its width**. This pack ships a smaller *landscape* clip for
mobile rather than a portrait one, because a native 9:16 render was never proven
on the pipeline that produced this footage.

Frame your subject centrally, or supply your own portrait encode as
`clipMobile`. The engine switches to it below 860 px.

---

## Alternative: no video at all

If you want the "fly through a world" feeling without a render pipeline, the kit
also ships `three-flight` (T3) — a live three.js camera flight along a spline.
It costs nothing to regenerate, re-themes from your tokens at runtime, has no
portrait-crop problem, and measured *more* motion than this pack does.

What it does not buy you is photorealism. Pick per project:

```
sdtk-design start --idea "…" --signature three-flight
sdtk-design preview        # Signatures tab — every option with its real weight
```
