返回 Skills
oso95/scroll-world· MIT 内容可用

scroll-world

Build an immersive scroll-scrubbed "fly through the world" landing page for any industry or brand using Higgsfield. As the visitor scrolls, a pre-rendered camera flies from outside each scene into its interior, then flows on to the next scene with NO cuts — one continuous connected flight (Emons-style isometric diorama world, or any art direction you pick). The skill interviews the user for the topic, the story beats/sections, and brand kit, then generates cohesive scenes + seamless camera clips with Higgsfield and wires a portable, framework-agnostic scroll-scrub engine. Use when the user wants a "3D world" / "browse-through-the-industry" hero, a scroll cinematic, a diorama landing, or to turn a business into a scrollable world.

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: scroll-world description: > Build an immersive scroll-scrubbed "fly through the world" landing page for any industry or brand using Higgsfield. As the visitor scrolls, a pre-rendered camera flies from outside each scene into its interior, then flows on to the next scene with NO cuts — one continuous connected flight (Emons-style isometric diorama world, or any art direction you pick). The skill interviews the user for the topic, the story beats/sections, and brand kit, then generates cohesive scenes + seamless camera clips with Higgsfield and wires a portable, framework-agnostic scroll-scrub engine. Use when the user wants a "3D world" / "browse-through-the-industry" hero, a scroll cinematic, a diorama landing, or to turn a business into a scrollable world. allowed-tools: Bash, Read, Write, Edit, AskUserQuestion, Skill

scroll-world

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

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

The one rule that makes or breaks it: seams must be frame-identical. Read The seamless chain before generating any connector. Getting this wrong is the single most common failure and produces a visible "pop" between scenes.

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


Step 0 — Bootstrap

  1. Higgsfield CLI. If higgsfield is not on $PATH, install per the higgsfield-generate skill. If higgsfield workspace list fails auth, ask the user to run higgsfield auth login (interactive OAuth — you cannot run it) and, if needed, higgsfield workspace set <id>. Confirm there are enough credits: a full run is roughly N image gens + (2N-1) video gens.
  2. ffmpeg / ffprobe on $PATH (frame extraction + encoding).
  3. An image tool for background knockout if you want floating scenes: PIL (python3 -c "import PIL"), or cwebp/sips. Optional — see Step 3.
  4. (Optional) Codex CLI — if codex is on $PATH (≥ 0.125) and codex login status reports a ChatGPT login, the scene stills can be generated through Codex's built-in image_gen (the same gpt-image-2 model) billed to the user's ChatGPT subscription instead of Higgsfield credits — offer it at Step 1.6, command in Step 2. Absence just removes the option.
  5. Caveats: macOS ships bash 3.2 (no declare -A); don't use associative arrays in scripts. Higgsfield generations take 3–8 min each — always run them detached (background) and poll, never a foreground blocking call. Reference-by-job-UUID is rejected by media flags — pass local file paths to --image/--start-image/--end-image. Video models differ in accepted params (e.g. Kling has no --resolution) and in whether they support start/end-image conditioning at all — before batching, confirm the chosen model's schema with higgsfield model get <job_type> and see the Step 4 model table.

Step 1 — Interview the user

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

  1. Subject (ask openly, not multiple-choice) — "What should this world be about? Your business, a client's, or any idea — a word or a sentence is fine." Capture the industry/product + a one-line pitch (e.g. "a bubble tea company, from leaf to last sip"), and a brand name if they have one; otherwise you'll propose one below.

  2. Brand kit — offer three paths, pick one:

    • Import from a URL: higgsfield marketing-studio brand-kits fetch --url <site> --wait (pulls name, colours, tone). Then read it back with brand-kits list --json.
    • The user hands you palette + name + tone directly.
    • You propose a palette + name and let them approve. Capture 4–6 named hex values, a display name, and a tone word or two.
  3. Art direction — default is "soft matte low-poly clay diorama, isometric, tilt-shift miniature, warm light." Offer alternatives (flat papercraft, glossy toy, claymation, neon night). Whatever is chosen becomes the shared style preamble reused verbatim in every scene prompt (this is what makes the world cohesive).

  4. The journey (sections) — the ordered scenes the camera flies through. Propose a set derived from the subject's own value chain and let the user edit. 5–7 works well. Boba example: farms → pearl kitchen → flagship shop → delivery → community plaza → the hero product. Each section needs: a short subject description (what's IN the diorama), an eyebrow, a headline, one line of body, and 0–3 tag pills. The last section is usually the hero product + the CTA.

  5. Mobile version — ALWAYS ask this; never silently generate both. Ask as a two-option choice (AskUserQuestion in Claude Code; a plain question elsewhere): "Want a mobile-optimized version too? The mobile version is a second camera chain rendered natively in 9:16 portrait — composed for phones, not a crop of the landscape film — which roughly doubles the Higgsfield credit spend (state the estimated number)." Options: "Desktop only" / "Desktop + mobile (native 9:16 — ~2× credits)". The credit cost must be stated to the user, not just implied. What the answer gates:

    • Yes → render the parallel 9:16 portrait chain and ship it as the mobile variants (Step 6 / pipeline.md §6b): portrait start canvases → 9:16 dives + connectors frame-locked against their own renders → 720-wide -m.mp4 encodes → stillMobile portrait posters. Wire clipMobile/connectorsMobile/stillMobile (Step 7); run the full mobile QA (Step 8). Budget ~2N-1 extra video gens + NSFW re-rolls. Never ship the centre-crop as the mobile version by default — if credits can't cover the portrait chain, say so and offer the crop encodes (pipeline.md §6) as an explicitly-labelled stopgap the user must approve.
    • No → skip the mobile encodes and wiring entirely. The engine's phone hardening (seek-coalescing, iOS priming, safe-area CSS) is always on regardless — that's not a "mobile version," it's just the page not breaking when a phone visits — so a desktop-only build still degrades gracefully.
  6. Budget — engines shown by cost, decided before anything renders. Present the render tiers (AskUserQuestion), then compute and state the estimated total for the user's N scenes — N stills + (2N−1) videos [videos ×2 if mobile] + ~15% re-roll headroom — and get a go before generating.

    • Video tier (roster only — every option frame-locks seams, Step 4):

      TierModelRough cost
      Draft / previzseedance_2_0_mini (720p)~¼ of Standard
      Standard (default)seedance_2_0 (1080p)baseline
      Alternatekling3_0 (720p native)≈ Standard; different look + content filter

      Draft doubles as the previz path: run the whole chain cheap, approve the journey, re-render final legs on Standard (pipeline.md Notes) — suggest it unprompted when the balance reads tight.

    • Stills source (only offer if the Codex CLI is present, Step 0.4): Higgsfield gpt_image_2 (spends credits) vs Codex image_gen — the same gpt-image-2 model billed to the ChatGPT subscription (zero credits; counts toward Codex usage limits; 1536×1024 output — exactly 3:2, slightly under Higgsfield's 2k). Stills are plain PNGs handed to --start-image, so the video chain is indifferent to their source. Command in Step 2. One source for all N stills of a build — the two render with slightly different character (verified: Codex runs warmer/lighter), and mixing sources across scenes reads as style drift, same reason the video chain uses one model.

    • Calibrate costs, don't guess. The CLI exposes no pricing and plans differ. Run ONE still and ONE video first, diff higgsfield workspace list before/ after, extrapolate to the full run, and warn the user whenever the estimate exceeds ~70% of the balance. (Observed on a plus plan, 2026-07: Standard video ≈ 40–55 credits, still ≈ 15.) A real not_enough_credits mid-run is recoverable (finished clips survive; resume after top-up) but ugly — the whole point of this step is that the user decides before the spend.

If the user names a video model outside the roster, honor it only if it can frame-lock seams (Step 4). This skill only ships seamless output, so a model that can't frame-lock is declined with a one-line why, not substituted in — use a roster model instead.

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


Step 2 — Generate the scene stills

One image per section, all sharing the same style preamble for cohesion. Default model gpt_image_2 (crisp, great at isometric illustration; returns a solid/white background which is perfect for floating diorama "islands"). Use nano_banana_2 only if the brief is character/cartoon-heavy (note: nano_banana_2 is a CLI alias — it resolves to nano_banana_pro; it won't appear under that name in higgsfield model list).

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

<STYLE PREAMBLE, identical every time>. On a plain solid <bg> background with a soft
contact shadow. <PALETTE hexes>. No text, no letters, no logos, centered, 3:2.
Subject: <what is in THIS diorama>.
  • Run all N concurrently, detached. Command per scene: higgsfield generate create gpt_image_2 --prompt "$(cat scene_i.txt)" --aspect_ratio 3:2 --resolution 2k --quality high --wait --wait-timeout 15m --json > scene_i.json 2>scene_i.err

  • Result URL is .[]0.result_url in the --wait --json output. curl it down.

  • Codex stills variant (if chosen at Step 1.6 — subscription-billed, zero credits): same prompt files, same byte-identical preamble, generated by Codex's built-in image_gen:

    codex exec -C "$WORK" -s workspace-write --skip-git-repo-check \
      'Use the image generation tool ($imagegen) to generate: '"$(cat "$WORK/still_i.txt")"' Wide 3:2 landscape, high resolution. Save it as ./still_i.png. Do not do anything else.'
    

    Single-quote the $imagegen segment (the shell must not expand it); if editing with reference images, the prompt goes BEFORE any -i flag (it's variadic). ~1–3 min per image; run a few in parallel, not all N at once. Output lands at 1536×1024 (3:2) — fine for --start-image and posters. Everything downstream (cohesion review, knockout, dives) is unchanged.

  • A generation may fail transiently (HTTP 503) — re-roll that one individually; don't restart the batch.

  • Review the stills before continuing. They must read as one cohesive world (same angle, palette, light). If one is off-style, regenerate it, optionally passing an approved scene as --image to lock style.

See references/pipeline.md for the exact batch script.


Step 3 — (Optional) Float the scenes

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

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


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

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

Video model — pick ONE for the whole chain

This skill only ships seamless output, so the only usable models are ones that can frame-lock a seam: every chained clip must accept --start-image, and connectors also need --end-image. That capability — not preference — is the selection rule. Check any model with higgsfield model get <job_type> and skip anything whose media inputs are reference-only (no start/end image): it can only condition a generation, not continue a shot, so it physically can't hold a seam. Schemas below were confirmed against the CLI:

Modelstart/end imageNotes
seedance_2_0 (default)✓ / ✓Full chain (legs + connectors). --mode std --resolution 1080p. Its NSFW filter is the touchy one (see Gotchas).
kling3_0✓ / ✓Full chain — tested: --mode std --sound off --duration 5 with start+end images accepted, seams frame-lock cleanly. No --resolution param (don't pass one; --mode std returns 720p native — encode what ffprobe reports, never upscale). Sound defaults on--sound off. --duration default 5, try 10 for legs. Different content filter than Seedance — the sanctioned NSFW fallback.
seedance_2_0_mini✓ / ✓Cheap draft tier that keeps frame-locking (720p). The previz tier: run the whole chain here first, then re-render final legs on the full model — still seamless, so it translates directly.

Those three are the roster — all do both architectures. (kling3_0_turbo also frame-locks via --start-image, but has no --end-image, so it's architecture-A-only and can't make connectors; it also takes a different flag set — no --mode, has --resolution — so it doesn't drop into the pipeline as-is. It's not in the default roster; only reach for it, and wire it by hand, if architecture A's sequential render time is a proven bottleneck and you've benchmarked it as actually faster.)

Rules:

  • One model for all chained clips. Each renderer has its own motion/color/grain character; mixing models mid-chain keeps position continuity (frames still hand off) but the render-character shift reads as a subtle pop. The one sanctioned exception is the NSFW fallback for a single stubborn clip (Gotchas) — a slight character shift on one 5s connector beats a missing connector.
  • Default to seedance_2_0; honor a user's stated preference only if the model qualifies (frame-locking). If it doesn't, say so and use a supported model — never ship a non-seamless build to satisfy a model request.
  • The pipeline scripts take the model as $VMODEL with per-model flags already cased out (references/pipeline.md).

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

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

B) Dive-in + aerial connector — only for diorama / miniature / god's-eye worlds

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

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

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

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

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

Pick the grammar from the concept:

Concept / toneMid-leg move
Product / luxury retailslow half-orbit around the hero object, then continue past it
Real estate / hospitalitysteadicam glide through doorways; gentle crane-up in atria
Industrial / process / logisticslow lateral track alongside the line, foreground parallax
Travel / outdoors / campusdrone-style rise-and-reveal, then a descending swoop
Food / craft / detail-drivenpush in close to the craft moment, ease back, carry on
Playful miniature (arch. B)dives + aerial hops — the connector IS the grammar

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

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

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

For B, one camera flight per scene: starts high/outside, descends into the interior, structure opens. Model: the chain model you picked above (default seedance_2_0), --start-image = the scene still.

  • Use the solid-background still (not the knocked-out transparent one) as the start image, so the video has a full frame.
  • Prompt: "Single continuous cinematic camera move, no cuts. Begin high and far looking at the whole <scene> from outside … descend and fly inside toward <focal point> … the roof/walls gently open to reveal the interior. <style>, smooth graceful slow motion. No text." (Template in references/prompts.md.)
  • Params (seedance): --mode std --resolution 1080p --aspect_ratio 16:9 --duration 8. For Kling: drop --resolution (no such param), add --sound off, --duration 10. Do not pass --generate-audio (it errors on seedance; audio is wasted anyway — you'll mute).
  • Run concurrently, detached, then download each .result_url. Re-roll individual failures. Keep the raw 1080p sources — you need their frames next.

Step 5 — Connectors (architecture B only)

Skip this whole step for architecture A — the forward take has no connectors; its legs already chain seamlessly. This step applies to B (diorama/miniature), and note the reversal caveat from Step 4.

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

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

For each connector between dive_i and dive_{i+1}:
  start-image = the LAST frame extracted from dive_i's rendered video
  end-image   = the FIRST frame extracted from dive_{i+1}'s rendered video

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

Extract the boundary frames from the rendered dives (not the stills):

ffmpeg -sseof -0.15 -i dive_i.mp4   -frames:v 1 -q:v 2 dive_i_last.png    # interior of i
ffmpeg -ss 0      -i dive_{i+1}.mp4 -frames:v 1 -q:v 2 dive_next_first.png # establishing of i+1

Generate the connector (--duration 5 is plenty). Connectors need --end-image, so the model must accept it — any roster model does (seedance_2_0, seedance_2_0_mini, kling3_0):

higgsfield generate create "$VMODEL" \
  --prompt "$(cat connector_i.txt)" \
  --start-image dive_i_last.png --end-image dive_next_first.png \
  $VOPTS --aspect_ratio 16:9 --duration 5 --wait --json
# seedance: VOPTS="--mode std --resolution 1080p"; kling3_0: VOPTS="--mode std --sound off"

Connector prompt: "Single continuous camera move, no cuts. Pull up and back out of <scene i>, rise into the sky, glide across the connected miniature world, and arrive above <scene i+1>, beginning to descend toward it. Seamless flowing aerial transition.

<style>. No text." (Template in `references/prompts.md`.) Insurance: Seedance lands *close* to the end-image but not always pixel-perfect, so the engine still applies a **short crossfade** (a few frames) at each seam. Frame-matched endpoints + a small crossfade = no visible cut. Never skip the actual-frame handoff and rely on the crossfade alone; a big content jump can't be hidden by a crossfade. --- ## Step 6 — Encode for smooth scrubbing Scrubbing = setting `video.currentTime` from scroll. Two things matter, and they are often gotten wrong: 1. **Seekability, not keyframe density, is what makes scrubbing work.** Many static hosts (and `python -m http.server`) don't serve HTTP byte-range requests, which pins `video.seekable` to `[0,0]` and clamps *every* seek to frame 0 — the video looks frozen. The robust fix is to **fetch each clip as a `Blob` and play it from an in-memory object URL** (blobs are always fully seekable). The engine does this. Because of it, you do **not** need all-intra video. 2. **Don't shrink quality to get smooth seeks.** Encode at the **native resolution** (1080p from Seedance — don't downscale), `crf ~20`, a **small GOP** (`-g 8`) rather than all-intra (all-intra bloats an 8s clip to ~25 MB; GOP 8 is ~8 MB and scrubs fine via blob). Strip audio, add faststart, and a light `unsharp` counters video softness: ```bash ffmpeg -i src.mp4 -an -vf "unsharp=5:5:0.8:5:5:0.0" \ -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \ -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart out.mp4 ``` Encode all 2N-1 clips (dives + connectors) with the same settings for uniform quality. **Mobile encodes (only if the user opted in at Step 1.5).** The mobile version is the **native 9:16 portrait chain** (pipeline.md §6b): portrait renders of every dive and connector, encoded **720 wide (`scale=720:-2`), `-g 4`** (more keyframes = cheaper seeks — phone decoders' seek cost scales with GOP length), crf 23 — wired as `clipMobile` / `connectorsMobile`, with each portrait dive's first frame extracted as the section's `stillMobile` poster (Step 7). The engine serves them automatically on phones and falls back to the desktop clip when absent. The 16:9 centre-crop `encm()` encodes (pipeline.md §6) are a **fallback only** — for when credits can't cover the portrait chain — and shipping them must be called out to the user, never silent. If the user chose desktop-only, skip this — the engine still hardens phone scrubbing regardless (seek-coalescing, iOS priming), so the page degrades gracefully rather than breaking. --- ## Step 7 — Assemble the page Copy `references/scrub-engine.js` (and, if you want a fully standalone page, the tiny `references/index-template.html`) into the user's project — or adapt into their framework. It's config-driven and self-contained: ```js mountScrollWorld(document.getElementById('world'), { brand: { name: 'Pearl & Co.' }, diveScroll: 1.3, connScroll: 0.9, // viewport-heights of scroll per clip sections: [ { id:'farm', label:'The Farms', still:'assets/farm.webp', clip:'assets/vid/farm.mp4', clipMobile:'assets/vid/farm-m.mp4', // mobile opt-in only: native 9:16 render stillMobile:'assets/farm-m.webp', // its first frame as the portrait poster scroll: 1.6, linger: 0.45, // optional pacing: longer dwell + camera settles mid-scene accent:'#8FB98A', eyebrow:'From leaf to last sip', title:'It starts in the hills.', body:'…', tags:['Single-origin','Hand-picked'] }, // …one per section; last may carry a `cta` ], connectors: ['assets/vid/conn1.mp4','assets/vid/conn2.mp4', /* … length = sections-1 */], connectorsMobile: ['assets/vid/conn1-m.mp4','assets/vid/conn2-m.mp4' /* … same length; mobile opt-in only */], }); ``` The engine handles: the ordered dive/connector chain, scroll→currentTime with rAF smoothing, blob loading, lazy prefetch of nearby clips, frame-matched crossfades, pinned per-section copy (first section greets on landing, last holds its CTA), a route rail, `prefers-reduced-motion`, and mobile. **Pacing per section:** `scroll` overrides `diveScroll` for that scene (more scroll = longer dwell) and `linger` (0–1, keep ≤ 0.6) remaps time so the camera settles mid-scene — exactly while the copy peaks — then speeds up toward the seam; seam frames are untouched (f(0)=0, f(1)=1). Give the hero and finale scenes a higher `scroll` + some `linger`; keep transit scenes brisk. Theme it with CSS variables (`--accent`, `--sw-bg`, `--sw-ink`, …) — the visual identity comes from the generated clips, so the chrome stays quiet. See the header of `scrub-engine.js` for the full config + CSS vars. **On phones the engine adapts automatically** (coarse pointer or ≤860px): it serves `clipMobile` / `connectorsMobile` when present, **coalesces seeks** (never queues a new `currentTime` while the decoder is still seeking — this is what stops a fast flick from freezing the clip), **keeps the still as a poster until the clip paints its first frame** and **primes each video on first touch** (fixes iOS's blank-until-played video), drops the drifting particles, ignores URL-bar-only resizes (no scroll jump), and uses safe-area insets so copy clears the notch/home indicator. All of this hardening is on by default — no config needed. The `clipMobile`/`connectorsMobile` encodes are the opt-in part (Step 1.5): only wire them when the user asked for the mobile version. For non-JS backends (Python/Rails/etc.): serve the assets and drop the engine `<script>` into the rendered HTML; nothing about it is framework-specific. --- ## Step 8 — QA the seams (don't skip) Drive the page in a headless browser and **verify frame continuity at the seams**, which is the thing most likely to be wrong: - Screenshot at scroll positions just before and just after each seam. The two frames must be near-identical (the dive's last frame == the connector's first frame). If they pop, you used the diorama still instead of the actual rendered frame (redo Step 5), or the crossfade band is too short. - Check the console for errors, confirm `video.seekable.end(0) > 0` (blob working), and that `currentTime` tracks scroll across each clip's band. - **Mobile — full checklist only if the user opted into the mobile version (Step 1.5).** For a desktop-only build, just sanity-check a phone viewport once: page loads, still posters show, nothing overlaps — the engine's hardening covers graceful degradation. For the mobile build (do this on a real phone or an emulated one, portrait + landscape): - Emulate a phone viewport **with CPU throttled 4–6×** and scroll fast — the clip should track without freezing (the seek-coalescing + `-m.mp4` encodes are what make this hold). - Confirm the first scene shows immediately (its still is the poster) and the video takes over the instant you scroll — no blank/black scene (the iOS priming fix). Test iOS Safari specifically; it's the one that goes blank if this regresses. - Verify the `-m.mp4` variant is actually served on mobile (Network panel), and the heavy 1080p master on desktop. The mobile clips must be **natively portrait** (`videoWidth < videoHeight` — not a downscaled 16:9 file), and the `stillMobile` posters must be served and match each portrait clip's first frame (no landscape→portrait flash when the video paints). - Slowly scroll so the URL bar collapses — the page must **not jump** (height-only resizes are ignored on touch). Rotate the device — layout should recompose cleanly. - Only if the crop **fallback** shipped (no credits for the portrait chain): portrait crops a 16:9 clip to its centre — confirm the focal subject still reads, and remind the user this is the stopgap, not the mobile version. - Check reduced-motion (should fall back to the stills, no video, no particles). --- ## Gotchas (hard-won) - **Seam pop** → connector endpoints were the diorama stills, not the neighbouring clips' actual frames. Always extract real frames (Step 5). - **Seam stutter / camera "jumps backward"** → even with frame-matched seams, if the camera *velocity reverses* (forward dive, then a connector that pulls back out) it reads as a rewind. This is inherent to architecture B. For any grounded walkthrough use architecture A (one continuous forward take — legs chained from actual last frames, no pull-back, no `--end-image`); see Step 4. - **Frozen video / stuck at frame 0** → `seekable=[0,0]`; the host isn't serving byte ranges. Use blob URLs (engine does). - **Huge files** → you used all-intra. Use `-g 8` + blob instead. - **Soft / low quality** → you downscaled or over-compressed. Encode native 1080p, crf ≤ 20, add `unsharp`. Video is inherently softer than the stills — keep the stills as the lite fallback for max fidelity. - **Concurrent gens 503 / "not_enough_credits" race** → transient when many launch at once; re-roll the individual failure, it's not really out of credits (verify with `higgsfield workspace list`). - **NSFW false-positives (Seedance `status "nsfw"`)** → the video content filter flags perfectly innocuous clips, especially **bedroom, pool, spa/wellness** contexts and trigger words like "bed", "pool", "waterfall", "wine", "swim". It's partly the prompt wording and partly the reference frames. Fixes, in order: (1) re-roll — it's often non-deterministic and passes on the 2nd–3rd try; (2) strip trigger words and add "empty, unoccupied, no people, no figures, architectural, tasteful"; (3) regenerate just that clip on **`kling3_0`** with the same start/end frames — a different provider's filter often passes what Seedance blocks. Expect a slight render-character shift on that one clip (each model has its own grain/motion feel); for a 5s connector behind a crossfade that usually beats option (4): set the connector slot to `null` — the engine crossfades that seam directly (optional connectors), so the page still completes. Budget extra credits/time for these re-rolls on interiors/real-estate content. - **Dark / custom theme** → the engine wraps its default tokens in `@layer sw`, so a page-level `:root` / `.sw-root { --sw-bg; --sw-ink; --sw-accent; --sw-font-* }` block wins cleanly (no specificity hacks). `--sw-ink` is your primary **text/heading** colour; the **accent** fills the primary button and active nav. For a dark theme, set `--sw-bg` dark and `--sw-ink` light — the copy scrim and title shadow follow `--sw-bg` automatically. - **Phone scrub stutters / freezes on a fast flick** → the 1080p master is too heavy for a phone decoder and seeks pile up. Ship the `-m.mp4` mobile encodes (720p, `-g 4`) and wire `clipMobile`/`connectorsMobile` (Step 6/7). The engine already coalesces seeks; the lighter encode is the other half. Still choppy on a low-end device? Tighten GOP (`-g 2` / all-intra). - **Blank / black scene on iOS (desktop was fine)** → an iOS Safari quirk: a muted video that was never played won't paint a seeked frame. The engine fixes this by keeping the still as a poster until the clip paints and priming each video on first touch — so **don't** hide the still on `loadedmetadata` or strip the `playsinline`/`muted` attributes if you adapt the engine into a framework. - **Page jumps while scrolling on mobile** → something is re-running layout on the URL-bar show/hide `resize`. The engine ignores height-only resizes on touch; if you ported it, gate your resize handler on a width change (keep the `orientationchange` path for rotation). - **Copy hidden behind the URL bar / notch on mobile** → use the engine's safe-area-aware bottom offset (`env(safe-area-inset-bottom)` + `dvh`); make sure the page's `<meta viewport>` includes `viewport-fit=cover` (the template does). - **Portrait crops the scene** → a 16:9 clip on a tall phone shows only its centre — which is why the mobile version is the native 9:16 chain (§6b), never the crop. If you're seeing this on a mobile build, either the crop fallback shipped (call it out to the user) or the 9:16 encodes aren't actually being served (check `videoWidth < videoHeight`). Keeping each scene's focal subject centred (prompts.md) still matters for the desktop film itself. - **`--generate-audio` errors on seedance** → omit it; mute in HTML and `-an` on encode. - **Kling rejects your flags** → `kling3_0` has **no `--resolution` param** (don't pass one; encode at whatever native res ffprobe reports) and **sound defaults on** — pass `--sound off`. Duration default is 5; legs/dives want 10. - **Seam pop only where you "saved credits"** → you swapped models mid-chain, or used a start-image-only model where a connector needs an `--end-image`. One model for the whole chain; the only cheap tier is `seedance_2_0_mini`, which keeps frame-locking so it stays seamless. (Any model with reference-only inputs can't hold a seam at all — Step 4.) - **White-box scenes** → `gpt_image_2` returns a solid bg; either match the page bg to it or knock it out (Step 3). - **bash 3.2** on macOS → no associative arrays in scripts. - **Connector grabs the wrong scene's frames** (or errors on a frame that doesn't exist yet) → the array loop ran in **zsh** (macOS default interactive shell), where arrays are 1-indexed, not bash's 0-indexed. Keep every array-driven chain step in a `#!/bin/bash` script run via `bash script.sh` — never inline array loops in the interactive shell. ## References - `references/prompts.md` — the intake checklist, style-preamble pattern, and every prompt template (scene still, dive, connector) with fill-in slots. - `references/pipeline.md` — copy-paste batch scripts for the whole run (generate → extract frames → connectors → encode → mobile encode), bash-3.2-safe. - `references/scrub-engine.js` — the portable, config-driven scrub engine (builds DOM + injects CSS; blob-seek, lazy load, seam crossfade, copy, route rail, reduced-motion, and phone hardening: mobile encodes, seek-coalescing, iOS priming, safe-area, no-jump resize). - `references/index-template.html` — a minimal standalone page that mounts the engine. - `references/knockout.py` — border-connected background knockout for floating scenes.

附带文件

references/index-template.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
  <title>BRAND — the world of SUBJECT</title>
  <meta name="description" content="Scroll to fly through the world of BRAND." />
  <style>
    /* Theme the engine here. These match your generated scenes' background + palette. */
    :root, .sw-root {
      --sw-bg: #F5EDE0;          /* == the scene background colour */
      --sw-ink: #241d2b;
      --sw-ink-soft: #6a6072;
      --sw-accent: #9B7EBD;      /* per-section accents override this at runtime */
    }
  </style>
</head>
<body>
  <div id="top"></div>
  <div id="world"></div>

  <script src="scrub-engine.js"></script>
  <script>
    mountScrollWorld(document.getElementById('world'), {
      brand: { name: 'BRAND', href: '#top' },
      cta: { label: 'Order now', href: '#finale' },
      hint: 'scroll to fly in',
      diveScroll: 1.3,
      connScroll: 0.9,
      sections: [
        // One object per scene, in order. `accent` colours the copy/route for that scene.
        // First section is the hero (its copy greets on landing); last carries the CTA.
        {
          id: 'sceneA', label: 'Scene A',
          still: 'assets/sceneA.webp',
          clip: 'assets/vid/sceneA.mp4',
          clipMobile: 'assets/vid/sceneA-m.mp4',   // mobile opt-in only: lighter 720p encode served on phones
          accent: '#8FB98A',
          // scroll: 1.6, linger: 0.45,  // optional pacing: longer dwell; camera settles mid-scene
          eyebrow: 'Value prop label',
          title: 'The hero line.',
          body: 'One plain-spoken sentence about this stage.',
          tags: ['Proof', 'Proof', 'Proof'],
        },
        // …middle scenes…
        {
          id: 'finale', label: 'The Product',
          still: 'assets/product.webp',
          clip: 'assets/vid/finale.mp4',
          clipMobile: 'assets/vid/finale-m.mp4',
          accent: '#9B7EBD',
          eyebrow: 'And it all pours into',
          title: 'One perfect thing.',
          body: 'The payoff sentence.',
          tags: [],
          cta: { primary: { label: 'Get started', href: '#' },
                 secondary: { label: 'See more', href: '#' } },
        },
      ],
      // length === sections.length - 1, in order. Generated per SKILL.md Step 5.
      connectors: [
        'assets/vid/conn1.mp4',
        // 'assets/vid/conn2.mp4', …
      ],
      // Mobile opt-in only: lighter connectors for phones — same length/order as `connectors`.
      connectorsMobile: [
        'assets/vid/conn1-m.mp4',
        // 'assets/vid/conn2-m.mp4', …
      ],
    });
  </script>
</body>
</html>
references/knockout.py
#!/usr/bin/env python3
"""Border-connected background knockout for diorama stills.

Removes the flat background of a scene image (leaving the diorama floating on
transparency) via a flood fill from the borders over pixels near the corner
colour. Interior regions that happen to match the background (e.g. cream walls
inside the scene) are preserved because the fill only reaches border-connected
pixels. The soft contact shadow is kept as a natural base.

Usage:
    python3 knockout.py scene1.png scene2.png ...
    # writes scene1.rgba.png, scene2.rgba.png, ...
    # optional: TOL env var (default 34) widens/narrows the bg match

Then encode to webp with alpha, e.g.:
    cwebp -q 84 -alpha_q 95 -resize 1800 0 scene1.rgba.png -o scene1.webp

No numpy/ImageMagick needed — pure PIL. Pairs with SKILL.md Step 3.
"""
import os
import sys
from collections import deque
from PIL import Image, ImageFilter

TOL = float(os.environ.get("TOL", "34"))   # RGB Euclidean distance counted as background


def corner_color(im):
    w, h = im.size
    px = im.load()
    pts = [(1, 1), (w - 2, 1), (1, h - 2), (w - 2, h - 2)]
    r = sum(px[x, y][0] for x, y in pts) // 4
    g = sum(px[x, y][1] for x, y in pts) // 4
    b = sum(px[x, y][2] for x, y in pts) // 4
    return (r, g, b)


def knock(inp, outp):
    im = Image.open(inp).convert("RGB")
    w, h = im.size
    px = im.load()
    cr, cg, cb = corner_color(im)
    tol2 = TOL * TOL

    def is_bg(x, y):
        r, g, b = px[x, y]
        dr, dg, db = r - cr, g - cg, b - cb
        return dr * dr + dg * dg + db * db <= tol2

    bg = bytearray(w * h)
    seen = bytearray(w * h)
    dq = deque()
    for x in range(w):
        for y in (0, h - 1):
            i = y * w + x
            if not seen[i] and is_bg(x, y):
                seen[i] = 1; bg[i] = 1; dq.append((x, y))
    for y in range(h):
        for x in (0, w - 1):
            i = y * w + x
            if not seen[i] and is_bg(x, y):
                seen[i] = 1; bg[i] = 1; dq.append((x, y))
    while dq:
        x, y = dq.popleft()
        for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
            if 0 <= nx < w and 0 <= ny < h:
                i = ny * w + nx
                if not seen[i]:
                    seen[i] = 1
                    if is_bg(nx, ny):
                        bg[i] = 1; dq.append((nx, ny))

    alpha = Image.new("L", (w, h), 255)
    ap = alpha.load()
    for y in range(h):
        base = y * w
        for x in range(w):
            if bg[base + x]:
                ap[x, y] = 0
    alpha = alpha.filter(ImageFilter.GaussianBlur(1.4))   # soften the threshold contour
    out = im.convert("RGBA")
    out.putalpha(alpha)
    out.save(outp)
    print("knocked", outp)


if __name__ == "__main__":
    for name in sys.argv[1:]:
        knock(name, name.rsplit(".", 1)[0] + ".rgba.png")
references/pipeline.md
# Pipeline: copy-paste scripts (bash 3.2 safe)

Set these once. `NAMES` is the ordered section ids; the last is the hero/finale.

```bash
WORK=/tmp/scroll-world           # scratch dir for prompts, sources, frames
ASSETS=./assets                  # where the site reads stills (webp) + clips (mp4)
mkdir -p "$WORK" "$ASSETS/vid"
NAMES="farm kitchen shop delivery plaza finale"   # <-- your section ids, in order

# Chain video model — ONE for every chained clip (SKILL Step 4 roster).
# Must accept --start-image AND --end-image (verify: higgsfield model get <model>):
# seedance_2_0 | kling3_0 | seedance_2_0_mini (draft tier). Reference-only models can't
# hold a seam; models without --mode (e.g. kling3_0_turbo) need their own flag branch below.
VMODEL=seedance_2_0
case "$VMODEL" in                                  # per-model flags + durations (bash 3.2 safe)
  kling3_0)          VOPTS="--mode std --sound off";          DIVE_DUR=10; CONN_DUR=5 ;;  # no --resolution param on Kling
  seedance_2_0_mini) VOPTS="--mode std --resolution 720p";    DIVE_DUR=8;  CONN_DUR=5 ;;  # cheap frame-locked previz
  *)                 VOPTS="--mode std --resolution 1080p";   DIVE_DUR=8;  CONN_DUR=5 ;;  # seedance_2_0 default
esac
```

Higgsfield generations take minutes — every `higgsfield ... --wait` call below is meant
to run inside a **backgrounded** script. Launch the whole script with your tool's
background/detached mode and poll the progress log; never block the foreground.

## 1. Scene stills (Step 2)

Write one prompt file per section to `$WORK/still_<name>.txt` (see prompts.md), then:

```bash
gen_still() { # name
  higgsfield generate create gpt_image_2 --prompt "$(cat "$WORK/still_$1.txt")" \
    --aspect_ratio 3:2 --resolution 2k --quality high --wait --wait-timeout 15m --json \
    > "$WORK/still_$1.json" 2> "$WORK/still_$1.err"
  url=$(jq -r '.[0].result_url // empty' "$WORK/still_$1.json")
  [ -n "$url" ] && curl -fsSL "$url" -o "$WORK/still_$1.png" && echo "still $1 ok" || echo "still $1 FAIL"
}
for n in $NAMES; do gen_still "$n" & done ; wait
```

Codex variant (STILLS_SOURCE=codex, SKILL Step 1.6 — subscription-billed, zero
credits; ~1–3 min each, parallelize in small batches):

```bash
gen_still_codex() { # name
  codex exec -C "$WORK" -s workspace-write --skip-git-repo-check \
    'Use the image generation tool ($imagegen) to generate: '"$(cat "$WORK/still_$1.txt")"' Wide 3:2 landscape, high resolution. Save it as ./still_'"$1"'.png. Do not do anything else.' \
    > "$WORK/still_$1.codex.log" 2>&1
  [ -f "$WORK/still_$1.png" ] && echo "still $1 ok (codex)" || echo "still $1 FAIL (see .codex.log)"
}
```

Convert to webp for the site (and optionally run knockout.py first for transparency):

```bash
for n in $NAMES; do cwebp -quiet -q 84 -resize 1800 0 "$WORK/still_$n.png" -o "$ASSETS/$n.webp"; done
```

Review the stills for cohesion before continuing. Re-roll any off-style one (optionally
add `--image "$WORK/still_<good>.png"` to lock style).

## 2. Dive-in clips (Step 4)

Prompt files at `$WORK/dive_<name>.txt`. Start image = the solid-bg still PNG.

```bash
gen_dive() { # name                       ($VOPTS is unquoted on purpose — word-split flags)
  higgsfield generate create "$VMODEL" --prompt "$(cat "$WORK/dive_$1.txt")" \
    --start-image "$WORK/still_$1.png" \
    $VOPTS --aspect_ratio 16:9 --duration "$DIVE_DUR" \
    --wait --wait-timeout 20m --json > "$WORK/dive_$1.json" 2> "$WORK/dive_$1.err"
  url=$(jq -r '.[0].result_url // empty' "$WORK/dive_$1.json")
  [ -n "$url" ] && curl -fsSL "$url" -o "$WORK/dive_$1.mp4" && echo "dive $1 ok" || echo "dive $1 FAIL"
}
for n in $NAMES; do gen_dive "$n" & done ; wait
```

Re-roll individual failures (503 / credit race are transient):
`gen_dive shop`  (just that one).

## 3. Extract boundary frames — the seam handoff (Step 5)

For each adjacent pair, the connector's start = dive_i's LAST frame, end = dive_{i+1}'s
FIRST frame — extracted from the **rendered videos**, never the stills.

```bash
set -- $NAMES
prev=""
for n in "$@"; do
  ffmpeg -v error -ss 0 -i "$WORK/dive_$n.mp4" -frames:v 1 -q:v 2 "$WORK/first_$n.png"      # establishing
  ffmpeg -v error -sseof -0.15 -i "$WORK/dive_$n.mp4" -frames:v 1 -q:v 2 "$WORK/last_$n.png" # interior
done
```

## 4. Connector clips (Step 5)

Prompt files at `$WORK/conn_<i>.txt` (i = 1..N-1). Iterate adjacent pairs:

```bash
gen_conn() { # i startPng endPng          (end-image required → seedance/kling3_0 only)
  higgsfield generate create "$VMODEL" --prompt "$(cat "$WORK/conn_$1.txt")" \
    --start-image "$2" --end-image "$3" \
    $VOPTS --aspect_ratio 16:9 --duration "$CONN_DUR" \
    --wait --wait-timeout 20m --json > "$WORK/conn_$1.json" 2> "$WORK/conn_$1.err"
  url=$(jq -r '.[0].result_url // empty' "$WORK/conn_$1.json")
  [ -n "$url" ] && curl -fsSL "$url" -o "$WORK/conn_$1.mp4" && echo "conn $1 ok" || echo "conn $1 FAIL"
}
set -- $NAMES ; i=0 ; prev=""
for n in "$@"; do
  if [ -n "$prev" ]; then i=$((i+1)); gen_conn "$i" "$WORK/last_$prev.png" "$WORK/first_$n.png" & fi
  prev="$n"
done ; wait
```

## 5. Encode everything for scrubbing (Step 6)

Native resolution (1080p from seedance std; kling3_0 std returned **720p** in testing —
never upscale, encode what ffprobe reports), crf 20, GOP 8, light sharpen, no audio,
faststart. Same for dives + connectors.

```bash
enc() { ffmpeg -v error -y -i "$1" -an -vf "unsharp=5:5:0.8:5:5:0.0" \
  -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
  -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart "$2"; echo "enc $2 $(du -h "$2"|cut -f1)"; }

for n in $NAMES; do enc "$WORK/dive_$n.mp4" "$ASSETS/vid/$n.mp4"; done
i=0; for f in "$WORK"/conn_*.mp4; do i=$((i+1)); enc "$f" "$ASSETS/vid/conn$i.mp4"; done
```

Now the engine config's `sections[k].clip = assets/vid/<name>.mp4` and
`connectors = [assets/vid/conn1.mp4, …]` (length N-1, in order).

## 6. Centre-crop mobile encodes — FALLBACK ONLY, not the mobile version

**The mobile version is the native 9:16 portrait chain (§6b).** This section's crop
encodes exist for one case: the user opted into mobile but credits can't cover the
portrait chain — and shipping them must be called out and approved, never silent
(portrait phones will see the landscape film's centre ~26%). The encode mechanics
matter either way: scrubbing sets `currentTime` every frame, and a phone decoder's
**seek cost scales with how many frames it must decode from the nearest keyframe** — so
a 1080p `-g 8` master that scrubs fine on a laptop stutters on a phone. A **smaller
frame + tighter GOP** fixes that (and halves the bytes on cellular). The crop `-m.mp4`
sibling per clip:

```bash
# 720p, GOP 4 (twice the keyframes = ~half the seek-decode work), crf 23, same sharpen/faststart.
encm() { ffmpeg -v error -y -i "$1" -an -vf "scale=-2:720,unsharp=5:5:0.6:5:5:0.0" \
  -c:v libx264 -preset slow -crf 23 -pix_fmt yuv420p \
  -g 4 -keyint_min 4 -sc_threshold 0 -movflags +faststart "$2"; echo "encm $2 $(du -h "$2"|cut -f1)"; }

for n in $NAMES; do encm "$WORK/dive_$n.mp4" "$ASSETS/vid/$n-m.mp4"; done
i=0; for f in "$WORK"/conn_*.mp4; do i=$((i+1)); encm "$f" "$ASSETS/vid/conn$i-m.mp4"; done
```

Wire the variants in the engine config — the engine serves them automatically on phones,
falling back to the desktop `clip` when a mobile one is absent:

```js
sections[k].clipMobile = 'assets/vid/<name>-m.mp4';
connectorsMobile = ['assets/vid/conn1-m.mp4', …];   // length N-1, in order
```

If phone scrubbing still stutters, tighten the GOP further (`-g 2`, or `-g 1` for all-intra
= instant seeks at the cost of larger files); if cellular weight is the bigger worry, raise
`crf` (24–26) or drop to `scale=-2:600`. If the master is already 720p (e.g. kling3_0 std),
the mobile encode still pays off — the tighter GOP is what makes phone seeks cheap. All-mobile encodes stay 16:9 — the engine
centre-crops them; see the portrait note in SKILL Step 8 / prompts.md.

## 6b. Native 9:16 portrait chain — THE mobile version (Step 1.5 opt-in)

When the user opts into mobile, this is what they get: a **parallel 9:16 chain** rendered
natively for phones and shipped as the mobile variants — never the §6 crops (those are the
no-credits stopgap). Same seam laws as the main chain — the portrait chain frame-locks
against its own rendered frames, never the landscape ones. Budget ~2N-1 video gens +
re-rolls (interiors trip the NSFW filter in portrait too); state the credit cost at the
Step 1.5 interview.

1. **Portrait start canvases.** Don't hand the video model a 3:2 still and hope: composite
   each scene onto a 1080×1920 canvas in the page bg colour (island at ~94% width, visual
   centre at ~45% height). The render then opens exactly on what the portrait poster shows.
   For knocked-out stills, composite the RGBA over the bg colour first.
2. **Dives/legs**: same prompt templates with a portrait clause up front ("Vertical
   portrait composition, the diorama centered with generous [bg] space above and below"),
   `--aspect_ratio 9:16`, same model/params as the main chain. Review each last frame
   before chaining, as ever.
3. **Connectors**: extract first/last frames **from the 9:16 renders** and generate 9:16
   connectors between them. A native 9:16 scene mixed into cropped-16:9 neighbours pops at
   both seams — the portrait chain must be complete, not partial.
4. **Encode** with the §6 settings but portrait-oriented scale: `scale=720:-2` (720 wide),
   `-g 4`, crf 23 → these ARE the `-m.mp4` mobile files (and they replace any §6 crop
   stopgaps that shipped earlier).
5. **Posters**: extract each 9:16 dive's first frame → webp → wire as the section's
   `stillMobile` so the poster matches the portrait video's frame 0 (no landscape→portrait
   flash when the clip paints). Engine support: `sections[k].stillMobile`.

## Notes

- `.[0].result_url` is the field on the `--wait --json` job object. `.min_result_url` is
  a lower-res preview if you ever want it.
- **NSFW fallback across models**: if one clip keeps getting flagged on seedance after
  re-rolls + prompt scrubbing, regenerate just that clip on `kling3_0` with the SAME
  start/end frames: `VMODEL=kling3_0; VOPTS="--mode std --sound off"; gen_conn 3 …` —
  then restore your chain model. See SKILL Gotchas for the trade-off.
- **Previz on the cheap**: run the whole chain once with `VMODEL=seedance_2_0_mini`
  (frame-locking intact, ~720p) to validate the journey and seams before spending
  full-model credits — because it's still seamless, the previz translates directly to the
  final render. Don't reach for reference-only models here: without `--start/--end-image`
  they can't hold a seam, so their output can't be chained (Step 4 rule).
- If a whole batch stalls, check `higgsfield workspace list` for credits and
  `$WORK/*.err` for the reason.
- Concurrency: launching ~5–6 gens at once is fine; much more can trigger transient
  credit/race errors — stagger or re-roll.
references/prompts.md
# Prompt templates & intake

Everything here is fill-in-the-slots. Keep the **style preamble** byte-for-byte identical
across all scene stills — that identical text is what makes the world feel like one place.

## Intake checklist (Step 1)

Collect and write down:

- `SUBJECT` — the business + one-line pitch.
- `BRAND_NAME` — display name.
- `PALETTE` — 4–6 named hexes, e.g. `taro #9B7EBD, cream #F5EDE0, caramel #C88A5A, matcha #8FB98A, plum #3A2E48`. Pick ONE as the scene **background** colour (usually the lightest) and one as the primary **accent**.
- `TONE` — a word or two (cozy/premium, playful, industrial…).
- `STYLE` — the art direction (default below).
- `SECTIONS[]` — ordered list; for each: `id`, `label`, `subject` (what's in the diorama), `eyebrow`, `title`, `body` (≤ 1 sentence), `tags[]` (0–3). Last section = hero product + CTA.
- `MOBILE` — yes/no. **Always asked** (SKILL Step 1.5), presented to the user
  with the ~2× credit cost stated.
- `VIDEO_TIER` — draft (`seedance_2_0_mini`) | standard (`seedance_2_0`, default) |
  alternate (`kling3_0`). Chosen by cost at SKILL Step 1.6, with the calibrated
  total estimate stated before anything renders.
- `STILLS_SOURCE` — higgsfield (`gpt_image_2`, spends credits) | codex
  (`image_gen`, subscription-billed; only offer when the Codex CLI is present). Yes = the **native 9:16 portrait chain** (pipeline §6b):
  portrait renders of every dive/connector + `clipMobile`/`connectorsMobile`/`stillMobile`
  wiring + the full mobile QA. The §6 crop encodes are a no-credits stopgap only.

## Style preamble (default: clay diorama)

Reuse verbatim in every scene prompt. Swap the bracketed bits for the brand's palette/bg.

```
Isometric low-poly 3D diorama floating as a small rounded island on a plain solid
[BG_HEX] background with a soft contact shadow beneath it. Soft matte clay 3D render,
rounded toy-model shapes, gentle warm studio lighting, soft long shadows, tilt-shift
miniature look. Cohesive color palette of [PALETTE]. Highly detailed, centered
composition, absolutely no text, no letters, no numbers, no logos.
```

Alternate directions (swap the first two sentences, keep the palette/no-text tail):
- **Flat papercraft:** "Isometric layered paper-craft diorama, matte cardstock, clean die-cut edges, subtle drop shadows between layers."
- **Glossy toy:** "Isometric glossy vinyl-toy diorama, smooth plastic shading, soft rim light, collectible figurine look."
- **Claymation:** "Isometric stop-motion clay set, visible thumbprints, handmade plasticine texture, soft studio softbox light."
- **Neon night:** "Isometric miniature at night, warm interior glow and neon signage, moody rim light, wet reflective ground."
- **Photoreal architectural** (real estate, hospitality, premium/luxury): "Ultra-photorealistic architectural photography of a single cohesive [subject], cinematic wide-angle, warm golden-hour light, natural materials, restrained designer furnishings, a breathtaking view, editorial magazine quality (Architectural Digest), shallow depth of field, no people." For photoreal, drop the floating-island framing and the knockout (Step 3) — the scenes are **full-bleed** (a dark page background reads premium), the "dive" glides *through doorways/glass* rather than opening a roof, and cohesion comes entirely from the identical preamble (do NOT pass an `--image` reference — it clones the same room). Interiors trip Seedance's NSFW filter often; see SKILL Gotchas.

## Scene still prompt (Step 2)

```
[STYLE PREAMBLE]
Subject: [SECTION.subject — describe the miniature scene: the building/space, a few
characters doing the work, the props that signal this stage of the business].
```

Tips:
- Name concrete props (they anchor the scene): tanks, cauldrons, conveyor, crates, awning, string lights, benches, scooters, map pins.
- For the final "hero product" section, drop the diorama-island framing and prompt a
  single oversized product centerpiece floating on the same background with a few small
  orbiting props.
- **Compose for the centre.** The page renders every clip `object-fit:cover`. Keep the
  focal subject horizontally centred with a little headroom, and don't park anything
  essential at the far left/right edges. Mobile ships its own native 9:16 chain
  (pipeline §6b), so this is not about surviving a crop — but a centred composition makes
  the portrait renders open cleanly from the same still, and it keeps the dive's focal
  point where the camera actually flies.
- Aspect `3:2`, `--resolution 2k --quality high`.

## Leg prompt — architecture A, continuous forward take (Step 4)

`--start-image = previous leg's ACTUAL last frame` (leg 0: the first scene's still).
**No `--end-image`.** The bolded clauses are the motion-handoff contract — keep them
verbatim; the mid-leg move is where the expression goes.

```
Single continuous cinematic camera move, no cuts. **Continue the same slow, steady
forward glide.** [MID-LEG MOVE — optional, from the library below.] The camera moves
into [SCENE i] toward [FOCAL POINT]. **In the final second, settle back into a slow,
steady forward glide toward [the doorway / opening / direction of the next scene].**
[STYLE tail + PALETTE]. Smooth, graceful, slow motion, subtle parallax. No text, no captions.
```

### Mid-leg move library (pick by concept; omit for a plain glide)

Reversals are safe *inside* a leg (it's one continuous render) — only a seam may never
reverse. That's why "ease back out" is fine mid-leg.

- **Half-orbit** (product, luxury): "sweeping in a slow half-orbit around [the hero
  object], keeping it centered, then continuing past it"
- **Crane-up reveal** (scale, atriums, campuses): "rising smoothly as the full scale of
  [the space] reveals below"
- **Low lateral track** (production lines, counters, shelves): "tracking low and level
  alongside [the line], foreground objects sliding past in parallax"
- **Push-in + ease back** (craft, detail): "pushing in close to [the craft moment] until
  it nearly fills the frame, then easing gently back out"
- **Rise-and-swoop** (travel, outdoors): "climbing in a gentle arc over [the terrain],
  then swooping down toward [the next focal point]"

After rendering each leg, **check its last frame** before generating the next: it should
read as a frame from a calm forward glide (no motion blur sideways, no half-finished
orbit). If it doesn't, re-roll this leg — a bad handoff frame poisons every leg after it.

## Dive-in clip prompt (Step 4)

`--start-image = the scene still` (solid-bg version).

```
Single continuous cinematic camera move, no cuts. Begin high and far, looking down at the
whole [SECTION.subject] from outside like a tiny model. The camera slowly glides forward
and descends toward it, sweeping in toward [FOCAL POINT — the counter/the cauldrons/the
people], as if flying inside. As the camera pushes in, the roof and upper structure
gently lift and open away to reveal the warm interior. [STYLE tail: soft matte clay
diorama, tilt-shift miniature, warm light, [PALETTE]]. Smooth, graceful, slow motion,
subtle parallax. No text, no captions.
```

For scenes with no building to open (a field, a plaza, a road), replace the roof clause
with "the camera flies low across [the scene] toward [focal point]."

Params by chain model (SKILL Step 4 table): seedance —
`--mode std --resolution 1080p --aspect_ratio 16:9 --duration 8`, no audio flag;
kling3_0 — `--mode std --sound off --aspect_ratio 16:9 --duration 10` (no `--resolution`
param). Same for architecture-A legs.

## Connector clip prompt (Step 5)

`--start-image = dive_i LAST frame` (extracted), `--end-image = dive_{i+1} FIRST frame`
(extracted). Both from the RENDERED videos, not the stills.

```
Single continuous cinematic camera move, no cuts. The camera smoothly pulls up and back
out of [SCENE i], rising into the sky, then glides forward across the connected miniature
world and arrives above [SCENE i+1], beginning to descend toward it. One connected
miniature clay world, seamless flowing aerial transition. [STYLE tail + PALETTE]. Smooth
graceful slow motion. No text, no captions.
```

For the last connector into a hero-product finale: "…glides forward and the world
dissolves toward a single giant [PRODUCT] floating in soft [BG] space, arriving in front
of it."

seedance: `--mode std --resolution 1080p --aspect_ratio 16:9 --duration 5`; kling3_0:
`--mode std --sound off --aspect_ratio 16:9 --duration 5`. Connectors need `--end-image`
→ use a roster model that accepts it (Step 4).

## Copy per section (for the engine config)

- `eyebrow` — 2–4 words, uppercase feel (a value-prop label).
- `title` — 3–6 words, the beat's headline. First section = the site's hero line; last =
  the payoff + it carries the CTA.
- `body` — one sentence, plain-spoken, from the visitor's side.
- `tags` — 0–3 short proof chips (e.g. "Fresh-cooked", "30-min delivery").
references/scrub-engine.js
/* ============================================================================
   scroll-world — portable scroll-scrubbed camera-flight engine
   ----------------------------------------------------------------------------
   Framework-agnostic. Vanilla JS, zero dependencies. It builds its own DOM and
   injects its own (namespaced) CSS into a container you give it, so it drops into
   plain HTML, Next.js (call from a ref/useEffect), Vue (onMounted), a server-
   rendered page, anything.

   USAGE
     mountScrollWorld(document.getElementById('world'), {
       brand: { name: 'Pearl & Co.', href: '#top' },
       diveScroll: 1.3,   // viewport-heights of scroll per dive clip
       connScroll: 0.9,   // ...per connector clip
       hint: 'scroll to fly in',
       nav: true,         // show the top section nav
       atmosphere: true,  // subtle gradient + drifting particles behind the clips
       sections: [
         { id, label, still, stillMobile, clip, clipMobile, accent,
           scroll: 1.6,   // optional per-section override of diveScroll — more scroll
                          // distance = a slower, longer dwell in this scene
           linger: 0.5,   // optional 0..1 — remaps time so the camera settles mid-scene
                          // (exactly where the copy peaks) and moves quicker at the
                          // edges. 0 = linear (default). Keep ≤ 0.6; 1 = full pause.
           eyebrow, title, body, tags:[…],
           cta:{ primary:{label,href}, secondary:{label,href} } }, // last section only
         …
       ],
       connectors: [clipUrl, …],          // length = sections.length - 1 (nulls allowed)
       connectorsMobile: [clipUrl, …],    // optional lighter connectors for phones (same length)

   MOBILE (the clipMobile/connectorsMobile variants are the opt-in mobile version;
   the rest of the phone handling below is always on)
     The engine is phone-aware out of the box: on a coarse-pointer / ≤860px viewport it
       - loads `clipMobile` / `connectorsMobile` when provided (encode these smaller +
         tighter-GOP — seek cost on a phone decoder is dominated by frames-from-keyframe,
         so a 720p, -g 4 file scrubs far smoother than the 1080p desktop master; see
         pipeline.md). Falls back to the desktop `clip` if no mobile variant is given.
       - uses `stillMobile` as the scene poster when provided (pair it with native 9:16
         clipMobile renders so the poster matches the portrait video's first frame instead
         of flashing from a landscape crop). Chosen once at mount; a desktop resize into
         phone width keeps the desktop poster (clips still switch via isMobile()).
       - coalesces seeks (never issues a new currentTime while the decoder is still
         `seeking`) so fast flicks can't pile up and freeze the video.
       - keeps the still as a live poster until the clip actually paints its first frame,
         and primes each video (muted play→pause) on first touch — this is what stops iOS
         from showing a blank scene before the first seek.
       - drops the drifting particles and ignores URL-bar-only resizes (no scroll jump).
     Nothing here is required — a config with only `clip`/`connectors` still works on
     phones; the mobile variants just make it lighter and smoother.

   THEME (CSS custom properties; set on the container or :root to override)
     --sw-bg         page background (match your scene bg for seamless posters)
     --sw-ink        primary text
     --sw-ink-soft   secondary text
     --sw-accent     default accent (each section overrides via its `accent`)
     --sw-font-display / --sw-font-body

   REQUIREMENTS ON YOUR ASSETS
     - clips encoded native-res, crf~20, -g 8, +faststart, no audio (see pipeline.md)
     - connectors' endpoints are the neighbouring dives' ACTUAL frames (see SKILL Step 5)
     - (optional) mobile variants at ~720p, -g 4 for smoother phone scrubbing
   The engine loads each clip as a Blob (always seekable) and scrubs currentTime; it does
   NOT depend on HTTP byte-range support.
   ========================================================================== */

function mountScrollWorld(container, config) {
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  // Phone detection. `coarse` is captured once (input type doesn't change mid-session);
  // the ≤860px query is read live via isMobile() so a desktop resize/DevTools toggle
  // switches sources and seek behaviour without a reload.
  const coarse = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
  const smallMQ = window.matchMedia('(max-width: 860px)');
  const isMobile = () => coarse || smallMQ.matches;
  const SECTIONS = config.sections || [];
  const CONNECTORS = config.connectors || [];
  const CONNECTORS_M = config.connectorsMobile || [];
  const DIVE_W = config.diveScroll || 1.3;
  const CONN_W = config.connScroll || 0.9;
  const CROSSFADE = (config.crossfade != null) ? config.crossfade : 0.12;  // seam dissolve width (vh)
  const N = SECTIONS.length;
  if (!N) return;

  injectCSS();
  container.classList.add('sw-root');

  // ---- build the interleaved segment chain: dive0, conn0, dive1, … diveN-1 ----
  const SEGMENTS = [];
  SECTIONS.forEach((s, i) => {
    const dive = { kind: 'dive', si: i, clip: s.clip, clipM: s.clipMobile, still: s.still, stillM: s.stillMobile,
                   accent: s.accent, w: s.scroll || DIVE_W, linger: s.linger || 0 };
    SEGMENTS.push(dive);
    s._seg = dive;
    // A connector is optional: if connectors[i] is falsy, the two dives simply
    // crossfade directly (no fly-over). Lets a page complete even when a
    // connector can't be generated (e.g. a content-filter false-positive).
    if (i < N - 1 && CONNECTORS[i]) {
      SEGMENTS.push({ kind: 'conn', si: i, clip: CONNECTORS[i], clipM: CONNECTORS_M[i],
                      still: SECTIONS[i + 1].still, stillM: SECTIONS[i + 1].stillMobile,
                      accent: SECTIONS[i + 1].accent, w: CONN_W });
    }
  });
  const NSEG = SEGMENTS.length;

  // ---- DOM ----
  const sky = el('div', 'sw-sky');
  if (config.atmosphere !== false) {
    sky.appendChild(el('div', 'sw-sky__grad'));
    sky.appendChild(el('div', 'sw-sky__glow'));
  }
  const particles = el('div', 'sw-particles'); sky.appendChild(particles);

  const scrollbar = el('div', 'sw-scrollbar');
  const scrollbarFill = el('span'); scrollbar.appendChild(scrollbarFill);

  const topbar = el('div', 'sw-topbar');
  if (config.brand) {
    const brand = el('a', 'sw-brand'); brand.href = (config.brand.href || '#');
    brand.appendChild(el('span', 'sw-brand__mark'));
    const nm = el('span', 'sw-brand__name'); nm.textContent = config.brand.name || ''; brand.appendChild(nm);
    topbar.appendChild(brand);
  }
  const nav = el('nav', 'sw-nav'); if (config.nav !== false) topbar.appendChild(nav);
  if (config.cta && config.cta.label) {
    const c = el('a', 'sw-topcta'); c.href = config.cta.href || '#'; c.textContent = config.cta.label;
    topbar.appendChild(c);
  }

  const stage = el('div', 'sw-stage');
  const copylayer = el('div', 'sw-copylayer');
  const route = el('div', 'sw-route');
  const hint = el('div', 'sw-hint');
  const hintText = el('span'); hintText.textContent = config.hint || 'scroll'; hint.appendChild(hintText);
  hint.appendChild(el('i'));
  const track = el('div', 'sw-track');

  [sky, scrollbar, topbar, stage, copylayer, route, hint, track].forEach(n => container.appendChild(n));

  // segment scenes
  SEGMENTS.forEach(s => {
    const scene = el('div', 'sw-scene'); scene.style.setProperty('--sw-accent', s.accent || '');
    const img = el('img', 'sw-scene__still'); img.alt = ''; img.decoding = 'async'; img.loading = 'lazy';
    const poster = (isMobile() && s.stillM) ? s.stillM : s.still;
    if (poster) img.src = poster;
    scene.appendChild(img); stage.appendChild(scene);
    s.el = scene; s.img = img; s.video = null; s.hasClip = false;
    s.loading = false; s.ready = false; s.cur = 0; s.target = 0; s.visible = false;
  });

  // per-section copy / route / nav
  const copies = [], dots = [];
  SECTIONS.forEach((s, i) => {
    const c = el('article', 'sw-copy'); c.style.setProperty('--sw-accent', s.accent || '');
    c.innerHTML =
      `<span class="sw-copy__num">${pad(i + 1)} / ${pad(N)}</span>` +
      (s.eyebrow ? `<span class="sw-copy__eyebrow">${esc(s.eyebrow)}</span>` : '') +
      (s.title ? `<h2 class="sw-copy__title">${esc(s.title)}</h2>` : '') +
      (s.body ? `<p class="sw-copy__body">${esc(s.body)}</p>` : '') +
      (s.tags && s.tags.length ? `<ul class="sw-copy__tags">${s.tags.map(t => `<li>${esc(t)}</li>`).join('')}</ul>` : '') +
      (s.cta ? `<div class="sw-copy__cta">${ctaBtns(s.cta)}</div>` : '');
    copylayer.appendChild(c); copies.push(c);

    const dot = el('button', 'sw-route__dot'); dot.style.setProperty('--sw-accent', s.accent || '');
    dot.innerHTML = `<span class="sw-route__label">${esc(s.label || '')}</span><i></i>`;
    dot.addEventListener('click', () => jumpTo(i)); route.appendChild(dot); dots.push(dot);

    if (config.nav !== false) {
      const b = el('button', 'sw-nav__item'); b.textContent = s.label || '';
      b.addEventListener('click', () => jumpTo(i)); nav.appendChild(b);
    }
  });

  // ---- math ----
  const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
  const smooth = x => { x = clamp(x); return x * x * (3 - 2 * x); };
  // Per-section dwell: monotone remap of scroll→time so the camera settles mid-scene
  // (where the copy peaks) and moves quicker near the seams. L=0 linear, L=1 full
  // mid-scene pause. f(0)=0, f(1)=1 always, so seam frames are untouched.
  const lingerEase = (x, L) => { L = clamp(L); const c = x - 0.5; return (1 - L) * x + L * (4 * c * c * c + 0.5); };
  let vh = window.innerHeight, stageX = 0, totalW = 0, activeIndex = -1, ticking = false;
  let laidOutW = window.innerWidth;   // width the current layout was computed at (see onResize)

  function layout() {
    vh = window.innerHeight;
    laidOutW = window.innerWidth;
    stageX = window.innerWidth > 860 ? 4 : 0;
    let off = 0;
    SEGMENTS.forEach(s => { s.start = off * vh; off += s.w; s.end = off * vh; });
    totalW = off;
    track.style.height = (totalW * vh + vh) + 'px';   // +1vh so the last flight completes
    read();
  }

  function jumpTo(i) {
    const seg = SECTIONS[i]._seg;
    window.scrollTo({ top: seg.start + (seg.end - seg.start) * 0.5, behavior: reduce ? 'auto' : 'smooth' });
  }

  function loadClip(s) {
    // Under prefers-reduced-motion we never load the clips at all — the stills stay up
    // and simply cross-dissolve as you scroll. No scrubbed video motion, no decode cost.
    if (reduce || s.loading || !s.clip) return;
    s.loading = true;
    // Serve the lighter mobile encode on phones when one was provided.
    const url = (isMobile() && s.clipM) ? s.clipM : s.clip;
    fetch(url).then(r => r.ok ? r.blob() : Promise.reject(new Error('404')))
      .then(blob => {
        const v = document.createElement('video');
        v.className = 'sw-scene__video';
        v.muted = true; v.playsInline = true; v.preload = 'auto';
        v.setAttribute('muted', ''); v.setAttribute('playsinline', '');
        v.src = URL.createObjectURL(blob);
        v.addEventListener('loadedmetadata', () => { s.ready = true; read(); });
        // Reveal the video (hide the still poster) only once a real frame has
        // painted — on iOS a seeked-but-never-played muted video stays blank, so
        // hiding the still on metadata alone would flash an empty scene.
        v.addEventListener('seeked', () => { s.el.classList.add('has-clip'); }, { once: true });
        v.addEventListener('loadeddata', () => { try { v.pause(); } catch (e) {} if (userReady) primeVideo(v); });
        s.el.appendChild(v); s.video = v; s.hasClip = true;
      }).catch(() => { s.loading = false; });
  }

  function read() {
    const y = window.scrollY || window.pageYOffset;
    const fade = CROSSFADE * vh;
    let ci = 0;
    for (let i = 0; i < NSEG; i++) if (y >= SEGMENTS[i].start) ci = i;

    for (let i = 0; i < NSEG; i++) {
      const s = SEGMENTS[i];
      if (y > s.start - 1.6 * vh && y < s.end + 1.6 * vh) loadClip(s);
      const local = clamp((y - s.start) / (s.end - s.start), 0, 1);
      s.target = s.linger ? lingerEase(local, s.linger) : local;
      let outside = 0;
      if (y < s.start) outside = s.start - y; else if (y > s.end) outside = y - s.end;
      const op = smooth(1 - outside / fade);
      s.el.style.opacity = op; s.visible = op > 0.001;
      s.el.style.zIndex = (i === ci) ? '120' : String(100 + Math.round(op * 10));
      if (!s.hasClip || !s.ready) {
        const sc = reduce ? 1 : 1.03 + local * 0.14;
        s.img.style.transform = `translateX(${stageX - 2}vw) scale(${sc.toFixed(3)})`;
      }
    }

    for (let i = 0; i < N; i++) {
      const seg = SECTIONS[i]._seg;
      const pr = clamp((y - seg.start) / (seg.end - seg.start), 0, 1);
      const before = y < seg.start, after = y > seg.end;
      let cop;
      if (i === 0) cop = after ? 0 : smooth(1 - pr / 0.62);            // greets on landing
      else if (i === N - 1) cop = before ? 0 : smooth(pr / 0.4);       // holds CTA at the end
      else cop = (before || after) ? 0 : smooth(1 - Math.abs(pr - 0.5) / 0.5);
      const c = copies[i];
      c.style.opacity = cop;
      c.style.transform = reduce ? 'none' : `translateY(${(0.5 - pr) * 4}vh)`;
      c.style.pointerEvents = cop > 0.5 ? 'auto' : 'none';
    }

    const cur = SEGMENTS[ci];
    const near = clamp(cur.kind === 'dive' ? cur.si
      : (((y - cur.start) / (cur.end - cur.start)) > 0.5 ? cur.si + 1 : cur.si), 0, N - 1);
    if (near !== activeIndex) {
      activeIndex = near;
      dots.forEach((d, k) => d.classList.toggle('is-active', k === near));
      nav.querySelectorAll('.sw-nav__item').forEach((n, k) => n.classList.toggle('is-active', k === near));
      container.style.setProperty('--sw-accent', SECTIONS[near].accent || '');
    }
    scrollbarFill.style.transform = `scaleX(${clamp(y / (totalW * vh))})`;
    hint.style.opacity = clamp(1 - y / (0.5 * vh));
    if (particles) particles.style.transform = `translate3d(0, ${-y * 0.05}px, 0)`;
    ticking = false;
  }

  function raf() {
    const eps = isMobile() ? 0.02 : 0.008;   // coarser seek step on phones = fewer decodes
    for (let i = 0; i < NSEG; i++) {
      const s = SEGMENTS[i];
      if (!s.hasClip || !s.ready || !s.video) continue;
      // Never queue a seek while the decoder is still resolving the last one.
      // On phones a fast flick would otherwise pile up seeks and freeze the clip;
      // cur keeps lerping, so we snap to the latest target the moment it's free.
      if (s.video.seeking) continue;
      if (!s.visible && Math.abs(s.cur - s.target) < 0.002) continue;
      s.cur += (s.target - s.cur) * (reduce ? 1 : 0.18);
      const dur = s.video.duration || 1;
      const t = clamp(s.cur, 0, 0.999) * dur;
      if (Math.abs(s.video.currentTime - t) > eps) { try { s.video.currentTime = t; } catch (e) {} }
    }
    requestAnimationFrame(raf);
  }

  // iOS needs a user gesture before a muted video will decode/paint reliably. On the
  // first touch we prime every loaded clip (muted play→pause) so the first seek is
  // instant instead of showing a blank frame. `userReady` also makes freshly-loaded
  // clips prime themselves (see loadClip).
  let userReady = false;
  function primeVideo(v) {
    if (!isMobile() || !v) return;
    try { const p = v.play(); if (p && p.then) p.then(() => { try { v.pause(); } catch (e) {} }).catch(() => {}); }
    catch (e) {}
  }
  function onFirstGesture() {
    if (userReady) return;
    userReady = true;
    SEGMENTS.forEach(s => primeVideo(s.video));
  }
  window.addEventListener('pointerdown', onFirstGesture, { once: true, passive: true });
  window.addEventListener('touchstart', onFirstGesture, { once: true, passive: true });

  // Particles are a per-frame cost we can't afford alongside video scrubbing on a phone.
  seedParticles(particles, reduce || coarse);
  window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(read); } }, { passive: true });
  // Mobile browsers fire `resize` every time the URL bar slides in/out. Re-running
  // layout() there rebuilds the track height and yanks the scroll position, so on
  // touch we ignore height-only changes and only relayout when the width actually
  // changes (rotation still comes through orientationchange). layout() records the
  // width it laid out at.
  function onResize() {
    if (coarse && window.innerWidth === laidOutW) return;
    layout();
  }
  window.addEventListener('resize', onResize);
  window.addEventListener('orientationchange', layout);
  window.addEventListener('load', layout);
  layout();
  requestAnimationFrame(raf);

  // ---- helpers ----
  function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; }
  function pad(n) { return String(n).padStart(2, '0'); }
  function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
  function ctaBtns(cta) {
    let h = '';
    if (cta.primary) h += `<a class="sw-btn sw-btn--primary" href="${esc(cta.primary.href || '#')}">${esc(cta.primary.label)}</a>`;
    if (cta.secondary) h += `<a class="sw-btn sw-btn--ghost" href="${esc(cta.secondary.href || '#')}">${esc(cta.secondary.label)}</a>`;
    return h;
  }
}

function seedParticles(host, reduce) {
  if (!host || reduce) return;
  const kinds = ['dot', 'dot', 'ring'];
  const seeds = [7, 23, 41, 58, 71, 88, 12, 34, 52, 66, 83, 95, 18, 29, 47, 63, 77, 91, 5, 38, 55, 69, 82, 97];
  for (let k = 0; k < 20; k++) {
    const s = document.createElement('span');
    s.className = 'sw-pt sw-pt--' + kinds[k % kinds.length];
    s.style.left = seeds[k % seeds.length] + 'vw';
    s.style.top = ((seeds[(k * 3) % seeds.length] * 1.3) % 100) + 'vh';
    s.style.setProperty('--sw-sc', (0.5 + ((seeds[(k * 5) % seeds.length] % 60) / 60) * 1.1).toFixed(2));
    const dur = 14 + (seeds[(k * 7) % seeds.length] % 22);
    s.style.animationDuration = dur + 's';
    s.style.animationDelay = (-(seeds[(k * 2) % seeds.length] % dur)) + 's';
    host.appendChild(s);
  }
}

function injectCSS() {
  if (document.getElementById('sw-css')) return;
  const css = `
  .sw-root{--sw-bg:#F5EDE0;--sw-ink:#241d2b;--sw-ink-soft:#6a6072;--sw-accent:#8a7bb5;
    --sw-font-display:ui-rounded,"SF Pro Rounded","Segoe UI",system-ui,sans-serif;
    --sw-font-body:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif;
    color:var(--sw-ink);font-family:var(--sw-font-body);}
  html,body{margin:0;background:var(--sw-bg,#F5EDE0);overflow-x:hidden;}
  .sw-sky{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:var(--sw-bg);}
  .sw-sky__grad{position:absolute;inset:-10%;background:linear-gradient(178deg,color-mix(in srgb,var(--sw-accent) 12%,var(--sw-bg)) 0%,var(--sw-bg) 55%,color-mix(in srgb,var(--sw-accent) 6%,var(--sw-bg)) 100%);}
  .sw-sky__glow{position:absolute;inset:0;background:radial-gradient(60% 42% at 74% 16%,color-mix(in srgb,var(--sw-accent) 22%,transparent),transparent 70%),radial-gradient(46% 34% at 50% 50%,color-mix(in srgb,#fff 45%,transparent),transparent 70%);}
  .sw-particles{position:absolute;inset:-6% -2%;will-change:transform;}
  .sw-pt{position:absolute;width:13px;height:13px;transform:scale(var(--sw-sc,1));opacity:0;animation:sw-drift linear infinite;}
  .sw-pt::before{content:"";position:absolute;inset:0;border-radius:50%;}
  .sw-pt--dot::before{background:radial-gradient(circle at 34% 30%,color-mix(in srgb,var(--sw-accent) 60%,#000),#000 82%);}
  .sw-pt--ring::before{background:transparent;border:2px solid color-mix(in srgb,var(--sw-accent) 55%,transparent);}
  @keyframes sw-drift{0%{opacity:0;transform:scale(var(--sw-sc)) translate(0,12vh) rotate(0)}12%{opacity:.5}88%{opacity:.45}100%{opacity:0;transform:scale(var(--sw-sc)) translate(4vw,-22vh) rotate(210deg)}}
  .sw-scrollbar{position:fixed;top:0;left:0;right:0;height:3px;z-index:60;background:color-mix(in srgb,var(--sw-accent) 14%,transparent);}
  .sw-scrollbar span{display:block;height:100%;width:100%;transform-origin:0 50%;transform:scaleX(0);background:var(--sw-accent);}
  .sw-topbar{position:fixed;top:0;left:0;right:0;z-index:50;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:clamp(14px,2.4vw,26px) clamp(18px,5vw,64px);}
  .sw-brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--sw-ink);}
  .sw-brand__mark{width:24px;height:28px;border-radius:7px 7px 10px 10px;background:linear-gradient(160deg,var(--sw-accent),color-mix(in srgb,var(--sw-accent) 60%,#000));box-shadow:0 6px 14px color-mix(in srgb,var(--sw-accent) 40%,transparent);}
  .sw-brand__name{font-family:var(--sw-font-display);font-weight:700;font-size:1.1rem;}
  .sw-nav{display:flex;gap:4px;padding:5px;background:color-mix(in srgb,#fff 55%,transparent);backdrop-filter:blur(10px);border:1px solid color-mix(in srgb,var(--sw-accent) 16%,transparent);border-radius:999px;}
  .sw-nav__item{font:inherit;font-size:.82rem;color:var(--sw-ink-soft);border:0;background:transparent;cursor:pointer;padding:7px 14px;border-radius:999px;transition:color .25s,background .25s;}
  .sw-nav__item:hover{color:var(--sw-ink);} .sw-nav__item.is-active{color:#fff;background:var(--sw-accent);}
  .sw-topcta{text-decoration:none;font-weight:600;font-size:.9rem;color:#fff;background:var(--sw-ink);padding:10px 20px;border-radius:999px;white-space:nowrap;}
  .sw-stage{position:fixed;inset:0;z-index:10;pointer-events:none;}
  .sw-scene{position:absolute;inset:0;opacity:0;overflow:hidden;will-change:opacity;}
  .sw-scene__video,.sw-scene__still{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center 42%;}
  .sw-scene__still{will-change:transform;} .sw-scene.has-clip .sw-scene__still{opacity:0;} .sw-scene__video{z-index:1;}
  .sw-copylayer{position:fixed;inset:0;z-index:20;pointer-events:none;}
  .sw-copylayer::before{content:"";position:absolute;inset:0;width:min(58vw,780px);background:linear-gradient(90deg,var(--sw-bg) 0%,color-mix(in srgb,var(--sw-bg) 82%,transparent) 34%,color-mix(in srgb,var(--sw-bg) 40%,transparent) 62%,transparent 100%);}
  .sw-copy{position:absolute;left:clamp(18px,5vw,64px);top:50%;transform:translateY(-50%);width:min(42vw,460px);opacity:0;will-change:opacity,transform;}
  .sw-copy__num{font-family:ui-monospace,Menlo,monospace;font-size:.74rem;letter-spacing:.12em;color:var(--sw-ink-soft);}
  .sw-copy__eyebrow{display:block;margin-top:18px;font-family:var(--sw-font-display);font-weight:700;font-size:.8rem;letter-spacing:.16em;text-transform:uppercase;color:var(--sw-accent);}
  .sw-copy__title{font-family:var(--sw-font-display);font-weight:700;color:var(--sw-ink);font-size:clamp(2rem,4.4vw,3.5rem);line-height:1.03;margin:12px 0 0;letter-spacing:-.01em;text-shadow:0 2px 20px color-mix(in srgb,var(--sw-bg) 70%,transparent);}
  .sw-copy__body{margin-top:18px;font-size:clamp(1rem,1.25vw,1.14rem);line-height:1.55;color:color-mix(in srgb,var(--sw-ink) 78%,var(--sw-ink-soft));max-width:40ch;text-shadow:0 1px 12px color-mix(in srgb,var(--sw-bg) 90%,transparent);}
  .sw-copy__tags{list-style:none;display:flex;flex-wrap:wrap;gap:8px;margin:24px 0 0;padding:0;}
  .sw-copy__tags li{font-size:.82rem;font-weight:600;color:color-mix(in srgb,var(--sw-accent) 70%,#000);padding:7px 14px;border-radius:999px;background:color-mix(in srgb,var(--sw-accent) 14%,#fff);border:1px solid color-mix(in srgb,var(--sw-accent) 30%,transparent);}
  .sw-copy__cta{display:flex;flex-wrap:wrap;gap:12px;margin-top:28px;pointer-events:auto;}
  .sw-btn{text-decoration:none;font-weight:600;font-size:.95rem;padding:13px 24px;border-radius:999px;transition:transform .2s;}
  .sw-btn--primary{color:#fff;background:var(--sw-ink);} .sw-btn--primary:hover{transform:translateY(-2px);}
  .sw-btn--ghost{color:var(--sw-ink);border:1.5px solid color-mix(in srgb,var(--sw-ink) 25%,transparent);} .sw-btn--ghost:hover{transform:translateY(-2px);}
  .sw-route{position:fixed;right:clamp(14px,2.4vw,30px);top:50%;z-index:40;transform:translateY(-50%);display:flex;flex-direction:column;gap:22px;padding:18px 10px;}
  .sw-route::before{content:"";position:absolute;left:50%;top:22px;bottom:22px;width:2px;transform:translateX(-50%);background:var(--sw-accent);opacity:.28;}
  .sw-route__dot{position:relative;border:0;background:transparent;cursor:pointer;width:14px;height:14px;display:grid;place-items:center;}
  .sw-route__dot i{width:9px;height:9px;border-radius:50%;background:color-mix(in srgb,var(--sw-accent) 40%,transparent);transition:transform .3s,background .3s,box-shadow .3s;}
  .sw-route__dot:hover i{transform:scale(1.25);background:var(--sw-accent);}
  .sw-route__dot.is-active i{background:var(--sw-accent);transform:scale(1.4);box-shadow:0 0 0 5px color-mix(in srgb,var(--sw-accent) 22%,transparent);}
  .sw-route__label{position:absolute;right:24px;top:50%;transform:translateY(-50%) translateX(6px);white-space:nowrap;font-size:.78rem;font-weight:600;color:var(--sw-ink);background:color-mix(in srgb,#fff 85%,transparent);backdrop-filter:blur(6px);padding:5px 11px;border-radius:999px;opacity:0;pointer-events:none;transition:opacity .25s,transform .25s;border:1px solid color-mix(in srgb,var(--sw-accent) 14%,transparent);}
  .sw-route__dot:hover .sw-route__label,.sw-route__dot.is-active .sw-route__label{opacity:1;transform:translateY(-50%) translateX(0);}
  .sw-hint{position:fixed;left:50%;bottom:26px;z-index:30;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:10px;font-size:.76rem;letter-spacing:.14em;text-transform:uppercase;color:var(--sw-ink-soft);transition:opacity .3s;}
  .sw-hint i{width:22px;height:34px;border-radius:12px;border:2px solid color-mix(in srgb,var(--sw-ink) 28%,transparent);position:relative;}
  .sw-hint i::after{content:"";position:absolute;left:50%;top:7px;width:4px;height:7px;border-radius:2px;background:var(--sw-accent);transform:translateX(-50%);animation:sw-wheel 1.7s ease-in-out infinite;}
  @keyframes sw-wheel{0%{opacity:0;top:6px}40%{opacity:1}100%{opacity:0;top:17px}}
  .sw-track{position:relative;z-index:1;width:100%;pointer-events:none;}
  @media (max-width:860px){
    .sw-nav{display:none;}
    .sw-copylayer::before{width:100%;height:60%;top:auto;bottom:0;background:linear-gradient(0deg,var(--sw-bg) 8%,color-mix(in srgb,var(--sw-bg) 70%,transparent) 46%,transparent 100%);}
    /* Anchor copy to the bottom, clear of the home indicator / collapsing URL bar.
       dvh + env() are progressive: browsers that lack them keep the vh fallback line. */
    .sw-copy{left:clamp(18px,5vw,64px);right:clamp(18px,5vw,64px);top:auto;bottom:clamp(64px,14vh,120px);transform:none;width:auto;max-width:560px;}
    .sw-copy{bottom:calc(clamp(56px,12dvh,110px) + env(safe-area-inset-bottom));}
    .sw-copy__title{font-size:clamp(1.9rem,7.5vw,2.7rem);}
    .sw-copy__body{max-width:none;font-size:clamp(.98rem,3.6vw,1.1rem);} .sw-scene__video,.sw-scene__still{object-position:center 46%;}
    .sw-hint{bottom:calc(20px + env(safe-area-inset-bottom));}
    .sw-route{gap:16px;right:6px;} .sw-route__label{display:none;}
  }
  /* Portrait phones crop a 16:9 clip hard; keep the framing centred so the focal
     subject (which the camera dives toward) stays in view. */
  @media (max-width:860px) and (orientation:portrait){
    .sw-scene__video,.sw-scene__still{object-position:center 44%;}
  }
  /* Touch: give the route dots a finger-sized hit area without growing the visible dot. */
  @media (hover:none) and (pointer:coarse){
    .sw-route{padding:14px 6px;}
    .sw-route__dot{width:28px;height:28px;}
    .sw-btn{padding:15px 26px;}
  }
  @media (prefers-reduced-motion:reduce){ .sw-hint i::after{animation:none;} .sw-pt{display:none;} }
  `;
  // Wrap in a cascade layer so the page's own theme tokens (unlayered
  // :root / .sw-root { --sw-bg / --sw-ink / --sw-accent … }) always win over
  // these defaults, regardless of injection order. Enables clean dark themes.
  const style = document.createElement('style'); style.id = 'sw-css';
  style.textContent = '@layer sw {\n' + css + '\n}';
  document.head.appendChild(style);
}

// Expose for module + global use.
if (typeof module !== 'undefined' && module.exports) module.exports = { mountScrollWorld };
if (typeof window !== 'undefined') window.mountScrollWorld = mountScrollWorld;
    scroll-world | Prompt Minder