@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 +5 -4
- package/manifest.json +28 -3
- package/package.json +1 -1
- package/scripts/lib/domains.js +1 -1
- package/skills/ai-media/SKILL.md +2 -2
- package/skills/cost-tracking/SKILL.md +2 -1
- package/skills/design/references/motion-and-interaction.md +4 -0
- package/skills/llm-pipeline/SKILL.md +1 -1
- package/skills/scroll-film/SKILL.md +220 -0
- package/skills/scroll-film/assets/page-template.html +128 -0
- package/skills/scroll-film/assets/scrub-engine.js +352 -0
- package/skills/scroll-film/evals/README.md +26 -0
- package/skills/scroll-film/evals/cases.yaml +81 -0
- package/skills/scroll-film/references/chain.md +218 -0
- package/skills/scroll-film/references/engine.md +132 -0
- package/skills/scroll-film/scripts/verify.sh +158 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/@ericrisco/rsc)
|
|
10
10
|
[](https://www.npmjs.com/package/@ericrisco/rsc)
|
|
11
|
-
[](#the-catalog)
|
|
12
12
|
[](LICENSE)
|
|
13
13
|
[](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:
|
|
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
|
-
|
|
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
|
+
"version": "3.1.4",
|
|
3
3
|
"counts": {
|
|
4
|
-
"skills":
|
|
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
package/scripts/lib/domains.js
CHANGED
|
@@ -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
|
];
|
package/skills/ai-media/SKILL.md
CHANGED
|
@@ -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
|
|
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>
|