Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build an immersive scroll-scrubbed "fly through the world" landing page for any industry or brand using OpenArt. 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 wi
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-11 | ✗→✓ | ▲ Improved | 787% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 334% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 505% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 426% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 588% | 0% |
Produces a landing page where scroll drives a camera: it dives from outside a scene into its interior, then flies out and into the next scene, continuously, with no visible cuts. The visuals are AI-generated (OpenArt); the page just scrubs pre-rendered video by scroll position. This is the same technique behind Apple's scroll-through product pages — the camera genuinely moves, scroll only drives time.
What you generate: N scene stills → N "dive-in" camera clips → N-1 "connector" clips that join consecutive scenes seamlessly → a portable scrub engine that plays the whole chain as one flight.
The one rule that makes or breaks it: seams must be frame-identical. Read Step 5 before generating any connector. Getting this wrong is the single most common failure and produces a visible "pop" between scenes.
Do not assume a frontend framework. The scrub engine in references/scrub-engine.js is self-contained vanilla JS (it builds its own DOM + injects its own CSS into a container you give it), so it drops into plain HTML, Next.js, Vue, a Python-served page, anything. The value of this skill is the OpenArt pipeline, the prompts, and the seam method — not the framework.
tools are reachable and read the balance with openart_account_get (returns plan + credits). If they're missing, the user needs to connect the OpenArt MCP server — you cannot install it for them. Tool names carry a server-name prefix that varies by how the user configured it (e.g. mcp__openart_ai__openart_account_get); match what's present.
$PATH (frame extraction + encoding). Both are mandatory —the seam method is built on extracting real rendered frames.
(Step 5d), and the optional transparent-scene knockout (references/knockout.py).
Prompt integrity. Three separate switches will silently mutate prompt text, and the shared style preamble is the only reason the world looks cohesive.
declare -A); don't use associative arrays inscripts. Generations take 1–4 min each — submit them as a batch and poll, never one at a time. openart_creation_wait with timeoutSeconds: 90 can exceed the MCP client's own timeout and error out; use ≤ 60 and call again on STILL_RUNNING.
The OpenArt generate tools default to helping with your prompt. Every one of those defaults is wrong for this skill, because cohesion comes from reusing a byte-identical style preamble across all N scenes. Three switches to hold down:
openart_generate_image /openart_generate_video tool descriptions instruct you to rewrite the prompt into variants and have the user pick — unless the user explicitly opts out. Invoking this skill is that opt-out: the prompts here are templates whose exact wording is the mechanism (references/prompts.md). Send them verbatim, and say so once, plainly, at the start of generation — don't silently skip a step the tool asked for.
autoEnhancePrompt (image models) → set false.enablePromptExpansion) → set false. Read the schema: these default to true on some models.
Approving the journey is the user's job (Step 1). Rewording individual prompts mid-chain is not a decision to delegate to a model — it is the thing that breaks the world.
The model roster and the price list are the two things about OpenArt that go stale fastest. Models get added, renamed and retired; prices change per plan and over time. This document therefore states no prices and names no default model — both are discovered at run time:
openart_model_list filtered by the capability rulein Step 4, checked against each model's live openart_model_form_get schema.
openart_model_cost at the exact params you intend to send.preference baked into this file that quietly rots.
If you ever find yourself about to say "the default model is X" or "this costs N credits" from memory, stop and run the discovery instead. Any model id appearing anywhere in this skill is an illustration of the rule, never a recommendation to reuse unchecked.
If the user only asks what a world would cost — "how much for 6 scenes?", "is this expensive?" — run the Step 1.6 discovery and answer with live numbers, then stop. Don't drag them through the interview to get a price. Quote in credits; convert to currency only if they've told you their plan's rate, since it varies by plan and you cannot infer it.
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:
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.
Capture 4–6 named hex values, a display name, and a tone word or two.
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).
set derived from the subject's own value chain and let the user edit. 5–7 works well. Boba example: farms → pearl kitchen → flagship shop → delivery → community plaza → the hero product. Each section needs: a short subject description (what's IN the diorama), an eyebrow, a headline, one line of body, and 0–3 tag pills. The last section is usually the hero product + the CTA. Scene count is the single biggest lever on the bill (each scene adds a still, a dive and a connector), so once you reach Step 1.6 and have real per-clip prices, state the per-scene marginal cost — it's cheaper to cut a scene then than after they've fallen in love with the list.
two-option choice: "Want a mobile-optimized version too? The mobile version is a second camera chain rendered natively in 9:16 portrait — composed for phones, not a crop of the landscape film — which roughly doubles the credit spend (state the estimated number)." Options: "Desktop only" / "Desktop + mobile (native 9:16 — ~2× credits)". The credit cost must be stated to the user, not just implied. What the answer gates:
(Step 6 / pipeline.md §6b): portrait start canvases → 9:16 dives + connectors frame-locked against their own renders → 720-wide -m.mp4 encodes → stillMobile portrait posters. Wire clipMobile/connectorsMobile/stillMobile (Step 7); run the full mobile QA (Step 8). Budget ~2N-1 extra video gens + re-rolls. Never ship the centre-crop as the mobile version by default — if credits can't cover the portrait chain, say so and offer the crop encodes (pipeline.md §6) as an explicitly-labelled stopgap the user must approve.
(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.
as one step: the price is the main thing distinguishing the options, so choosing a model and approving a spend is a single decision. Never pre-select a model for them, and never quote a price from memory.
a. Discover what qualifies. openart_model_list → take every model offering an image2video mode → openart_model_form_get(model, "image2video") on each → keep only those whose schema exposes both startFrame and endFrame (the Step 4 capability rule). Models missing endFrame cannot make connectors and are out. b. Price each survivor live. openart_model_cost with the exact params you'd send — the resolution and duration you intend, audio off. Do this for one dive (longer duration) and one connector (shorter), since duration drives price. c. Compute the totals for the user's N: N stills + (2N−1) videos, doubled videos if they opted into mobile, plus ~15% re-roll headroom. Read the balance with openart_account_get and warn if the total exceeds ~70% of it. d. Present the qualifying models as a choice (AskUserQuestion), each row showing its live per-clip cost, its build total for this journey, and a one-line character note from its openart_model_list description (realism, motion stability, speed). Order them cheapest-first — the price spread between models can be several-fold, and the cheapest qualifying model is genuinely the right default for a previz pass. Also state the per-scene marginal cost so they can trade scenes against quality. e. Suggest the previz path unprompted when the balance reads tight: run the whole chain on the cheapest qualifying model, approve the journey, then re-render on the chosen one. Because every qualifying model frame-locks, the previz is genuinely seamless and translates directly. f. Get an explicit go before generating anything.
Same for stills: pick the image model from openart_model_list (favour one described as strong at illustration/detail with accurate prompt adherence), price it, and mention the alternative if a materially cheaper one would do. One stills model for all N — mixing sources reads as style drift, the same reason the video chain uses one model.
If the user names a specific model, honour it only if it passes the capability check in (a). This skill only ships seamless output, so a model that can't frame-lock is declined with a one-line why — never silently substituted, and never shipped non-seamless.
Keep the scroll mechanic fixed (continuous fly-through) — that's the point of the skill. See references/prompts.md for the intake checklist and copy structure.
One image per section, all sharing the same style preamble for cohesion. Default image model chosen at Step 1.6 — favour one described as strong at illustration and prompt adherence, returning a solid background (perfect for floating diorama "islands").
Prompt shape (full templates in references/prompts.md):
<STYLE PREAMBLE, identical every time>. On a plain solid <bg> background with a soft
contact shadow. <PALETTE hexes>. No text, no letters, no logos, centered, 3:2.
Subject: <what is in THIS diorama>.json { "model": "<chosen stills model>", "mode": "text2image", "params": { "prompt": "<verbatim template>", "imageCount": 1, "aspectRatio": "3:2", "resolutionTier": "2k", "quality": "high", "lockAspectRatio": true, "outputFormat": "png", "autoEnhancePrompt": false } }
{status:"PENDING", historyId} immediately. Collect the historyIds,then openart_creation_wait(historyId, timeoutSeconds: 45) on each; on STILL_RUNNING call again with the same id. Observed ~90 s per still at 2k/high; output 2016×1344.
resources[0].url — a public CDN URL. Download it withcurl for the local pipeline, but keep the URL: it feeds straight into startFrame with no upload (Step 4).
angle, palette, light). If one is off-style, regenerate it, optionally passing an approved scene as an image2image reference to lock style.
See references/pipeline.md for the exact batch procedure.
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.
How the camera moves between scenes is the single biggest quality lever. Two shapes; pick by aesthetic.
This skill only ships seamless output, so the only usable models are ones that can frame-lock a seam: every chained clip must accept startFrame, and connectors also need endFrame. That capability — not preference, not reputation — is the selection rule.
The qualifying set is discovered, never memorised (Step 1.6a). There is deliberately no roster in this file: models come and go, and a hard-coded list would quietly start recommending something retired or skipping something new and better.
openart_model_list → models with an image2video mode
openart_model_form_get(model, "image2video") → its live param schema
keep if schema has BOTH startFrame and endFrame → can hold a seam, usable
drop if startFrame only → can start a shot, not land one:
architecture-A legs only, no connectors
drop if reference/element-only inputs → can only condition, not continueThe same schema tells you how to call the model. These four rules are what the per-model quirks actually reduce to, and they hold for models that don't exist yet:
generateAudio, generateSound, orsomething new, and it frequently defaults to true. You mute in HTML and -an on encode, so an audio track is pure wasted spend. Some models emit a silent-to-you AAC track with no field to disable it — hence -an is mandatory regardless.
enablePromptExpansion,autoEnhancePrompt, or similar. These default to true on some models and will mutate the style preamble that holds the world together (see Prompt integrity).
aspectRatio field → the output follows the start frame. Several models omit itentirely. This is why the 9:16 mobile chain composites a real portrait canvas (pipeline.md §6b) instead of requesting an aspect. If the schema does offer an enum, set it explicitly.
"720p" fed a 3:2 start frame mayreturn 1200×800. Always ffprobe the download and encode what it actually reports — never upscale to match the label.
Check duration bounds on the schema too — they vary, and the dive/connector lengths you picked have to fit inside them.
Rules:
character; mixing models mid-chain keeps position continuity (frames still hand off) but the render-character shift reads as a subtle pop. The one sanctioned exception is a content-filter fallback for a single stubborn clip (Gotchas) — a slight character shift on one 5s connector beats a missing connector.
default baked in here. Honour a stated preference only if it qualifies; if it doesn't, say so plainly and let them pick again — never ship a non-seamless build to satisfy a model request.
discovery in Step 1.6 is cheap, so repeat it rather than trusting a stale call.
One camera that only ever glides forward, first scene through last, as a single take. Generate the legs sequentially: leg 0 from scene-0's still (glide forward into it); then each leg's startFrame = the previous leg's ACTUAL last frame (extract with ffmpeg, upload per Step 5b), prompt "continue gliding smoothly FORWARD into [scene i], never pulling back" (or an expressive mid-leg move under the motion-handoff contract — see Camera grammar below), and no endFrame — an end frame of a wide establishing shot forces the camera to pull back, which is the #1 cause of stutter. Extract each leg's last frame to feed the next. Result: every seam is frame-identical and the camera never reverses. There are no connectors (skip Step 5c, but you still need 5a/5b) — the legs ARE the journey. Wire each leg as a section clip with connectors: [] and a small crossfade (~0.08). Even without an endFrame the legs still arrive at distinct rooms (the prompt steers the content). Cost: strictly sequential (can't parallelize) and slower; build in re-rolls (3 attempts/leg).
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.
"Forward only" is the seam rule, not the leg rule. The physics of the chain:
previous leg's actual last frame).
that's the rewind stutter.
no seam to break mid-leg, so orbits, crane-ups, lateral tracking, even a push-in that eases back out are all safe within the clip. Reversals are only fatal across seams.
So give each leg an expressive move chosen from the scene's own logic, under a motion handoff contract: every leg ends by settling into a slow, steady forward drift toward the next destination (final ~1 s), and every leg begins by continuing that same drift. Keep both clauses in the prompts verbatim (templates in references/prompts.md).
Pick the grammar from the concept:
| Concept / tone | Mid-leg move | |---|---| | Product / luxury retail | slow half-orbit around the hero object, then continue past it | | Real estate / hospitality | steadicam glide through doorways; gentle crane-up in atria | | Industrial / process / logistics | low lateral track alongside the line, foreground parallax | | Travel / outdoors / campus | drone-style rise-and-reveal, then a descending swoop | | Food / craft / detail-driven | push in close to the craft moment, ease back, carry on | | Playful miniature (arch. B) | dives + aerial hops — the connector IS the grammar |
Honest costs: expressive mid-leg moves raise re-roll odds — the model can end a fancy move in a state that isn't a clean forward drift. Mitigations: keep the final-second settle clause verbatim; eyeball each leg's last frame before chaining the next (it should look like a frame from a gentle forward glide — if not, re-roll before wasting the next leg); budget ~1 extra re-roll per expressive leg. A plain forward glide stays the zero-risk default — use it for legs where the scene itself is the show.
Two related pacing knobs live in the engine (Step 7): per-section scroll (more scroll distance = longer dwell in that scene) and linger (the camera settles mid-scene exactly while the copy peaks, then picks up speed toward the seam). Prefer expressive motion in the clip and restraint in the scrub mapping — they compound.
And remember scroll is a scrubber: visitors can scroll up, so every move also plays in reverse. That's free and expected — no extra work — but it's another reason seam velocity must be consistent in both directions (a seam that reads fine forward reads as a stutter backward too if velocity flips).
For B, one camera flight per scene: starts high/outside, descends into the interior, structure opens. Model: the chain model chosen at Step 1.6. startFrame = the scene still's CDN URL (no upload needed — it's already hosted).
start frame, so the video has a full frame.
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.)
resolution, duration ~8 for a dive, audio field off, prompt-expansion field off.
downloads — you need their frames next.
Skip the generation (5c) for architecture A — the forward take has no connectors; its legs already chain seamlessly. But A still needs 5a/5b, because each leg's start frame is the previous leg's last frame.
The connector clips are what make the world feel connected instead of cut. A connector flies from the end of scene i out and into the start of scene i+1. Both of its endpoints must be the ACTUAL RENDERED FRAMES of the neighbouring clips — never the original diorama still.
Why: every generation renders slightly differently. If a connector ends on a fresh render of "the kitchen diorama," but the next dive clip starts on its own different render of that same diorama, the two won't match and you get a pop at the seam. The fix is to hand off the exact pixels:
For each connector between dive_i and dive_{i+1}:
startFrame = the LAST frame extracted from dive_i's rendered video
endFrame = the FIRST frame extracted from dive_{i+1}'s rendered videoNow every seam is frame-identical on both sides: dive_i.end == connector.start and connector.end == dive_{i+1}.start.
From the rendered dives (not the stills):
bashffmpeg -sseof -0.15 -i dive_i.mp4 -frames:v 1 -q:v 2 last_i.png # interior of i ffmpeg -ss 0 -i dive_next.mp4 -frames:v 1 -q:v 2 first_next.png # establishing of i+1
startFrame/endFrame take a hosted URL, so local frames must be uploaded first. (Assets OpenArt itself generated are already on the CDN — pass those URLs directly and skip this. Uploading is only for files that exist locally: extracted frames and portrait canvases.)
bash# 1) openart_upload_sign { mediaType:"image", contentType:"image/png", # size:<exact bytes>, filename:"last_i.png", purpose:"create-video" } # -> { signURL, accessURL, visualReference:{type,id,url,label} } # 2) PUT the bytes (a plain PUT works at typical frame sizes; no chunking needed): curl -X PUT -H "Content-Type: image/png" -H "Content-Length: $(stat -f%z last_i.png)" \ --data-binary @last_i.png "<signURL>" # expect HTTP 200
size must equal the real byte count (stat -f%z). The returned visualReference is already the exact {type, id, url, label} shape startFrame/endFrame want — pass it through unchanged. The asset is readable from accessURL immediately after the 200.
duration: 5 is plenty. Connectors need endFrame, so the model must accept it — any roster model does:
json{ "model": "<chosen chain model>", "mode": "image2video", "params": { "prompt": "<connector template>", "startFrame": <visualReference from last_i.png>, "endFrame": <visualReference from first_next.png>, "resolution": "1080p", "duration": 5, "negativePrompt": "", "enablePromptExpansion": false } }
Connector prompt: "Single continuous camera move, no cuts. Pull up and back out of <scene i>, rise into the sky, glide across the connected miniature world, and arrive above <scene i+1>, beginning to descend toward it. Seamless flowing aerial transition. <style>. No text." (Template in references/prompts.md.)
Insurance: the model lands close to the end frame but not always pixel-perfect, so the engine still applies a short crossfade (a few frames) at each seam. Frame-matched endpoints + a small crossfade = no visible cut. Never skip the actual-frame handoff and rely on the crossfade alone; a big content jump can't be hidden by a crossfade.
Don't eyeball this. Extract the connector's first/last frames and compare against the neighbours' frames with a per-pixel RMS difference (script in references/pipeline.md):
bashffmpeg -ss 0 -i conn_i.mp4 -frames:v 1 -q:v 2 conn_first.png ffmpeg -sseof -0.15 -i conn_i.mp4 -frames:v 1 -q:v 2 conn_last.png # compare last_i.png vs conn_first.png, and conn_last.png vs first_next.png
A correct handoff lands around RMS 3–8. Unrelated frames score ~90+. Anything above ~30 means the handoff didn't take — you passed a still instead of a rendered frame, or crossed the two frames over. (Measured on a live 2-scene build: seam A 3.76, seam B 6.98, unrelated-frame control 93.80.) Do this before encoding — re-rolling one connector is far cheaper than discovering the pop at Step 8.
Scrubbing = setting video.currentTime from scroll. Two things matter, and they are often gotten wrong:
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.
(whatever ffprobe reports — don't downscale, don't upscale), crf ~20, a small GOP (-g 8) rather than all-intra (all-intra bloats an 8s clip to ~25 MB; GOP 8 is ~8 MB and scrubs fine via blob). Strip audio, add faststart, and a light unsharp counters video softness:
bashffmpeg -i src.mp4 -an -vf "unsharp=5:5:0.8:5:5:0.0" \ -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \ -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart out.mp4
-an is not optional: some models return an AAC track even when their schema has no audio parameter to turn off.
Encode all 2N-1 clips (dives + connectors) with the same settings for uniform quality.
Mobile encodes (only if the user opted in at Step 1.5). The mobile version is the native 9:16 portrait chain (pipeline.md §6b): portrait renders of every dive and connector, encoded 720 wide (scale=720:-2), -g 4 (more keyframes = cheaper seeks — phone decoders' seek cost scales with GOP length), crf 23 — wired as clipMobile / connectorsMobile, with each portrait dive's first frame extracted as the section's stillMobile poster (Step 7). The engine serves them automatically on phones and falls back to the desktop clip when absent. The 16:9 centre-crop encm() encodes (pipeline.md §6) are a fallback only — for when credits can't cover the portrait chain — and shipping them must be called out to the user, never silent. If the user chose desktop-only, skip this — the engine still hardens phone scrubbing regardless (seek-coalescing, iOS priming), so the page degrades gracefully rather than breaking.
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:
jsmountScrollWorld(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 5d already measured the seams numerically — that catches bad handoffs before you spend an encode. This step catches page-level problems. Drive the page in a headless browser:
must be near-identical. If they pop, you used the diorama still instead of the actual rendered frame (redo Step 5), or the crossfade band is too short.
video.seekable.end(0) > 0 (blob working), andthat currentTime tracks scroll across each clip's band.
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):
track without freezing (the seek-coalescing + -m.mp4 encodes are what make this hold).
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.
-m.mp4 variant is actually served on mobile (Network panel), and theheavy 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).
are ignored on touch). Rotate the device — layout should recompose cleanly.
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.
Prompt integrity was left on: the tool-level "polish into 4 variants" flow, autoEnhancePrompt (images), or a prompt-expansion field on the video model (defaults true on some). The style preamble must go through byte-identical or the world stops looking like one place.
clips' actual frames. Always extract real frames (Step 5). Measure it (Step 5d): a good seam is RMS 3–8, a broken one is 30+.
camera velocity reverses (forward dive, then a connector that pulls back out) it reads as a rewind. This is inherent to architecture B. For any grounded walkthrough use architecture A; see Step 4.
openart_creation_wait errors out instead of returning → timeoutSeconds: 90can exceed the MCP client's own request timeout. Use ≤ 60 and call again when it returns STILL_RUNNING. openart_creation_get is a cheap non-blocking status peek.
generateAudio,generateSound, …) default to true on several models. Set them false. Some models return an AAC track with no field to disable it — -an at encode is mandatory either way.
startFrame rejected / "invalid image" → you passed a local path. It needs a hostedURL. OpenArt-generated assets already have one (resources[0].url); local files must go through sign + PUT (Step 5b), and the size you sign with must be the exact byte count.
aspectRatio field; the output follows the start frame (verified: a 1080×1920 portrait canvas returned 1080×1920). This is why the mobile chain composites a real portrait canvas (pipeline.md §6b) rather than asking for 9:16. Check the schema — some models do take an explicit enum.
"720p" fed a 3:2 start frame returned1200×800. ffprobe the download and encode what it reports; never upscale.
seekable=[0,0]; the host isn't serving byteranges. Use blob URLs (engine does).
-g 8 + blob instead.crf ≤ 20, add unsharp. Video is inherently softer than the stills — keep the stills as the lite fallback for max fidelity.
then strip trigger words and add "empty, unoccupied, no people, no figures, architectural, tasteful"; then regenerate just that clip on a different roster model with the same start/end frames — a different provider's filter often passes what another blocks. Expect a slight render-character shift on that one clip; for a 5s connector behind a crossfade that usually beats the last resort: set the connector slot to null — the engine crossfades that seam directly (optional connectors), so the page still completes.
@layer sw, so apage-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 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).
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.
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).
bottom offset (env(safe-area-inset-bottom) + dvh); make sure the page's <meta viewport> includes viewport-fit=cover (the template does).
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.
model with no endFrame where a connector needs one. One model for the whole chain; the cheapest qualifying model still frame-locks, so saving money never costs you the seam.
bg to it or knock it out (Step 3).
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/prompts.md — the intake checklist, style-preamble pattern, and everyprompt template (scene still, dive, connector) with fill-in slots.
references/pipeline.md — the full run procedure (generate → extract frames → upload →connectors → verify seams → encode → mobile), with bash-3.2-safe scripts for the local ffmpeg work.
references/scrub-engine.js — the portable, config-driven scrub engine (builds DOM +injects CSS; blob-seek, lazy load, seam crossfade, copy, route rail, reduced-motion, and phone hardening: mobile encodes, seek-coalescing, iOS priming, safe-area, no-jump resize).
references/index-template.html — a minimal standalone page that mounts the engine.references/knockout.py — border-connected background knockout for floating scenes.Other measured skills in the registry, with their headline benchmark lift.