cinematic-scroll-skill 2.1.0
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/COMPATIBILITY.md +244 -0
- package/LICENSE +21 -0
- package/MODELS.md +92 -0
- package/README.md +250 -0
- package/SKILL.md +1003 -0
- package/audit-mode.md +497 -0
- package/bin/install.mjs +91 -0
- package/compile-choreography.mjs +296 -0
- package/decision-log.md +241 -0
- package/examples/GETTING_STARTED.md +279 -0
- package/examples/KNOWN_ISSUES.md +50 -0
- package/examples/PROMPTS.md +166 -0
- package/examples/luxe/README.md +88 -0
- package/examples/luxe/index.html +662 -0
- package/examples/noir/README.md +72 -0
- package/examples/noir/index.html +634 -0
- package/examples/pop/README.md +81 -0
- package/examples/pop/index.html +711 -0
- package/examples/renaissance/README.md +39 -0
- package/examples/renaissance/index.html +648 -0
- package/examples/studio/README.md +77 -0
- package/examples/studio/chapters.js +105 -0
- package/examples/studio/index.html +520 -0
- package/manifest.json +92 -0
- package/manifest.md +136 -0
- package/package.json +56 -0
- package/references/film-archetypes.md +211 -0
- package/references/performance-budget.md +499 -0
- package/references/scroll-patterns.md +693 -0
- package/scroll-choreography-compilation.md +543 -0
- package/scroll-choreography.json +1512 -0
- package/taste-guardrails.md +164 -0
- package/templates/nextjs/.env.example +41 -0
- package/templates/nextjs/app/api/fal/proxy/route.ts +33 -0
- package/templates/nextjs/app/api/fal/webhook/route.ts +132 -0
- package/templates/nextjs/app/api/generate-edition-asset/route.ts +66 -0
- package/templates/nextjs/app/globals.css +80 -0
- package/templates/nextjs/app/layout.tsx +21 -0
- package/templates/nextjs/app/page.tsx +10 -0
- package/templates/nextjs/components/ChapterDemoVisual.tsx +212 -0
- package/templates/nextjs/components/ChapterScene.tsx +373 -0
- package/templates/nextjs/components/EditionsPage.tsx +116 -0
- package/templates/nextjs/components/SmoothScrollProvider.tsx +8 -0
- package/templates/nextjs/lib/api-guard.ts +110 -0
- package/templates/nextjs/lib/editions-manifest.ts +224 -0
- package/templates/nextjs/lib/fal-client.ts +12 -0
- package/templates/nextjs/lib/fal-generate.ts +86 -0
- package/templates/nextjs/lib/fal-models.ts +213 -0
- package/templates/nextjs/lib/prompt-contract.ts +97 -0
- package/templates/nextjs/lib/use-device.ts +42 -0
- package/templates/nextjs/lib/use-lenis.ts +35 -0
- package/templates/nextjs/next.config.ts +29 -0
- package/templates/nextjs/package-lock.json +6455 -0
- package/templates/nextjs/package.json +41 -0
- package/templates/nextjs/package.patch.json +28 -0
- package/templates/nextjs/postcss.config.js +6 -0
- package/templates/nextjs/scripts/generate-chapter-assets.mjs +243 -0
- package/templates/nextjs/scripts/setup.mjs +170 -0
- package/templates/nextjs/tailwind.config.ts +37 -0
- package/templates/nextjs/tsconfig.json +23 -0
- package/troubleshooting.md +1284 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Taste Guardrails
|
|
2
|
+
|
|
3
|
+
> The difference between slop and craft is anti-convergence.
|
|
4
|
+
> This skill refuses to produce generic parallax.
|
|
5
|
+
|
|
6
|
+
These rules are non-negotiable. They exist because every broken scroll site violates at least three of them. An agent skill that does not enforce taste produces tasteless output — regardless of how good the prompt is.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Banned Patterns
|
|
11
|
+
|
|
12
|
+
The following patterns are prohibited in all generated output. No exceptions, no "just this once."
|
|
13
|
+
|
|
14
|
+
### 1.1 Never animate `blur()`, `brightness()`, `contrast()`, or any CSS `filter` during scroll
|
|
15
|
+
**Why:** Filters force a full paint-composite cycle on every frame. On mid-tier mobile GPUs this drops you to 20-30fps instantly. The browser cannot cache filtered layers the same way it caches transform layers.
|
|
16
|
+
**Replacement:** Use crossfades between pre-blurred image assets, or fake depth with opacity + scale layering. If you need a rack-focus effect, crossfade two stacked image layers at different scales — never animate the filter itself.
|
|
17
|
+
|
|
18
|
+
### 1.2 Never scroll-jack content shorter than 800px
|
|
19
|
+
**Why:** Scroll-jacking (hijacking native scroll behavior for pinned sections) is a contract with the user: you are asking them to surrender control in exchange for a curated experience. If the payoff is less than one viewport tall, the contract is broken. The user feels tricked, not delighted.
|
|
20
|
+
**Replacement:** Let short content flow naturally. Reserve pinning for sections with genuine narrative or visual payoff — title choreography, multi-layer depth reveals, or 3D camera moves.
|
|
21
|
+
|
|
22
|
+
### 1.3 Never pin more than 3 consecutive sections without a "release viewport"
|
|
23
|
+
**Why:** Three pinned sections in a row creates scroll fatigue. The user loses their sense of progress. Page position stops correlating with scroll position, and the cognitive dissonance builds until they rage-quit.
|
|
24
|
+
**Replacement:** After every 3 pinned sections, insert at least 80vh of free-scrolling "breathing room" — a content section, a footer transition, or a clean chapter break. Let the user feel their scroll wheel working again.
|
|
25
|
+
|
|
26
|
+
### 1.4 Never apply parallax to text content below 18px
|
|
27
|
+
**Why:** Small text in motion destroys readability. The eye cannot track parallax-shifted microcopy. It becomes visual noise, not information.
|
|
28
|
+
**Replacement:** Keep body copy at `position: relative` with no scroll-driven transforms. Reserve parallax for display type (48px+), background layers, and decorative elements only.
|
|
29
|
+
|
|
30
|
+
### 1.5 Never call `setState` (React) inside a scroll handler — ever
|
|
31
|
+
**Why:** React state updates trigger re-renders. At 60fps, you are asking React to re-render your component tree 60 times per second. On a complex page, this creates jank that no amount of memoization will fix.
|
|
32
|
+
**Replacement:** Use refs and direct DOM manipulation for scroll-driven values. GSAP's `quickTo`, Framer Motion's `useTransform`, or raw `ref.current.style.transform` assignments. Keep React for structural updates only — never for per-frame values.
|
|
33
|
+
|
|
34
|
+
### 1.6 Never animate `width`, `height`, `top`, `left`, `margin`, or `padding`
|
|
35
|
+
**Why:** These properties trigger layout recalculation (the "layout thrash"). The browser must recompute the position of every affected element, then paint, then composite. This is a 3-4ms penalty per frame on desktop, 10-15ms on mobile. At 60fps you have 16.67ms total.
|
|
36
|
+
**Replacement:** Use `transform: scale()` for size changes, `transform: translate()` for position changes. If you need content to reflow, toggle a CSS class and let a `transition` handle it — never drive it from a scroll scrubber.
|
|
37
|
+
|
|
38
|
+
### 1.7 Never use more than 7 depth layers per chapter
|
|
39
|
+
**Why:** Each parallax layer is a composited layer in the GPU. Seven layers at high resolution consume significant VRAM. Beyond seven, you risk memory pressure that causes the browser to drop layers back to CPU rasterization — catastrophically slow.
|
|
40
|
+
**Replacement:** Be selective. 3-4 layers is often enough if the content is strong. Use opacity and scale to fake additional depth without extra layers. The best parallax feels deep with 4 layers; the worst parallax feels flat with 12.
|
|
41
|
+
|
|
42
|
+
### 1.8 Never attach a scroll listener without rAF throttling or a scrub proxy
|
|
43
|
+
**Why:** Raw `scroll` events fire at irregular intervals and can fire multiple times per frame. Reading `scrollY` and updating the DOM synchronously creates inconsistent motion and missed frames.
|
|
44
|
+
**Replacement:** Use Lenis (`requestAnimationFrame`-based smooth scroll), GSAP ScrollTrigger (which internally uses rAF), or a hand-rolled rAF loop that reads scroll position once per frame. Never update layout from inside a raw `addEventListener('scroll')` callback.
|
|
45
|
+
|
|
46
|
+
### 1.9 Never apply 3D rotation (`rotateX`, `rotateY`, `perspective` tilt) on touch devices or when `prefers-reduced-motion` is active
|
|
47
|
+
**Why:** 3D tilt on touch devices causes motion sickness for a non-trivial percentage of users (vestibular disorders). It also conflicts with native touch gestures — the browser may interpret rotateY as a swipe intent.
|
|
48
|
+
**Replacement:** On touch devices, replace 3D tilt with subtle scale + opacity fades. Respect `prefers-reduced-motion: reduce` by disabling all scroll-driven motion and showing static compositions instead. See the reduced-motion fallback spec in `SKILL.md`.
|
|
49
|
+
|
|
50
|
+
### 1.10 Never auto-play scroll-driven motion without user interaction
|
|
51
|
+
**Why:** Auto-scrolling or auto-playing pinned sections (via `setInterval`, `ScrollTrigger.to`, or similar) violates user agency. It also breaks screen readers and keyboard navigation.
|
|
52
|
+
**Replacement:** All motion must be scroll-driven or user-triggered. If you want a "playthrough" experience, provide a prominent "Play intro" button that calls `gsap.to(window, { scrollTo: ... })` — once, on user request.
|
|
53
|
+
|
|
54
|
+
### 1.11 Never use the same easing curve for every animation in a chapter
|
|
55
|
+
**Why:** Uniform easing makes motion feel mechanical — like a PowerPoint transition, not cinema. Real movement has variation: anticipation, overshoot, decay, snap.
|
|
56
|
+
**Replacement:** Vary easings by role. Hero entrances get `power3.out` (dramatic deceleration). Exits get `power2.in` (clean acceleration away). Micro-interactions get `back.out(1.4)` (playful overshoot). Chapter transitions get `power4.inOut` (weighty, deliberate).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 2. Cinematic Vocabulary
|
|
61
|
+
|
|
62
|
+
Web scroll is not "web design." It is **digital cinematography**. Every scroll behavior maps to a film grammar. Use the right term, implement the right motion, and the site will feel cinematic instead of gimmicky.
|
|
63
|
+
|
|
64
|
+
| Film Term | Scroll Equivalent | CSS/GSAP Implementation | Use When |
|
|
65
|
+
|-----------|------------------|------------------------|----------|
|
|
66
|
+
| **Dolly zoom** (Vertigo effect) | Background scales while foreground stays fixed | `scale(1 → 1.3)` on background layer + `translateZ(0 → -200px)`; foreground `scale` counter-animates to maintain size | Hero reveal, dramatic entrance, conveying disorientation or revelation |
|
|
67
|
+
| **Whip pan** | Fast horizontal snap between chapters | `translateX(-100vw → 0)` with `power4.inOut` easing, 0.4s duration; content blurs via motion, not filter | Chapter transition, genre shifts, tonal whiplash |
|
|
68
|
+
| **Rack focus** | Shifting attention between depth planes | Crossfade between two stacked layers at different scales — NOT CSS `filter: blur()`. Top layer fades out as bottom layer fades in, with 10-15% overlap | Shifting subject focus, narrative handoffs, revealing hidden detail |
|
|
69
|
+
| **Tracking shot** | Parallax at medium depth, steady camera | `translateY` at 0.5x scroll rate on mid-layer; foreground and background at 0.2x and 0.8x respectively | Narrative scroll, storytelling sequences, guided attention |
|
|
70
|
+
| **Crane shot** | Vertical dolly + subtle rotation | `translateY` + `rotateX(±4deg)` driven by scroll progress; perspective origin at `50% 100%` | Opening sequence, establishing shot, conveying scale and grandeur |
|
|
71
|
+
| **Static two-shot** | Split viewport, both subjects visible, no motion | Two 50vw columns, both `position: sticky`, zero parallax; tension comes from contrast, not movement | Comparisons, debates, dual narratives, before/after |
|
|
72
|
+
| **Match cut** | Identical composition, content swap | Same layout, same element positions; content crossfades with `opacity` while layout holds perfectly still | Category switches, product variants, timeline jumps |
|
|
73
|
+
| **Push-in** | Slow zoom toward a subject | `scale(1 → 1.08)` over 200vh of scroll, combined with `translateY` to keep subject centered; minimal other motion | Intensifying focus, emotional escalation, "the moment before" |
|
|
74
|
+
| **Montage** | Rapid cuts, stacked cards, snap scroll | Multiple pinned sections at 80vh each, snap scroll between them (`snap: 1 / (sections - 1)`), 0.15s transitions | Showcasing variety, process steps, portfolio grids |
|
|
75
|
+
| **Long take** | Single continuous pinned section with layered reveals | One 300vh pin; elements reveal sequentially via staggered `opacity` + `translateY` tied to scroll progress; no snapping, no hard cuts | Immersive narrative, world-building, letting the user explore at their own pace |
|
|
76
|
+
| **Overhead / God shot** | Scale-down from full frame to reveal surrounding context | `scale(1.5 → 1)` + `translateY(20% → 0)` over pin duration; starts tight on detail, pulls back to show full layout | Revealing structure, "where are we" moments, architectural showcases |
|
|
77
|
+
| **Jump scare** (comedic) | Sudden scale snap + rotation | `scale(0.8 → 1.05)` with `back.out(2)` + `rotateZ(-2deg → 0deg)` triggered at scroll threshold; 0.3s duration | Playful reveals, Easter eggs, Gen-Z energy moments |
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 3. Pacing Rules
|
|
82
|
+
|
|
83
|
+
Timing is not a matter of taste. These are working defaults informed by how scroll motion reads perceptually — strong starting points to adjust with intent, not arbitrary numbers.
|
|
84
|
+
|
|
85
|
+
### 3.1 Default rhythm
|
|
86
|
+
**1.2s of scroll distance per 100vh of content.** If a section is 200vh tall, the user should spend approximately 2.4 seconds scrolling through it at normal speed. This is the baseline — adjust ±20% for dramatic effect, but never violate it without explicit intent.
|
|
87
|
+
|
|
88
|
+
### 3.2 Pin duration minimum
|
|
89
|
+
**150vh.** Anything shorter and the pin feels like a glitch — the user hasn't mentally settled into the fixed frame before it releases. The content hasn't "landed."
|
|
90
|
+
|
|
91
|
+
### 3.3 Pin duration maximum
|
|
92
|
+
**400vh.** Beyond this, users think the page is broken. They try to scroll harder, check their mouse, assume the tab froze. If your content genuinely needs more than 400vh, split it into two pinned sections with a 50vh breathing room between.
|
|
93
|
+
|
|
94
|
+
### 3.4 Chapter transition breathing room
|
|
95
|
+
**Minimum 80vh of free-scroll space between pinned chapters.** This is the "cut" between scenes. Without it, chapters bleed into each other and the narrative structure collapses.
|
|
96
|
+
|
|
97
|
+
### 3.5 Title reveal duration
|
|
98
|
+
**30-40% of the total pin scroll range.** If a section is pinned for 200vh, the title choreography should occupy 60-80vh of that range. The title must finish revealing before the 70% mark of the pin — the final 30% is for the payoff, the "so what" moment.
|
|
99
|
+
|
|
100
|
+
### 3.6 Stagger offset
|
|
101
|
+
**5-8% per element, maximum 5 elements before overlap.** If you stagger more than 5 elements, the early ones finish before the late ones start — the user perceives it as random, not choreographed. For 6+ elements, group them into visual clusters and stagger the clusters instead.
|
|
102
|
+
|
|
103
|
+
### 3.7 Scroll snap dead zone
|
|
104
|
+
**Never snap within 10vh of a pin start or end.** The snap point must sit comfortably inside the pinned range, not at the boundary. Boundary snaps feel like the scroll is fighting the user.
|
|
105
|
+
|
|
106
|
+
### 3.8 Motion density limit
|
|
107
|
+
**No more than 3 simultaneous motion types in any 50vh window.** If you have parallax, title stagger, and a color morph, you cannot also have 3D tilt and a progress HUD animation in the same viewport. The eye cannot process it. Pick the 3 most important motions and let the others rest.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 4. Anti-Convergence Principles
|
|
112
|
+
|
|
113
|
+
These rules exist to prevent the output from looking like every other scroll-driven website on Awwwards. Convergence is the enemy. Generic parallax, default easings, and center-aligned everything are symptoms of the same disease: lack of intention.
|
|
114
|
+
|
|
115
|
+
### 4.1 Never use default easing
|
|
116
|
+
`ease`, `ease-in-out`, and `linear` are banned. Every animation must specify a custom `cubic-bezier` or a named GSAP easing with intentional character. Default easing signals default thinking.
|
|
117
|
+
- **Hero entrances:** `cubic-bezier(0.16, 1, 0.3, 1)` (dramatic deceleration — the "reveal" feel)
|
|
118
|
+
- **Chapter exits:** `cubic-bezier(0.7, 0, 0.84, 0)` (clean acceleration — the "handoff" feel)
|
|
119
|
+
- **Micro-interactions:** `cubic-bezier(0.34, 1.56, 0.64, 1)` (overshoot — the "playful" feel)
|
|
120
|
+
- **Transitions:** `cubic-bezier(0.87, 0, 0.13, 1)` (heavy, deliberate — the "chapter cut" feel)
|
|
121
|
+
|
|
122
|
+
### 4.2 Never center-align all text
|
|
123
|
+
Centered text is the first sign of a template. Use intentional asymmetry: left-align body copy, center only display titles (and not all of them), and occasionally right-align pull quotes or metadata. Asymmetry creates visual tension. Tension creates interest.
|
|
124
|
+
|
|
125
|
+
### 4.3 Never repeat a depth multiplier
|
|
126
|
+
If Layer 1 moves at 0.2x scroll rate and Layer 2 at 0.5x, Layer 3 cannot be 0.8x followed by Layer 4 at 1.0x in the next chapter. Vary the spacing: 0.15x, 0.4x, 0.7x, 1.0x in one chapter; 0.1x, 0.35x, 0.6x, 0.9x in the next. Repetition of depth ratios creates a rhythmic monotony the user cannot name but will feel.
|
|
127
|
+
|
|
128
|
+
### 4.4 Never repeat a transition type between adjacent chapters
|
|
129
|
+
If Chapter 1 uses a whip-pan exit, Chapter 2 cannot use a whip-pan entry. Alternate transition families: fade → slide → scale → rotate. Adjacent chapters using the same transition family feel like a single long chapter that forgot to end.
|
|
130
|
+
|
|
131
|
+
### 4.5 Always vary title treatment between chapters
|
|
132
|
+
Each chapter must have a distinct title reveal style. Rotate through this vocabulary:
|
|
133
|
+
- **Mask reveal:** `clip-path: inset()` animates to reveal text (dramatic, editorial)
|
|
134
|
+
- **Word stagger:** Each word fades + translates in with 0.08s offset (narrative, literary)
|
|
135
|
+
- **Letter-spacing scrub:** `letter-spacing` expands from `-0.05em` to `0.02em` tied to scroll (refined, luxury)
|
|
136
|
+
- **Scale-down entrance:** Title starts at 1.3x scale, settles to 1.0x with `power2.out` (impactful, bold)
|
|
137
|
+
- **Blur crossfade:** Two title copies crossfade — one sharp, one pre-blurred, swap opacity (soft, atmospheric)
|
|
138
|
+
- **Typewriter reveal:** Characters appear left-to-right with scroll progress (technical, precise)
|
|
139
|
+
- **Split line rise:** Each line of a multi-line title rises from `translateY(40px)` with stagger (editorial, magazine)
|
|
140
|
+
|
|
141
|
+
Never use the same treatment twice in a row. Never.
|
|
142
|
+
|
|
143
|
+
### 4.6 Never use the same palette temperature across all chapters
|
|
144
|
+
A site that is warm in Chapter 1, warm in Chapter 2, warm in Chapter 3 feels like a single photograph stretched too thin. Alternate temperature: warm → cool → neutral → warm. The contrast between chapters creates progression. Progression creates narrative.
|
|
145
|
+
|
|
146
|
+
### 4.7 Depth layers must earn their place
|
|
147
|
+
Every parallax layer must carry distinct visual information. If two layers are visually similar enough that removing one does not change the experience, merge them. Empty parallax is decoration masquerading as design.
|
|
148
|
+
|
|
149
|
+
### 4.8 Typography must breathe
|
|
150
|
+
Minimum `line-height: 1.1` for display type, `1.5` for body. Maximum 2 typefaces per chapter (one display, one body). If you need a third, use a weight or style variation of an existing family. More than 2 fonts in one viewport is visual cacophony.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 5. Enforcement
|
|
155
|
+
|
|
156
|
+
These guardrails are referenced in `SKILL.md` and are part of the agent's system prompt. When generating scroll-driven sections, the skill must:
|
|
157
|
+
|
|
158
|
+
1. Check every output against the Banned Patterns list before delivering.
|
|
159
|
+
2. Name the cinematic technique being used (from the Cinematic Vocabulary table) in the code comments.
|
|
160
|
+
3. Declare the pin duration, stagger offset, and easing curves in the section manifest.
|
|
161
|
+
4. Verify that no two adjacent chapters share a transition type or title treatment.
|
|
162
|
+
5. Include a reduced-motion fallback for every scroll-driven effect.
|
|
163
|
+
|
|
164
|
+
**Violating these rules is a bug, not a style choice.**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# Editions Scroll Generator — environment
|
|
3
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
4
|
+
#
|
|
5
|
+
# You DON'T need to fill this in to see the page. Run `npm run dev` and the
|
|
6
|
+
# 8 chapters render with CSS-only visuals (demo mode).
|
|
7
|
+
#
|
|
8
|
+
# Fill this in when you're ready for real AI-generated chapter heroes.
|
|
9
|
+
# Easiest path: run `npm run setup` — interactive wizard does it for you.
|
|
10
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
# Required for AI image generation. Get one at https://fal.ai/dashboard/keys
|
|
13
|
+
# Format: key_id:key_secret (two parts separated by ":")
|
|
14
|
+
# Stays server-side only via /api/fal/proxy — never sent to the browser.
|
|
15
|
+
FAL_KEY=""
|
|
16
|
+
|
|
17
|
+
# Default image model. See MODELS.md to swap to Nano Banana, Imagen 3, etc.
|
|
18
|
+
FAL_IMAGE_MODEL="fal-ai/flux-2-pro"
|
|
19
|
+
|
|
20
|
+
# Optional — only fill in if you want video chapter loops.
|
|
21
|
+
FAL_VIDEO_MODEL=""
|
|
22
|
+
|
|
23
|
+
# Safe to expose (used in the <title> tag only).
|
|
24
|
+
NEXT_PUBLIC_SITE_NAME="Editions Demo"
|
|
25
|
+
|
|
26
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
27
|
+
# Abuse protection for the fal.ai API routes (REQUIRED in production)
|
|
28
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# The /api/generate-edition-asset and /api/fal/proxy routes spend your FAL_KEY
|
|
30
|
+
# (each image is real money). This shared secret requires
|
|
31
|
+
# `Authorization: Bearer <GENERATE_API_SECRET>` on those routes.
|
|
32
|
+
# - Local dev (NODE_ENV !== production): may be empty; routes stay open for demos.
|
|
33
|
+
# - Production / Vercel preview & prod builds: if this is empty the routes FAIL
|
|
34
|
+
# CLOSED (generate -> 503, proxy -> rejects all) so nothing runs anonymously.
|
|
35
|
+
# Set it before deploying. To intentionally run the proxy open in pure local dev,
|
|
36
|
+
# use FAL_PROXY_ALLOW_UNAUTH below instead.
|
|
37
|
+
GENERATE_API_SECRET=""
|
|
38
|
+
|
|
39
|
+
# Set to "true" to run the fal proxy WITHOUT auth (pure local dev only).
|
|
40
|
+
# Never set this in a publicly reachable deployment — it opens a billable proxy.
|
|
41
|
+
FAL_PROXY_ALLOW_UNAUTH=""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createRouteHandler } from '@fal-ai/server-proxy/nextjs';
|
|
2
|
+
import { ALLOWED_FAL_ENDPOINTS } from '@/lib/fal-models';
|
|
3
|
+
import { isBearerValid } from '@/lib/api-guard';
|
|
4
|
+
|
|
5
|
+
export const runtime = 'nodejs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* fal.ai server proxy — keeps FAL_KEY off the client.
|
|
9
|
+
*
|
|
10
|
+
* Auth model:
|
|
11
|
+
* - `allowedEndpoints` restricts WHICH fal models the proxy will forward to,
|
|
12
|
+
* so a compromised client can't burn your account on arbitrary models.
|
|
13
|
+
* - `isAuthenticated` restricts WHO may reach it: by default the proxy requires
|
|
14
|
+
* `Authorization: Bearer <GENERATE_API_SECRET>` in EVERY environment.
|
|
15
|
+
*
|
|
16
|
+
* Previously this was keyed on `NODE_ENV !== 'production'`, which left Vercel
|
|
17
|
+
* preview/staging URLs (publicly reachable, built with NODE_ENV=production but
|
|
18
|
+
* often missing the secret) as open, billable fal proxies. Now it is secure by
|
|
19
|
+
* default; to intentionally run an open proxy (pure local dev), set
|
|
20
|
+
* FAL_PROXY_ALLOW_UNAUTH=true.
|
|
21
|
+
*
|
|
22
|
+
* Reference: https://fal.ai/docs/model-endpoints/server-side
|
|
23
|
+
*/
|
|
24
|
+
const allowUnauthenticated = process.env.FAL_PROXY_ALLOW_UNAUTH === 'true';
|
|
25
|
+
|
|
26
|
+
export const { GET, POST, PUT } = createRouteHandler({
|
|
27
|
+
allowedEndpoints: ALLOWED_FAL_ENDPOINTS.map((id) => `${id}/**`),
|
|
28
|
+
allowUnauthorizedRequests: allowUnauthenticated,
|
|
29
|
+
async isAuthenticated(behavior: { getHeader: (name: string) => string | null }) {
|
|
30
|
+
if (allowUnauthenticated) return true;
|
|
31
|
+
return isBearerValid(behavior.getHeader('authorization'));
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { createHash, createPublicKey, verify as ed25519Verify } from 'crypto';
|
|
3
|
+
|
|
4
|
+
export const runtime = 'nodejs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* fal.ai webhook receiver — called when a queued generation completes.
|
|
8
|
+
*
|
|
9
|
+
* Payload shape (from https://fal.ai/docs/model-endpoints/webhooks):
|
|
10
|
+
* {
|
|
11
|
+
* request_id: string,
|
|
12
|
+
* gateway_request_id: string,
|
|
13
|
+
* status: 'OK' | 'ERROR',
|
|
14
|
+
* payload: { images: [{ url, content_type, file_name, file_size, width, height }], seed },
|
|
15
|
+
* error?: string
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Production checklist:
|
|
19
|
+
* 1. [DONE] Verify the request signature against fal's ED25519 public keys
|
|
20
|
+
* (see verifyFalWebhook below). Unsigned / invalid requests get a 401.
|
|
21
|
+
* 2. Persist {request_id → asset_url} in your DB / KV so the client can poll
|
|
22
|
+
* or subscribe via SSE / Pusher / Ably.
|
|
23
|
+
* 3. Always return 200 quickly — fal retries non-2xx responses.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const JWKS_URL = 'https://rest.fal.ai/.well-known/jwks.json';
|
|
27
|
+
const JWKS_TTL_MS = 24 * 60 * 60 * 1000; // fal recommends caching keys up to 24h
|
|
28
|
+
const MAX_CLOCK_SKEW_SECONDS = 300; // reject requests older/newer than ±5 min (replay protection)
|
|
29
|
+
|
|
30
|
+
let jwksCache: { keys: string[]; fetchedAt: number } | null = null;
|
|
31
|
+
|
|
32
|
+
/** Fetch fal's ED25519 public keys (base64url `x` values), cached for 24h. */
|
|
33
|
+
async function getFalPublicKeys(): Promise<string[]> {
|
|
34
|
+
if (jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) {
|
|
35
|
+
return jwksCache.keys;
|
|
36
|
+
}
|
|
37
|
+
const res = await fetch(JWKS_URL, { cache: 'no-store' });
|
|
38
|
+
if (!res.ok) throw new Error(`Failed to fetch fal JWKS: ${res.status}`);
|
|
39
|
+
const jwks = (await res.json()) as { keys?: Array<{ x?: string }> };
|
|
40
|
+
const keys = (jwks.keys ?? []).map((k) => k.x).filter((x): x is string => Boolean(x));
|
|
41
|
+
jwksCache = { keys, fetchedAt: Date.now() };
|
|
42
|
+
return keys;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Verify a fal.ai webhook per https://fal.ai/docs/model-endpoints/webhooks.
|
|
47
|
+
*
|
|
48
|
+
* Signed message = `${requestId}\n${userId}\n${timestamp}\n${sha256hex(body)}`
|
|
49
|
+
* signed with ED25519; signature delivered as hex in X-Fal-Webhook-Signature.
|
|
50
|
+
*/
|
|
51
|
+
async function verifyFalWebhook(req: NextRequest, rawBody: string): Promise<boolean> {
|
|
52
|
+
const requestId = req.headers.get('x-fal-webhook-request-id');
|
|
53
|
+
const userId = req.headers.get('x-fal-webhook-user-id');
|
|
54
|
+
const timestamp = req.headers.get('x-fal-webhook-timestamp');
|
|
55
|
+
const signatureHex = req.headers.get('x-fal-webhook-signature');
|
|
56
|
+
|
|
57
|
+
if (!requestId || !userId || !timestamp || !signatureHex) return false;
|
|
58
|
+
|
|
59
|
+
// 1. Reject stale/future timestamps to blunt replay attacks.
|
|
60
|
+
const ts = Number(timestamp);
|
|
61
|
+
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > MAX_CLOCK_SKEW_SECONDS) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Rebuild the exact signed message.
|
|
66
|
+
const bodyHashHex = createHash('sha256').update(rawBody, 'utf8').digest('hex');
|
|
67
|
+
const message = Buffer.from(`${requestId}\n${userId}\n${timestamp}\n${bodyHashHex}`, 'utf8');
|
|
68
|
+
|
|
69
|
+
const signature = Buffer.from(signatureHex, 'hex');
|
|
70
|
+
if (signature.length !== 64) return false; // ED25519 signatures are 64 bytes
|
|
71
|
+
|
|
72
|
+
// 3. Accept if any current fal public key verifies the signature.
|
|
73
|
+
let keys: string[];
|
|
74
|
+
try {
|
|
75
|
+
keys = await getFalPublicKeys();
|
|
76
|
+
} catch {
|
|
77
|
+
return false; // fail closed if keys can't be fetched
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const x of keys) {
|
|
81
|
+
try {
|
|
82
|
+
const publicKey = createPublicKey({
|
|
83
|
+
key: { kty: 'OKP', crv: 'Ed25519', x },
|
|
84
|
+
format: 'jwk',
|
|
85
|
+
});
|
|
86
|
+
if (ed25519Verify(null, message, publicKey, signature)) return true;
|
|
87
|
+
} catch {
|
|
88
|
+
// malformed key — try the next one
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function POST(req: NextRequest) {
|
|
95
|
+
// Read the raw body once — needed verbatim for signature hashing.
|
|
96
|
+
const rawBody = await req.text();
|
|
97
|
+
|
|
98
|
+
const verified = await verifyFalWebhook(req, rawBody);
|
|
99
|
+
if (!verified) {
|
|
100
|
+
return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let body: {
|
|
104
|
+
request_id?: string;
|
|
105
|
+
gateway_request_id?: string;
|
|
106
|
+
status?: 'OK' | 'ERROR';
|
|
107
|
+
payload?: { images?: Array<{ url?: string }>; seed?: number };
|
|
108
|
+
error?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
body = JSON.parse(rawBody);
|
|
113
|
+
} catch {
|
|
114
|
+
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const chapterId = req.nextUrl.searchParams.get('chapter') ?? 'unknown';
|
|
118
|
+
const imageUrl = body.payload?.images?.[0]?.url;
|
|
119
|
+
|
|
120
|
+
// TODO(you): persist this to your storage layer.
|
|
121
|
+
// Example with @vercel/kv:
|
|
122
|
+
// await kv.set(`asset:${chapterId}`, { url: imageUrl, requestId: body.request_id });
|
|
123
|
+
console.log('[fal-webhook]', {
|
|
124
|
+
chapterId,
|
|
125
|
+
requestId: body.request_id,
|
|
126
|
+
status: body.status,
|
|
127
|
+
url: imageUrl,
|
|
128
|
+
error: body.error,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return NextResponse.json({ received: true });
|
|
132
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { generateEditionImage, submitEditionImage } from '@/lib/fal-generate';
|
|
3
|
+
import { resolveModelId, type FalImageModelId } from '@/lib/fal-models';
|
|
4
|
+
import type { EditionAssetPrompt } from '@/lib/prompt-contract';
|
|
5
|
+
import { rateLimit, requireBearer } from '@/lib/api-guard';
|
|
6
|
+
|
|
7
|
+
export const runtime = 'nodejs';
|
|
8
|
+
// fal-ai/flux-2-pro typically completes in 3-8s. Allow 60s headroom.
|
|
9
|
+
export const maxDuration = 60;
|
|
10
|
+
|
|
11
|
+
type RequestBody = EditionAssetPrompt & {
|
|
12
|
+
/** "sync" (default, blocking) or "queue" (returns request_id, posts result to webhook). */
|
|
13
|
+
mode?: 'sync' | 'queue';
|
|
14
|
+
/** Override default FAL_IMAGE_MODEL per-request. */
|
|
15
|
+
modelId?: FalImageModelId;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function POST(req: NextRequest) {
|
|
19
|
+
// Guard the FAL_KEY: throttle every caller, and (if GENERATE_API_SECRET is set)
|
|
20
|
+
// require a bearer token. Without this, a deployed URL is an anonymous billing drain.
|
|
21
|
+
const limited = rateLimit(req);
|
|
22
|
+
if (limited) return limited;
|
|
23
|
+
|
|
24
|
+
const unauthorized = requireBearer(req);
|
|
25
|
+
if (unauthorized) return unauthorized;
|
|
26
|
+
|
|
27
|
+
if (!process.env.FAL_KEY) {
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ error: 'FAL_KEY missing. Add it to .env.local (see .env.example).' },
|
|
30
|
+
{ status: 500 },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let body: RequestBody;
|
|
35
|
+
try {
|
|
36
|
+
body = (await req.json()) as RequestBody;
|
|
37
|
+
} catch {
|
|
38
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!body.chapterId || !body.subject || !body.productTruth) {
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: 'Missing required fields: chapterId, subject, productTruth' },
|
|
44
|
+
{ status: 400 },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const modelId = body.modelId ?? resolveModelId(process.env.FAL_IMAGE_MODEL);
|
|
49
|
+
const mode = body.mode ?? 'sync';
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (mode === 'queue') {
|
|
53
|
+
const origin = req.nextUrl.origin;
|
|
54
|
+
const webhookUrl = `${origin}/api/fal/webhook?chapter=${encodeURIComponent(body.chapterId)}`;
|
|
55
|
+
const submission = await submitEditionImage(body, webhookUrl, modelId);
|
|
56
|
+
return NextResponse.json({ status: 'queued', ...submission });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const asset = await generateEditionImage(body, modelId);
|
|
60
|
+
return NextResponse.json({ status: 'ok', ...asset });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : 'Unknown fal.ai error';
|
|
63
|
+
console.error('[generate-edition-asset]', message, error);
|
|
64
|
+
return NextResponse.json({ error: message, modelId }, { status: 500 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
color-scheme: dark;
|
|
7
|
+
/* Fluid type scale — clamp(min, fluid, max) */
|
|
8
|
+
--fluid-eyebrow: clamp(0.65rem, 0.55rem + 0.3vw, 0.78rem);
|
|
9
|
+
--fluid-body: clamp(1rem, 0.92rem + 0.45vw, 1.25rem);
|
|
10
|
+
--fluid-headline: clamp(2.25rem, 1.2rem + 5vw, 5rem);
|
|
11
|
+
--fluid-display: clamp(3rem, 1.4rem + 9vw, 9.5rem);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
html,
|
|
15
|
+
body {
|
|
16
|
+
background: #0b0907;
|
|
17
|
+
color: #fff;
|
|
18
|
+
-webkit-font-smoothing: antialiased;
|
|
19
|
+
text-rendering: optimizeLegibility;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Prevent horizontal bounce / overscroll color flash on iOS */
|
|
23
|
+
html {
|
|
24
|
+
background-color: #0b0907;
|
|
25
|
+
overscroll-behavior-y: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Reserve scrollbar gutter so pin/release doesn't shift layout */
|
|
29
|
+
@media (min-width: 1024px) {
|
|
30
|
+
html { scrollbar-gutter: stable; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ─── Fluid typography utilities ─────────────────────────────────────── */
|
|
34
|
+
|
|
35
|
+
.text-fluid-eyebrow { font-size: var(--fluid-eyebrow); }
|
|
36
|
+
.text-fluid-body { font-size: var(--fluid-body); }
|
|
37
|
+
.text-fluid-headline { font-size: var(--fluid-headline); }
|
|
38
|
+
.text-fluid-display { font-size: var(--fluid-display); }
|
|
39
|
+
|
|
40
|
+
/* ─── Reduced motion: kill animations + neutralise pinned sections ───── */
|
|
41
|
+
|
|
42
|
+
@media (prefers-reduced-motion: reduce) {
|
|
43
|
+
*,
|
|
44
|
+
*::before,
|
|
45
|
+
*::after {
|
|
46
|
+
animation-duration: 0.01ms !important;
|
|
47
|
+
animation-iteration-count: 1 !important;
|
|
48
|
+
transition-duration: 0.01ms !important;
|
|
49
|
+
scroll-behavior: auto !important;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Snap any pinned section into a stable readable state */
|
|
53
|
+
[data-pin],
|
|
54
|
+
.scrollchoreography-pin {
|
|
55
|
+
position: static !important;
|
|
56
|
+
height: auto !important;
|
|
57
|
+
min-height: 100vh;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ─── Touch / coarse-pointer adjustments ─────────────────────────────── */
|
|
62
|
+
|
|
63
|
+
@media (hover: none) and (pointer: coarse) {
|
|
64
|
+
/* Touch devices: ensure tap targets meet WCAG / HIG 44px minimum */
|
|
65
|
+
a, button, [role='button'] {
|
|
66
|
+
min-height: 44px;
|
|
67
|
+
}
|
|
68
|
+
/* Disable hover-only transforms */
|
|
69
|
+
*:hover {
|
|
70
|
+
transform: none !important;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ─── iOS Safari momentum scroll preservation ─────────────────────────── */
|
|
75
|
+
|
|
76
|
+
@supports (-webkit-touch-callout: none) {
|
|
77
|
+
body {
|
|
78
|
+
-webkit-overflow-scrolling: touch;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Metadata, Viewport } from 'next';
|
|
2
|
+
import './globals.css';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: process.env.NEXT_PUBLIC_SITE_NAME ?? 'Editions Demo',
|
|
6
|
+
description: 'Cinematic chaptered release page built with choreo-3d.',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const viewport: Viewport = {
|
|
10
|
+
width: 'device-width',
|
|
11
|
+
initialScale: 1,
|
|
12
|
+
viewportFit: 'cover',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<body className="antialiased">{children}</body>
|
|
19
|
+
</html>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { EditionsPage } from '@/components/EditionsPage';
|
|
2
|
+
import { SmoothScrollProvider } from '@/components/SmoothScrollProvider';
|
|
3
|
+
|
|
4
|
+
export default function Page() {
|
|
5
|
+
return (
|
|
6
|
+
<SmoothScrollProvider>
|
|
7
|
+
<EditionsPage />
|
|
8
|
+
</SmoothScrollProvider>
|
|
9
|
+
);
|
|
10
|
+
}
|