@ericrisco/rsc 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/@ericrisco/rsc?color=63d68a&labelColor=12161c&label=npm)](https://www.npmjs.com/package/@ericrisco/rsc)
10
10
  [![downloads](https://img.shields.io/npm/dm/@ericrisco/rsc?color=63d68a&labelColor=12161c&label=downloads)](https://www.npmjs.com/package/@ericrisco/rsc)
11
- [![skills](https://img.shields.io/badge/skills-271-63d68a?labelColor=12161c)](#the-catalog)
11
+ [![skills](https://img.shields.io/badge/skills-272-63d68a?labelColor=12161c)](#the-catalog)
12
12
  [![license](https://img.shields.io/badge/license-MIT-63d68a?labelColor=12161c)](LICENSE)
13
13
  [![stars](https://img.shields.io/github/stars/ericrisco/rsc-harness?color=63d68a&labelColor=12161c)](https://github.com/ericrisco/rsc-harness/stargazers)
14
14
 
@@ -60,7 +60,7 @@ The model is the brain your agent rents, and you already have it. rsc adds the r
60
60
  | --- | --- | --- |
61
61
  | **Memory** | `02-DOCS/` | A wiki the agent reads before it works and keeps up to date while it works. |
62
62
  | **Hands** | `01-TOOLS/` | One folder per tool, with a test that proves the connection works. |
63
- | **Trade** | skills | Only the skills your project needs: 271 available, a handful installed. |
63
+ | **Trade** | skills | Only the skills your project needs: 272 available, a handful installed. |
64
64
  | **Reflexes** | hooks | Rules that hold even when the agent forgets them mid-session. |
65
65
 
66
66
  ### Why documentation comes first
@@ -468,7 +468,7 @@ snapshots the project first, so `rsc restore latest` undoes a bad update.
468
468
 
469
469
  ## The catalog
470
470
 
471
- 271 skills, grouped by what you are trying to do. Click any skill to read its `SKILL.md`. It fires on
471
+ 272 skills, grouped by what you are trying to do. Click any skill to read its `SKILL.md`. It fires on
472
472
  its own when a task matches.
473
473
 
474
474
  ### 🧭 Core & control plane
@@ -561,7 +561,7 @@ whether, what and which platform.
561
561
 
562
562
  ### 🌀 Motion & interface craft
563
563
 
564
- [motion-craft](skills/motion-craft/) · [ui-engineering](skills/ui-engineering/) · [variant-explorer](skills/variant-explorer/)
564
+ [motion-craft](skills/motion-craft/) · [ui-engineering](skills/ui-engineering/) · [variant-explorer](skills/variant-explorer/) · [scroll-film](skills/scroll-film/)
565
565
 
566
566
  ### 🎨 Design & content craft
567
567
 
@@ -628,6 +628,7 @@ Most of this catalog is written here. These are not, and they keep their author'
628
628
  | `design-eng` · `animate` · `animate-expo` · `review-animations` · `improve-animations` · `find-animation-opportunities` · `animation-vocabulary` · `apple-design` · `prototype` · `pick-ui-library` · `write-swift` · `ask-sonner` | [emilkowalski/skills](https://github.com/emilkowalski/skills) by Emil Kowalski, commit `d23d7f8` | MIT |
629
629
  | part of the AI-tell corpus in `design` | [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) | MIT |
630
630
  | the answer-first habits in `orient` | [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) | MIT |
631
+ | the scroll-scrubbed camera flight, the frame handoff between clips and the phone fixes behind `scroll-film` (prose and engine rewritten) | [oso95/scroll-world](https://github.com/oso95/scroll-world) | MIT |
631
632
 
632
633
  Adapted, not mirrored: each carries rsc frontmatter, routing evals and hand-offs to its siblings. The
633
634
  craft bar in them is the original author's.
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "3.1.2",
2
+ "version": "3.1.4",
3
3
  "counts": {
4
- "skills": 271,
4
+ "skills": 272,
5
5
  "agents": 34,
6
6
  "commands": 53,
7
7
  "fixedCommands": 20
@@ -124,7 +124,8 @@
124
124
  "fal",
125
125
  "replicate",
126
126
  "remotion-video",
127
- "video-shorts"
127
+ "video-shorts",
128
+ "scroll-film"
128
129
  ],
129
130
  "profiles": []
130
131
  },
@@ -4300,6 +4301,30 @@
4300
4301
  ],
4301
4302
  "profiles": []
4302
4303
  },
4304
+ {
4305
+ "id": "scroll-film",
4306
+ "description": "Use when a page's hero is a pre-rendered camera flight the visitor scrubs by scrolling, Apple-style: AI clips chained frame to frame so seams never pop, encoded for seeking, mounted with an engine that holds up on phones. NOT scroll reveals or pinning (that is `design`), NOT a finished MP4 (that is `ai-media`).",
4307
+ "tags": [
4308
+ "scroll-scrub",
4309
+ "scroll-video",
4310
+ "scrollytelling",
4311
+ "landing-hero",
4312
+ "image-to-video",
4313
+ "camera-flight",
4314
+ "seamless-video"
4315
+ ],
4316
+ "recommends": [
4317
+ "ai-media",
4318
+ "fal",
4319
+ "replicate-images",
4320
+ "landing-copy",
4321
+ "design",
4322
+ "performance"
4323
+ ],
4324
+ "profiles": [
4325
+ "full"
4326
+ ]
4327
+ },
4303
4328
  {
4304
4329
  "id": "sdd",
4305
4330
  "description": "Use when you want a disciplined, spec-driven path from a feature idea to shipped, verified software — the SDD dispatcher and front door, in any language. States the method, reads the user's register from 02-DOCS, and routes to the right phase: constitution -> specify -> clarify -> plan -> tasks -> analyze -> implement -> verify -> review -> ship, with debug / worktrees / parallel on demand. Use it to start a feature, to govern the whole flow, or when unsure which phase you are in. NOT a single phase itself (it dispatches), NOT the workspace harness (that is `harness`), NOT a stack build skill.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@ export const DOMAINS = [
24
24
  { title: 'Ship & operate — platforms', ids: ['vercel', 'netlify', 'cloudflare', 'railway', 'render', 'fly-io', 'coolify', 'hetzner', 'digitalocean', 'aws-essentials', 'gcp-essentials'] },
25
25
  { title: 'Ship & operate — devops', ids: ['docker', 'github-actions', 'git-workflow', 'domains-dns', 'monitoring', 'email-deliverability', 'scaling', 'deployment', 'deprecation'] },
26
26
  { title: 'Ship & operate — quality & security', ids: ['code-review', 'security-scan', 'secure-coding', 'testing-py', 'testing-web', 'testing-go', 'e2e-testing', 'accessibility', 'performance', 'error-handling', 'observability'] },
27
- { title: 'Motion & interface craft', ids: ['motion-craft', 'ui-engineering', 'variant-explorer'] },
27
+ { title: 'Motion & interface craft', ids: ['motion-craft', 'ui-engineering', 'variant-explorer', 'scroll-film'] },
28
28
  { title: 'Design & content craft', ids: ['design-loop', 'design', 'design-dna', 'presentations', 'course-storytelling', 'course-builder', 'technical-writing', 'translation-l10n'] },
29
29
  { title: 'Knowledge & meta', ids: ['knowledge-ops', 'codebase-onboarding', 'research-ops', 'decision-records', 'continuous-learning', 'skill-scout', 'context-budget', 'roast-me', 'fable-operator'] },
30
30
  ];
@@ -2,7 +2,7 @@
2
2
  name: ai-media
3
3
  description: "Use when a creative goal must become a finished media file: pick and order generative-media models per modality — AI voiceover, image-to-video clips, score — then glue them with ffmpeg (mux, duck, loudnorm, concat). NOT still-image generation/editing (that is `replicate-images`); NOT code-rendered React compositing (that is `remotion-video`)."
4
4
  tags: [ai-media, text-to-speech, image-to-video, voiceover, music-generation, ffmpeg, media-pipeline, elevenlabs]
5
- recommends: [replicate-images, fal, replicate, remotion-video, video-shorts]
5
+ recommends: [replicate-images, fal, replicate, remotion-video, video-shorts, scroll-film]
6
6
  origin: risco
7
7
  ---
8
8
 
@@ -23,7 +23,7 @@ Map the goal to modalities and an ordered step list, and **lock that plan before
23
23
  | Just a clip from a still | img→video only | still (input) → clip | `fal`/`replicate` |
24
24
  | Code-rendered explainer | none of the above | render from React/TS | **stop — route to `remotion-video`** |
25
25
 
26
- If the video is *rendered from data/code* (charts, timelines, JSON-driven scenes), this is not your job → `../remotion-video/SKILL.md`. You handle *model-generated + ffmpeg-glued*.
26
+ If the video is *rendered from data/code* (charts, timelines, JSON-driven scenes), this is not your job → `../remotion-video/SKILL.md`. You handle *model-generated + ffmpeg-glued*. If the clips will be scrubbed by scroll on a page instead of played as a file, the chain, its seams and the page are `../scroll-film/SKILL.md`.
27
27
 
28
28
  ## Modality 1 — Voice (TTS)
29
29
 
@@ -152,9 +152,10 @@ Route every cloud alert into the **same alert pipe** as your app-level budget al
152
152
  |---|---|---|
153
153
  | Zero code change, fastest setup | Helicone (proxy, ~2-min) | adds a network hop / latency |
154
154
  | SDK-level capture + a ready cost table | Langfuse (MIT, ships model+tokenizer cost table) | you wire the SDK, but no proxy hop |
155
+ | Hard spend caps at a gateway, per team or app key | Bifrost (self-hosted, Apache-2.0; budgets and rate limits per virtual key) | one more service to run; caps are per key, not per feature |
155
156
  | Full control / custom attribution / typed caps | DIY ledger (this skill) | you own pricing-table freshness and capture coverage |
156
157
 
157
- (firecrawl.dev best-llm-observability-tools; guptadeepak.com top-5-llm-observability-platforms-2026, accessed 2026-06-02.) Buy the proxy/platform when you want spend *visibility* fast; build the ledger when caps and per-feature attribution must live inside your own logic.
158
+ (firecrawl.dev best-llm-observability-tools; guptadeepak.com top-5-llm-observability-platforms-2026, accessed 2026-06-02; docs.getbifrost.ai features/governance/virtual-keys, accessed 2026-10-19.) Buy the proxy/platform when you want spend *visibility* fast; build the ledger when caps and per-feature attribution must live inside your own logic.
158
159
 
159
160
  ## Anti-patterns
160
161
 
@@ -220,6 +220,10 @@ Both are opt-in above a motion budget, both stay inside
220
220
  `@media (prefers-reduced-motion: no-preference)`, and both collapse to a plain vertical stack
221
221
  below `768px` rather than pinning on a phone.
222
222
 
223
+ A **pre-rendered video scrubbed by scroll** (a camera flying through scenes, Apple-style) is not a
224
+ timeline animation. The clips, the seams between them and the scrub engine are
225
+ `../../scroll-film/SKILL.md`.
226
+
223
227
  ## See Also
224
228
 
225
229
  - `visual-system.md` — the easing and duration tokens these animations consume.
@@ -83,7 +83,7 @@ resp = router.completion(model="smart",
83
83
  messages=[{"role": "user", "content": prompt}])
84
84
  ```
85
85
 
86
- **Build vs buy:** raw SDK + a thin retry/timeout wrapper (fewest deps, fine for one provider) → LiteLLM SDK/proxy (widest provider coverage, fallbacks for free) → hosted gateway (Bifrost/Portkey, when you want it operated for you). Full config — all fallback buckets, redis cache params, budget/rate-limit settings, cost callbacks — is in [references/litellm-router.md](references/litellm-router.md).
86
+ **Build vs buy:** raw SDK + a thin retry/timeout wrapper (fewest deps, fine for one provider) → LiteLLM SDK/proxy (widest provider coverage, fallbacks for free) → self-hosted open-source gateway (Bifrost: Go, Apache-2.0, per-key budgets and rate limits) → hosted gateway (Portkey, when you want it operated for you). Full config — all fallback buckets, redis cache params, budget/rate-limit settings, cost callbacks — is in [references/litellm-router.md](references/litellm-router.md).
87
87
 
88
88
  ## Reliability mechanics
89
89
 
@@ -0,0 +1,220 @@
1
+ ---
2
+ name: scroll-film
3
+ description: "Use when a page's hero is a pre-rendered camera flight the visitor scrubs by scrolling, Apple-style: AI clips chained frame to frame so seams never pop, encoded for seeking, mounted with an engine that holds up on phones. NOT scroll reveals or pinning (that is `design`), NOT a finished MP4 (that is `ai-media`)."
4
+ tags: [scroll-scrub, scroll-video, scrollytelling, landing-hero, image-to-video, camera-flight, seamless-video]
5
+ recommends: [ai-media, fal, replicate-images, landing-copy, design, performance]
6
+ profiles: [full]
7
+ origin: risco
8
+ ---
9
+
10
+ # scroll-film — the camera moves, the scroll is the playhead
11
+
12
+ The page plays nothing. It holds a chain of pre-rendered clips and maps the scroll position to
13
+ their playhead, so the visitor flies through scenes — outside a building, inside it, on to the
14
+ next — without a cut. Three parts must be right, and each fails in its own way: the **chain**
15
+ (seams), the **encode** (seeking) and the **page** (phones). This skill owns all three.
16
+
17
+ **Not this skill — delegate:**
18
+ - Scroll-linked reveals, parallax, pinned sections, a horizontal pan, with no video → `../design/SKILL.md`.
19
+ - A finished MP4 with voice and music → `../ai-media/SKILL.md`.
20
+ - The call to the model endpoint (queue, webhooks, cost per call) → `../fal/SKILL.md` or `../replicate/SKILL.md`.
21
+ - The scene stills themselves → `../replicate-images/SKILL.md`.
22
+ - The words over the film → `../landing-copy/SKILL.md`. A video rendered from code → `../remotion-video/SKILL.md`.
23
+
24
+ ## 1. The gate: nothing renders before the user agrees to a number
25
+
26
+ A chain is many clips, each one slow and billed. Interview, price it, then wait for a yes.
27
+
28
+ 1. **Subject.** Ask it as an open question: what the world is about, in a sentence. A menu of
29
+ industries decides their business for them.
30
+ 2. **Scenes.** 4 to 7, in the order the camera visits them, proposed from their own value chain and
31
+ edited by them. The last one carries the call to action.
32
+ 3. **Look.** One art direction, written once as a style preamble and pasted unchanged into every
33
+ still prompt. Identical text is what makes separate renders read as one world.
34
+ 4. **Camera feel.** Always ask. It picks the chain shape (section 3).
35
+ 5. **Phones.** Always ask. A real phone version is a second chain (section 6) and doubles the video bill.
36
+ 6. **Brand.** 4 to 6 hex colours, a name, a tone. The page background is the scene background.
37
+
38
+ Then the bill:
39
+
40
+ | | Forward take (A) | Dive and hop (B) |
41
+ | --- | --- | --- |
42
+ | Video clips, desktop only | N | 2N − 1 |
43
+ | Video clips, with the phone chain | 2N | 4N − 2 |
44
+
45
+ Estimate = clips × seconds per clip × today's price per second, plus about 20% for re-rolls, plus N
46
+ stills. Read the price from the provider at build time; it moves (reading cost per call →
47
+ `../fal/SKILL.md`). State the total against the balance and wait for the yes. When money is tight,
48
+ offer a previz pass: the whole chain on the **same** model at its lowest resolution, approve the
49
+ journey, then render the finals.
50
+
51
+ ## 2. The model must continue a shot, not just start one
52
+
53
+ A seam holds only if the model starts a clip on an exact frame you hand it. That capability, not
54
+ quality or price, qualifies a model.
55
+
56
+ - Every clip needs a **start-frame** input. Hops (shape B) also need an **end-frame** input. Field
57
+ names vary by endpoint (`image_url`, `start_image`, `first_frame`; `end_image_url`,
58
+ `tail_image_url`, `last_frame`): read the input schema before batching. Model landscape →
59
+ `../ai-media/SKILL.md`.
60
+ - A **reference-image** input does not qualify. It steers the style; frame 0 is still a new
61
+ render, and the seam pops.
62
+ - **Probe once before the batch**, with one cheap clip from a real still prepared at the clip's
63
+ aspect (16:9, or 9:16 for phones). Frame 0 must match the still and the bill must match the
64
+ advertised price. For hop duty, add an end frame from another still: the last frame must land on
65
+ that composition. A near miss is normal; the engine's dissolve covers it. Commands →
66
+ `references/chain.md`.
67
+ - **One model for the whole chain.** Each model has its own grain, colour and motion; a swap
68
+ mid-chain keeps positions continuous and still pops. The one exception is a single clip a content
69
+ filter keeps blocking (`references/chain.md`).
70
+
71
+ ## 3. Pick the chain shape from the camera feel
72
+
73
+ | The user says | Shape | Clip k starts on | Clip k ends on | Fits |
74
+ | --- | --- | --- | --- | --- |
75
+ | "one continuous walkthrough", "one fixed angle gliding over it" | **A — forward take** | leg k−1's real last frame (leg 0: the first still) | nothing — no end frame | photoreal, interiors, product, real estate |
76
+ | "fly from one island to the next" | **B — dive and hop** | dive: its still. hop: dive k's real last frame | hop: dive k+1's real first frame | miniature, diorama, map-like worlds |
77
+
78
+ - A renders one leg at a time, because each needs the previous one's last frame. B renders every
79
+ dive in parallel, then every hop.
80
+ - Never give an A leg a wide establishing shot as its end frame. The camera pulls back to meet it,
81
+ and the take reverses.
82
+ - **The velocity rule.** Inside one clip the camera is free: orbit, rise, push in and ease back.
83
+ Across a seam it never reverses. Position matches, the motion flips, and it reads as a rewind,
84
+ in both scroll directions. So every leg ends by settling into a slow forward drift, and the next
85
+ one begins inside it. B reverses at every seam on purpose (pull out, hop, dive): charming over a
86
+ miniature map, a stutter in a realistic walkthrough. If the user picks B for a photoreal look, say
87
+ so before rendering.
88
+
89
+ Prompt templates (still, leg, dive, hop), the camera-move table by concept, the fixed-angle clause
90
+ and the batch loops → `references/chain.md`.
91
+
92
+ ## 4. The seam law: hand off pixels, not descriptions
93
+
94
+ Every render redraws its scene a little differently. A hop that ends on the *original still* of
95
+ scene 3 meets a dive that starts on *its own render* of scene 3: two pictures of one place, and the
96
+ seam pops. So both ends of every clip come from the neighbours' actual renders.
97
+
98
+ ```bash
99
+ # The true last frame: -update 1 overwrites per decoded frame, so the final one is what stays.
100
+ ffmpeg -v error -y -sseof -1 -i leg_02.mp4 -update 1 -q:v 2 leg_02_last.png
101
+ ffmpeg -v error -y -i dive_03.mp4 -frames:v 1 -q:v 2 dive_03_first.png
102
+ ```
103
+
104
+ A plain `-sseof -0.15 -frames:v 1` stops a few frames short of the end: 15 dB PSNR from the real
105
+ last frame on a 24 fps test clip. The engine parks the outgoing clip on its real last frame, so a
106
+ handoff from the earlier one starts the seam already off.
107
+
108
+ - Look at each last frame before chaining the next clip. In A, one bad handoff poisons every leg
109
+ after it: re-roll that leg.
110
+ - The engine dissolves over a tenth of a viewport at each seam. That hides an end frame that landed
111
+ close. It cannot hide a different picture.
112
+
113
+ ## 5. Encode for seeking, not for playback
114
+
115
+ Scrubbing seeks on every frame, and each seek decodes every frame since the last keyframe. The
116
+ keyframe interval sets how smooth the scrub feels.
117
+
118
+ ```bash
119
+ # Desktop: native resolution (never upscale), a keyframe every 8 frames, no audio, index first.
120
+ ffmpeg -i leg_01.mp4 -an -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
121
+ -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart assets/vid/shot-01.mp4
122
+ # Phone: 720 wide, a keyframe every 4 frames; a phone decoder pays more per frame.
123
+ ffmpeg -i leg_01_portrait.mp4 -an -vf scale=720:-2 -c:v libx264 -preset slow -crf 23 -pix_fmt yuv420p \
124
+ -g 4 -keyint_min 4 -sc_threshold 0 -movflags +faststart assets/vid/shot-01-m.mp4
125
+ # Poster: the clip's first frame. It is the LCP image and the reduced-motion fallback.
126
+ ffmpeg -v error -i assets/vid/shot-01.mp4 -frames:v 1 -c:v libwebp -quality 82 assets/shot-01.webp
127
+ ```
128
+
129
+ One recipe for every clip in the chain. All-intra (`-g 1`) is the last resort for a low-end phone
130
+ that still stutters, at about three times the size.
131
+
132
+ ## 6. Phones get their own chain, not a crop
133
+
134
+ A 16:9 film on a portrait phone shows its middle quarter. When the user asked for phones, render
135
+ the chain again at 9:16, frame-locked to its own renders. A landscape frame inside the portrait
136
+ chain pops on both sides. Start images: each still composited onto a 1080×1920 canvas in the page
137
+ background colour. The encodes are the `-m.mp4` files above, and each portrait clip's first frame is
138
+ its `posterMobile`.
139
+
140
+ No budget for it: the engine crops the landscape film with `object-fit: cover`, steered by
141
+ `--film-focus`. Tell the user this is a stopgap. Never ship it as "the mobile version".
142
+
143
+ ## 7. Mount the page
144
+
145
+ Copy `assets/scrub-engine.js` and `assets/page-template.html` into the project. The template is a
146
+ working page: its copy is real HTML that reads without JavaScript, and the script moves it onto the
147
+ film once the engine mounts.
148
+
149
+ ```js
150
+ const film = ScrollFilm.mount(document.getElementById('film'), {
151
+ shots: [
152
+ { section: 0, src: 'assets/vid/shot-01.mp4', srcMobile: 'assets/vid/shot-01-m.mp4',
153
+ poster: 'assets/shot-01.webp', posterMobile: 'assets/shot-01-m.webp', length: 1.4, hold: 0.4 },
154
+ { section: 0, src: 'assets/vid/hop-01.mp4', poster: 'assets/hop-01.webp', length: 0.9 },
155
+ // one entry per clip, in chain order
156
+ ],
157
+ onChange: ({ section, sectionProgress }) => { /* fade the copy block for `section` */ },
158
+ });
159
+ ```
160
+
161
+ What the engine does, and the failure each part prevents:
162
+
163
+ - **Loads each clip as a Blob.** On a host that ignores byte-range requests, a streamed `<video>`
164
+ reports `seekable` as [0, 0] and every seek lands on frame 0. A Blob is always seekable.
165
+ - **Keeps one seek in flight per clip.** Queued seeks are what freeze a phone on a fast flick.
166
+ - **Keeps the poster up until a frame has painted**, and primes each clip with a muted play and
167
+ pause, again on the first touch. iOS paints nothing for a seeked video that never played.
168
+ - **Picks phone sources at mount**, ignores height-only resizes on touch (the URL bar sliding), and
169
+ re-lays out on rotation.
170
+ - **Reduced motion or Save-Data:** no video is fetched; posters dissolve with the scroll and the
171
+ copy still works.
172
+ - **Fetches a clip once**, when the scroll comes within 1.5 viewports of it.
173
+ - **`destroy()`** removes everything it added. Call it from the effect cleanup in React or Vue.
174
+
175
+ `design` bans raw scroll listeners for animation. This one is passive and only requests a frame. If
176
+ the page already owns a scroll source (ScrollTrigger, `useScroll`), pass `listen: false` and call
177
+ `film.update()` from it. All options, the state object, theming, pacing and the Next.js and Vue
178
+ wiring → `references/engine.md`.
179
+
180
+ The film layer is `aria-hidden`; the meaning lives in the copy. Its words → `../landing-copy/SKILL.md`.
181
+ Its type, colour and spacing → `../design/SKILL.md`.
182
+
183
+ ## 8. Verify before calling it done
184
+
185
+ 1. **The chain, by script.** Pass the clips in chain order:
186
+
187
+ ```bash
188
+ bash <skill-dir>/scripts/verify.sh --frames qa/seams \
189
+ assets/vid/shot-01.mp4 assets/vid/hop-01.mp4 assets/vid/shot-02.mp4
190
+ bash <skill-dir>/scripts/verify.sh --gop 4 --portrait \
191
+ assets/vid/shot-01-m.mp4 assets/vid/hop-01-m.mp4 assets/vid/shot-02-m.mp4
192
+ ```
193
+
194
+ It checks each clip's encode (H.264, yuv420p, no audio, index first, keyframe interval), that
195
+ the chain shares one size and frame rate, and scores every seam against the clips' own
196
+ frame-to-frame step. A fixed SSIM threshold cannot tell: on a soft landscape photo, a cut to a
197
+ different picture still scored 0.76 and a fresh render of the same scene 0.92. Measured against
198
+ the step (0.98 there), the frame-locked seam passed at 0.987 and both others were flagged. Open
199
+ every flagged pair in `qa/seams/` and judge by what you see: a wrong seam shows different objects.
200
+ 2. **In a browser.** Scroll slowly across each seam on desktop. Then emulate a phone at 4 to 6× CPU
201
+ throttle and flick fast. Turn reduced motion on: posters only. In the Network panel, the phone
202
+ gets the `-m.mp4` files and the desktop gets the masters.
203
+ 3. **On a real iPhone**, if phones shipped: the first scene shows at once and no scene goes blank.
204
+
205
+ ## Anti-patterns
206
+
207
+ | Failure mode | Usually | Fix |
208
+ | --- | --- | --- |
209
+ | A visible pop at one seam | The hop's end frame was the original still, or came from `-sseof` a few frames early | Extract the true frames from the neighbouring renders (section 4) |
210
+ | The camera seems to rewind at every seam | Velocity reverses across the seam: B in a realistic walkthrough, or an A leg given a wide end frame | Shape A with the settle-into-drift clauses and no end frame |
211
+ | Stuck on the first frame in production, fine locally | The host ignores byte ranges | Load as Blob; keep it when porting the engine to a framework |
212
+ | A phone freezes on a fast flick | A 1080p, 8-frame-GOP master on a phone decoder, plus queued seeks | `-m.mp4` encodes at GOP 4; keep one seek in flight |
213
+ | Black scenes on iPhone until the first touch | The poster was hidden on `loadedmetadata`, or `muted`/`playsinline` lost in a port | Keep the poster until a frame paints; keep both attributes |
214
+ | The film scrolls away instead of sticking | An ancestor has `overflow: hidden`, often `body { overflow-x: hidden }` | `overflow-x: clip` |
215
+ | A cheaper model "just for the hops" | Each model renders its own grain and motion | One model for the chain; previz on the same model at low resolution |
216
+ | A content filter blocks a harmless interior | Words like bed, pool, spa or wine in the prompt | Re-roll, neutral wording, then the remedies in `references/chain.md` |
217
+
218
+ ## Orientación (siempre)
219
+
220
+ Habla con la voz de `orient`: frases cortas, una idea por frase, y cada respuesta se entiende sola. Registro técnico o con analogías según `technical_level` en `02-DOCS/wiki/harness/user-profile.md`. Cierra cada turno con el **bloque-brújula** (📍 dónde estás · ➡️ siguiente, terminando en pregunta; ✅ y 🧭 cuando hay algo hecho o decidido). **Nunca termines en seco.** Protocolo completo: skill `orient` → `skills/orient/references/orientation-contract.md`. (Defiere a `suggest` el "¿instalo la skill que falta?".)
@@ -0,0 +1,128 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Brand — one-line promise</title>
7
+ <meta name="description" content="One sentence on what the brand does, for search and link previews.">
8
+ <!-- The first poster is the LCP image: fetch it before the engine asks for it. -->
9
+ <link rel="preload" as="image" href="assets/shot-01.webp" fetchpriority="high">
10
+ <style>
11
+ :root {
12
+ --film-bg: #f3ede4; /* match the scene background so posters and seams sit on it */
13
+ --ink: #1e1a22;
14
+ --ink-soft: #5b5462;
15
+ --accent: #6d55b8;
16
+ --film-focus: 50% 45%; /* where object-fit: cover keeps the subject when it crops */
17
+ }
18
+ html { background: var(--film-bg); color: var(--ink); font: 400 1rem/1.55 system-ui, sans-serif; }
19
+ /* clip, never hidden: overflow hidden turns body into a scroll container and the stage stops sticking */
20
+ body { margin: 0; overflow-x: clip; }
21
+ a { color: inherit; }
22
+ .skip { position: absolute; left: -9999px; }
23
+ .skip:focus { left: 16px; top: 16px; z-index: 2000; background: var(--ink); color: var(--film-bg); padding: 8px 14px; border-radius: 8px; }
24
+ .site-head { position: fixed; inset: 0 0 auto; z-index: 1500; display: flex; justify-content: space-between; align-items: center; padding: 16px clamp(16px, 5vw, 56px); }
25
+ .eyebrow { margin: 0 0 12px; font-size: .8rem; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); }
26
+ .film-copy h1, .film-copy h2 { margin: 0; font-size: clamp(2rem, 4.4vw, 3.4rem); line-height: 1.05; letter-spacing: -.01em; }
27
+ .film-copy p { margin: 16px 0 0; max-width: 38ch; color: var(--ink-soft); font-size: clamp(1rem, 1.3vw, 1.15rem); }
28
+ .cta { display: inline-block; margin-top: 24px; padding: 13px 24px; border-radius: 999px; background: var(--ink); color: var(--film-bg); text-decoration: none; font-weight: 600; }
29
+
30
+ /* Without JavaScript the copy is a plain stacked page: readable, indexable, nothing hidden. */
31
+ .film-copy article { padding: 18vh clamp(16px, 6vw, 80px); }
32
+
33
+ /* With the engine mounted, the copy rides the sticky stage and the scroll drives its opacity. */
34
+ .sf-root .film-copy { position: absolute; inset: 0; }
35
+ .sf-root .film-copy::before { content: ""; position: absolute; inset: 0 auto 0 0; width: min(60vw, 760px);
36
+ background: linear-gradient(90deg, var(--film-bg) 0%, color-mix(in srgb, var(--film-bg) 70%, transparent) 45%, transparent 100%); }
37
+ .sf-root .film-copy article { position: absolute; left: clamp(16px, 6vw, 80px); top: 50%; transform: translateY(-50%);
38
+ width: min(40vw, 460px); padding: 0; opacity: 0; }
39
+ .sf-root .film-copy article.is-current { pointer-events: auto; }
40
+ @media (max-width: 860px) {
41
+ .sf-root .film-copy::before { inset: auto 0 0 0; width: auto; height: 60%;
42
+ background: linear-gradient(0deg, var(--film-bg) 10%, color-mix(in srgb, var(--film-bg) 65%, transparent) 50%, transparent 100%); }
43
+ .sf-root .film-copy article { top: auto; transform: none; left: 16px; right: 16px; width: auto;
44
+ bottom: calc(10svh + env(safe-area-inset-bottom)); }
45
+ }
46
+
47
+ .after-film { position: relative; z-index: 1; padding: 20vh clamp(16px, 6vw, 80px); background: var(--film-bg); }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <a class="skip" href="#after-film">Skip the film</a>
52
+ <header class="site-head"><strong>Brand</strong><a href="#contact">Contact</a></header>
53
+
54
+ <main>
55
+ <section id="film" aria-label="The journey, from first step to finished product">
56
+ <div class="film-copy">
57
+ <article data-section="0">
58
+ <p class="eyebrow">Where it starts</p>
59
+ <h1>Headline for the opening scene.</h1>
60
+ <p>One sentence, from the visitor's side.</p>
61
+ </article>
62
+ <article data-section="1">
63
+ <p class="eyebrow">Step two</p>
64
+ <h2>Headline for the second scene.</h2>
65
+ <p>One sentence, from the visitor's side.</p>
66
+ </article>
67
+ <article data-section="2">
68
+ <p class="eyebrow">The payoff</p>
69
+ <h2>Headline for the finale.</h2>
70
+ <p>One sentence that earns the click.</p>
71
+ <a class="cta" href="#contact">The one action</a>
72
+ </article>
73
+ </div>
74
+ </section>
75
+
76
+ <section id="after-film" class="after-film">
77
+ <h2 id="contact">The rest of the page scrolls normally after the film.</h2>
78
+ </section>
79
+ </main>
80
+
81
+ <script src="scrub-engine.js"></script>
82
+ <script>
83
+ (function () {
84
+ var copy = document.querySelector('#film .film-copy');
85
+ var blocks = Array.prototype.slice.call(copy.querySelectorAll('article'));
86
+ var last = blocks.length - 1;
87
+ var EDGE = 0.15; // share of a section spent fading its copy in and out
88
+
89
+ var film = ScrollFilm.mount(document.getElementById('film'), {
90
+ // Architecture B shown: dive, connector, dive, connector, dive. A forward take (A) is
91
+ // the same list with one leg per section and no connectors.
92
+ shots: [
93
+ { section: 0, src: 'assets/vid/shot-01.mp4', srcMobile: 'assets/vid/shot-01-m.mp4',
94
+ poster: 'assets/shot-01.webp', posterMobile: 'assets/shot-01-m.webp', length: 1.4, hold: 0.4 },
95
+ { section: 0, src: 'assets/vid/hop-01.mp4', srcMobile: 'assets/vid/hop-01-m.mp4',
96
+ poster: 'assets/hop-01.webp', posterMobile: 'assets/hop-01-m.webp', length: 0.9 },
97
+ { section: 1, src: 'assets/vid/shot-02.mp4', srcMobile: 'assets/vid/shot-02-m.mp4',
98
+ poster: 'assets/shot-02.webp', posterMobile: 'assets/shot-02-m.webp', length: 1.2, hold: 0.3 },
99
+ { section: 1, src: 'assets/vid/hop-02.mp4', srcMobile: 'assets/vid/hop-02-m.mp4',
100
+ poster: 'assets/hop-02.webp', posterMobile: 'assets/hop-02-m.webp', length: 0.9 },
101
+ { section: 2, src: 'assets/vid/shot-03.mp4', srcMobile: 'assets/vid/shot-03-m.mp4',
102
+ poster: 'assets/shot-03.webp', posterMobile: 'assets/shot-03-m.webp', length: 1.6, hold: 0.5 },
103
+ ],
104
+ onChange: function (state) {
105
+ blocks.forEach(function (block, i) {
106
+ var opacity = 0;
107
+ if (i === state.section) {
108
+ var p = state.sectionProgress;
109
+ var fadeIn = i === 0 ? 1 : Math.min(1, p / EDGE); // the first block greets on load
110
+ var fadeOut = i === last ? 1 : Math.min(1, (1 - p) / EDGE); // the last one holds its CTA
111
+ opacity = Math.min(fadeIn, fadeOut);
112
+ }
113
+ block.style.opacity = String(opacity);
114
+ block.classList.toggle('is-current', opacity > 0.5);
115
+ });
116
+ },
117
+ });
118
+
119
+ film.overlay.appendChild(copy);
120
+
121
+ // Keyboard users tab into copy that may be faded out: bring its scene to them.
122
+ blocks.forEach(function (block) {
123
+ block.addEventListener('focusin', function () { film.jumpTo(Number(block.dataset.section)); });
124
+ });
125
+ })();
126
+ </script>
127
+ </body>
128
+ </html>