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.
Files changed (61) hide show
  1. package/COMPATIBILITY.md +244 -0
  2. package/LICENSE +21 -0
  3. package/MODELS.md +92 -0
  4. package/README.md +250 -0
  5. package/SKILL.md +1003 -0
  6. package/audit-mode.md +497 -0
  7. package/bin/install.mjs +91 -0
  8. package/compile-choreography.mjs +296 -0
  9. package/decision-log.md +241 -0
  10. package/examples/GETTING_STARTED.md +279 -0
  11. package/examples/KNOWN_ISSUES.md +50 -0
  12. package/examples/PROMPTS.md +166 -0
  13. package/examples/luxe/README.md +88 -0
  14. package/examples/luxe/index.html +662 -0
  15. package/examples/noir/README.md +72 -0
  16. package/examples/noir/index.html +634 -0
  17. package/examples/pop/README.md +81 -0
  18. package/examples/pop/index.html +711 -0
  19. package/examples/renaissance/README.md +39 -0
  20. package/examples/renaissance/index.html +648 -0
  21. package/examples/studio/README.md +77 -0
  22. package/examples/studio/chapters.js +105 -0
  23. package/examples/studio/index.html +520 -0
  24. package/manifest.json +92 -0
  25. package/manifest.md +136 -0
  26. package/package.json +56 -0
  27. package/references/film-archetypes.md +211 -0
  28. package/references/performance-budget.md +499 -0
  29. package/references/scroll-patterns.md +693 -0
  30. package/scroll-choreography-compilation.md +543 -0
  31. package/scroll-choreography.json +1512 -0
  32. package/taste-guardrails.md +164 -0
  33. package/templates/nextjs/.env.example +41 -0
  34. package/templates/nextjs/app/api/fal/proxy/route.ts +33 -0
  35. package/templates/nextjs/app/api/fal/webhook/route.ts +132 -0
  36. package/templates/nextjs/app/api/generate-edition-asset/route.ts +66 -0
  37. package/templates/nextjs/app/globals.css +80 -0
  38. package/templates/nextjs/app/layout.tsx +21 -0
  39. package/templates/nextjs/app/page.tsx +10 -0
  40. package/templates/nextjs/components/ChapterDemoVisual.tsx +212 -0
  41. package/templates/nextjs/components/ChapterScene.tsx +373 -0
  42. package/templates/nextjs/components/EditionsPage.tsx +116 -0
  43. package/templates/nextjs/components/SmoothScrollProvider.tsx +8 -0
  44. package/templates/nextjs/lib/api-guard.ts +110 -0
  45. package/templates/nextjs/lib/editions-manifest.ts +224 -0
  46. package/templates/nextjs/lib/fal-client.ts +12 -0
  47. package/templates/nextjs/lib/fal-generate.ts +86 -0
  48. package/templates/nextjs/lib/fal-models.ts +213 -0
  49. package/templates/nextjs/lib/prompt-contract.ts +97 -0
  50. package/templates/nextjs/lib/use-device.ts +42 -0
  51. package/templates/nextjs/lib/use-lenis.ts +35 -0
  52. package/templates/nextjs/next.config.ts +29 -0
  53. package/templates/nextjs/package-lock.json +6455 -0
  54. package/templates/nextjs/package.json +41 -0
  55. package/templates/nextjs/package.patch.json +28 -0
  56. package/templates/nextjs/postcss.config.js +6 -0
  57. package/templates/nextjs/scripts/generate-chapter-assets.mjs +243 -0
  58. package/templates/nextjs/scripts/setup.mjs +170 -0
  59. package/templates/nextjs/tailwind.config.ts +37 -0
  60. package/templates/nextjs/tsconfig.json +23 -0
  61. package/troubleshooting.md +1284 -0
@@ -0,0 +1,116 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { ScrollBackgroundMorph } from 'choreo-3d';
5
+ import { editionChapters, editionThemes } from '@/lib/editions-manifest';
6
+ import { ChapterScene } from '@/components/ChapterScene';
7
+
8
+ /**
9
+ * Editions release page orchestrator.
10
+ *
11
+ * Each chapter is a self-contained <ChapterScene> with 7 parallax layers,
12
+ * a perspective camera, word-stagger title reveal, and a mobile fallback.
13
+ * This file stays small on purpose — extend chapters in `editions-manifest.ts`.
14
+ */
15
+ export function EditionsPage() {
16
+ const [activeId, setActiveId] = React.useState(editionChapters[0]?.id ?? null);
17
+
18
+ React.useEffect(() => {
19
+ const nodes = editionChapters
20
+ .map((chapter) => document.getElementById(chapter.id))
21
+ .filter(Boolean) as HTMLElement[];
22
+
23
+ const observer = new IntersectionObserver(
24
+ (entries) => {
25
+ const visible = entries
26
+ .filter((entry) => entry.isIntersecting)
27
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
28
+ if (visible?.target.id) setActiveId(visible.target.id);
29
+ },
30
+ { threshold: [0.25, 0.5, 0.75], rootMargin: '-10% 0px -10% 0px' },
31
+ );
32
+
33
+ nodes.forEach((node) => observer.observe(node));
34
+ return () => observer.disconnect();
35
+ }, []);
36
+
37
+ return (
38
+ <main className="relative min-h-screen overflow-x-hidden bg-[#0b0907] text-white selection:bg-white selection:text-black">
39
+ <ScrollBackgroundMorph activeId={activeId} themes={editionThemes} />
40
+ <TopNav />
41
+ <ChapterIndex activeId={activeId} />
42
+
43
+ {editionChapters.map((chapter, i) => (
44
+ <ChapterScene key={chapter.id} chapter={chapter} eager={i === 0} />
45
+ ))}
46
+ </main>
47
+ );
48
+ }
49
+
50
+ // ─── Persistent top nav with safe-area + 44px tap targets ─────────────────
51
+
52
+ function TopNav() {
53
+ return (
54
+ <nav
55
+ className="fixed inset-x-0 top-0 z-50 flex items-center justify-between bg-black/30 px-[max(env(safe-area-inset-left),1.25rem)] backdrop-blur-sm md:px-8"
56
+ style={{ paddingTop: 'max(env(safe-area-inset-top), 0.75rem)', paddingBottom: '0.75rem' }}
57
+ >
58
+ <a
59
+ href="#prologue"
60
+ className="grid h-11 min-w-[44px] place-items-center font-mono text-xs uppercase tracking-[0.22em] text-white"
61
+ >
62
+ Editions Demo
63
+ </a>
64
+ <div className="hidden items-center gap-2 md:flex">
65
+ {editionChapters.slice(1, 5).map((chapter) => (
66
+ <a
67
+ key={chapter.id}
68
+ href={`#${chapter.id}`}
69
+ className="grid h-11 min-w-[44px] place-items-center px-3 text-xs uppercase tracking-[0.18em] text-white/75 hover:text-white"
70
+ >
71
+ {chapter.eyebrow}
72
+ </a>
73
+ ))}
74
+ <a
75
+ href="#prologue"
76
+ className="ml-2 grid h-11 min-w-[88px] place-items-center rounded-full bg-white px-5 text-xs font-semibold uppercase tracking-[0.16em] text-black"
77
+ >
78
+ Start
79
+ </a>
80
+ </div>
81
+ </nav>
82
+ );
83
+ }
84
+
85
+ // ─── Persistent chapter index — hidden on mobile, hidden on iPad portrait ─
86
+
87
+ function ChapterIndex({ activeId }: { activeId: string | null }) {
88
+ return (
89
+ <aside
90
+ aria-label="Chapter index"
91
+ className="fixed bottom-6 left-5 z-50 hidden w-44 text-white/70 lg:block"
92
+ style={{
93
+ paddingLeft: 'env(safe-area-inset-left)',
94
+ paddingBottom: 'env(safe-area-inset-bottom)',
95
+ }}
96
+ >
97
+ <p className="mb-3 font-mono text-[10px] uppercase tracking-[0.24em]">Chapters</p>
98
+ <div className="space-y-1">
99
+ {editionChapters.map((chapter) => {
100
+ const isActive = activeId === chapter.id;
101
+ return (
102
+ <a
103
+ key={chapter.id}
104
+ href={`#${chapter.id}`}
105
+ className="grid min-h-[36px] grid-cols-[24px_1fr] items-baseline gap-2 font-mono text-[11px] uppercase tracking-[0.16em]"
106
+ aria-current={isActive ? 'true' : undefined}
107
+ >
108
+ <span className={isActive ? 'text-white' : 'text-white/35'}>{chapter.roman}</span>
109
+ <span className={isActive ? 'text-white' : 'text-white/45'}>{chapter.eyebrow}</span>
110
+ </a>
111
+ );
112
+ })}
113
+ </div>
114
+ </aside>
115
+ );
116
+ }
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ import { useLenisSmoothScroll } from '@/lib/use-lenis';
4
+
5
+ export function SmoothScrollProvider({ children }: { children: React.ReactNode }) {
6
+ useLenisSmoothScroll();
7
+ return <>{children}</>;
8
+ }
@@ -0,0 +1,110 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { timingSafeEqual } from 'crypto';
3
+
4
+ /**
5
+ * Dependency-free guards for the fal.ai API routes.
6
+ *
7
+ * Why this exists: the generate/proxy routes spend your FAL_KEY (each image is
8
+ * real money). Without a gate, a deployed URL is an anonymous billing drain.
9
+ *
10
+ * - rateLimit: best-effort per-IP throttle (always on)
11
+ * - requireBearer / isBearerValid: optional shared-secret gate
12
+ * (enabled by setting GENERATE_API_SECRET)
13
+ *
14
+ * Rate limiting is in-memory: it holds within a warm Fluid Compute instance but
15
+ * is not a cross-instance guarantee. For hard global limits, back `hits` with a
16
+ * shared store (Upstash / Vercel KV).
17
+ */
18
+
19
+ const WINDOW_MS = 60_000;
20
+ const MAX_REQUESTS = 10;
21
+ /** Hard backstop so a flood of unique (spoofed) IPs can't grow the Map unbounded. */
22
+ const MAX_TRACKED_IPS = 10_000;
23
+ const hits = new Map<string, number[]>();
24
+ let lastSweep = 0;
25
+
26
+ export function clientIp(req: NextRequest): string {
27
+ const fwd = req.headers.get('x-forwarded-for');
28
+ if (fwd) return fwd.split(',')[0]!.trim();
29
+ return req.headers.get('x-real-ip') ?? 'unknown';
30
+ }
31
+
32
+ /** Drop entries whose hits have all aged out; runs at most once per window. */
33
+ function sweep(now: number, windowMs: number): void {
34
+ if (now - lastSweep < windowMs) return;
35
+ lastSweep = now;
36
+ for (const [ip, times] of hits) {
37
+ if (times.every((t) => now - t >= windowMs)) hits.delete(ip);
38
+ }
39
+ }
40
+
41
+ /** Returns null if allowed, or a 429 response if the caller is over the limit. */
42
+ export function rateLimit(
43
+ req: NextRequest,
44
+ max = MAX_REQUESTS,
45
+ windowMs = WINDOW_MS,
46
+ ): NextResponse | null {
47
+ const ip = clientIp(req);
48
+ const now = Date.now();
49
+ sweep(now, windowMs);
50
+
51
+ const recent = (hits.get(ip) ?? []).filter((t) => now - t < windowMs);
52
+ if (recent.length >= max) {
53
+ return NextResponse.json(
54
+ { error: 'Rate limit exceeded. Try again shortly.' },
55
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(windowMs / 1000)) } },
56
+ );
57
+ }
58
+ // Backstop: if the Map is somehow saturated with new IPs, force a sweep; if it
59
+ // is still full, fail closed (429) rather than grow without bound.
60
+ if (!hits.has(ip) && hits.size >= MAX_TRACKED_IPS) {
61
+ lastSweep = 0;
62
+ sweep(now, windowMs);
63
+ if (hits.size >= MAX_TRACKED_IPS) {
64
+ return NextResponse.json(
65
+ { error: 'Server busy. Try again shortly.' },
66
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(windowMs / 1000)) } },
67
+ );
68
+ }
69
+ }
70
+ recent.push(now);
71
+ hits.set(ip, recent);
72
+ return null;
73
+ }
74
+
75
+ function safeEqual(a: string, b: string): boolean {
76
+ const ab = Buffer.from(a);
77
+ const bb = Buffer.from(b);
78
+ if (ab.length !== bb.length) return false;
79
+ return timingSafeEqual(ab, bb);
80
+ }
81
+
82
+ /** True if `authorization` carries the configured `Bearer <GENERATE_API_SECRET>`. */
83
+ export function isBearerValid(authorization: string | null | undefined): boolean {
84
+ const secret = process.env.GENERATE_API_SECRET;
85
+ if (!secret) return false;
86
+ const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
87
+ return safeEqual(token, secret);
88
+ }
89
+
90
+ /**
91
+ * Shared-secret gate for route handlers.
92
+ * - GENERATE_API_SECRET set -> requires `Authorization: Bearer <secret>`.
93
+ * - unset in development -> no-op (returns null) so local demos work.
94
+ * - unset in production (incl. -> fails CLOSED with 503 so a careless deploy
95
+ * Vercel preview/prod builds) can't run an anonymous billing drain.
96
+ * Returns null if allowed, or an error response otherwise.
97
+ */
98
+ export function requireBearer(req: NextRequest): NextResponse | null {
99
+ if (!process.env.GENERATE_API_SECRET) {
100
+ if (process.env.NODE_ENV === 'production') {
101
+ return NextResponse.json(
102
+ { error: 'Server misconfigured: GENERATE_API_SECRET is required in production.' },
103
+ { status: 503 },
104
+ );
105
+ }
106
+ return null;
107
+ }
108
+ if (isBearerValid(req.headers.get('authorization'))) return null;
109
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
110
+ }
@@ -0,0 +1,224 @@
1
+ import type { ScrollMorphTheme } from 'choreo-3d';
2
+
3
+ export type EditionChapter = {
4
+ id: string;
5
+ roman: string;
6
+ eyebrow: string;
7
+ title: string;
8
+ summary: string;
9
+ technicalDetail: string;
10
+ features: string[];
11
+ accent: string;
12
+ /**
13
+ * Optional. When undefined, ChapterScene renders the CSS-only ChapterDemoVisual
14
+ * (works beautifully without any fal.ai setup). Set to a path like
15
+ * `/generated/<id>.jpg` after running `node scripts/generate-chapter-assets.mjs`.
16
+ */
17
+ background?: string;
18
+ /** Optional inline base64/blur data URL — enables Next/Image blur placeholder. */
19
+ backgroundBlur?: string;
20
+ foreground?: string;
21
+ poster?: string;
22
+ video?: string;
23
+ /** Background-morph atmosphere for this chapter. */
24
+ atmosphere: ScrollMorphTheme;
25
+ visualPrompt: {
26
+ subject: string;
27
+ productTruth: string;
28
+ historicalLayer: 'renaissance' | 'baroque' | 'atelier' | 'architectural' | 'industrial';
29
+ modernLayer: string;
30
+ palette: string[];
31
+ camera: 'wide' | 'medium' | 'macro' | 'isometric' | 'low-angle';
32
+ };
33
+ };
34
+
35
+ /**
36
+ * 8-chapter demo manifest. Replace with project-specific chapters.
37
+ * 6–12 chapters is the sweet spot — under 6 feels light, over 12 feels endless.
38
+ */
39
+ export const editionChapters: EditionChapter[] = [
40
+ {
41
+ id: 'prologue',
42
+ roman: 'I',
43
+ eyebrow: 'The Release',
44
+ title: 'A new operating layer for visual decisions.',
45
+ summary:
46
+ 'A cinematic chaptered page that turns product updates into a world people understand in seconds.',
47
+ technicalDetail:
48
+ 'Scroll progress drives 7 pinned layers per chapter, atmospheric background morphs, and generated visual assets.',
49
+ features: ['Chapter taxonomy', 'Generated visual system', 'HTML overlay typography'],
50
+ accent: '#ff4fc3',
51
+ // background: '/generated/prologue.webp', ← uncomment after running `node scripts/generate-chapter-assets.mjs`
52
+ foreground: '/generated/prologue-figure.webp',
53
+ atmosphere: {
54
+ background: 'linear-gradient(to bottom, #0b0907 0%, #1a1410 60%, #2a1a14 100%)',
55
+ },
56
+ visualPrompt: {
57
+ subject: 'two figures in a classical studio discovering a glowing product interface on a table',
58
+ productTruth: 'the product turns fragmented updates into a coherent release system',
59
+ historicalLayer: 'renaissance',
60
+ modernLayer: 'transparent software panel, product cards, subtle AI terminal glow',
61
+ palette: ['aged cream', 'deep umber', 'acid pink', 'soft sky blue'],
62
+ camera: 'wide',
63
+ },
64
+ },
65
+ {
66
+ id: 'agentic',
67
+ roman: 'II',
68
+ eyebrow: 'Agentic Layer',
69
+ title: 'The interface stops waiting for instructions.',
70
+ summary: 'Agents convert product context into suggested actions, experiments, and operational next steps.',
71
+ technicalDetail: 'Fast foreground UI layer over a slow generated background keeps content editable.',
72
+ features: ['Action routing', 'Context memory', 'Approval gates'],
73
+ accent: '#b4ff38',
74
+ // background: '/generated/agentic.webp',
75
+ atmosphere: {
76
+ background: 'linear-gradient(to bottom right, #0d1410 0%, #14201a 60%, #1f2e1c 100%)',
77
+ },
78
+ visualPrompt: {
79
+ subject: 'a classical cartographer mapping commerce routes that become glowing conversational pathways',
80
+ productTruth: 'agents expose product actions across chat, search, and workflow surfaces',
81
+ historicalLayer: 'baroque',
82
+ modernLayer: 'floating graph nodes, chat cards, route lines, product tiles',
83
+ palette: ['dark olive', 'gold leaf', 'blackened green', 'electric lime'],
84
+ camera: 'medium',
85
+ },
86
+ },
87
+ {
88
+ id: 'studio',
89
+ roman: 'III',
90
+ eyebrow: 'Visual Studio',
91
+ title: 'Assets become a pipeline, not a folder.',
92
+ summary: 'fal.ai generates chapter images, posters, and variants through a structured prompt contract.',
93
+ technicalDetail: 'Server-side generation protects credentials, normalises prompts, and records output metadata.',
94
+ features: ['fal.ai proxy', 'Prompt manifest', 'Variant generation'],
95
+ accent: '#37c7ff',
96
+ // background: '/generated/studio.webp',
97
+ atmosphere: {
98
+ background: 'linear-gradient(to bottom, #0a1418 0%, #102230 60%, #1a3447 100%)',
99
+ },
100
+ visualPrompt: {
101
+ subject: 'a painterly atelier where canvases connect to a modern asset generation console',
102
+ productTruth: 'creative direction becomes repeatable infrastructure',
103
+ historicalLayer: 'atelier',
104
+ modernLayer: 'generation queue, image grid, prompt cards, render status lights',
105
+ palette: ['warm canvas', 'sepia', 'cyan', 'bone white'],
106
+ camera: 'wide',
107
+ },
108
+ },
109
+ {
110
+ id: 'taste',
111
+ roman: 'IV',
112
+ eyebrow: 'Taste Layer',
113
+ title: 'Quality becomes a scoring function.',
114
+ summary: 'Every generated asset passes a measurable taste filter before reaching the page.',
115
+ technicalDetail: 'Evaluation runs as a pure function on prompt + output metadata; scores feed back into ranking.',
116
+ features: ['Composition scoring', 'Palette adherence', 'Brand voice gate'],
117
+ accent: '#e87e7e',
118
+ // background: '/generated/taste.webp',
119
+ atmosphere: {
120
+ background: 'linear-gradient(to bottom, #1a0f10 0%, #2a181a 60%, #3a2024 100%)',
121
+ },
122
+ visualPrompt: {
123
+ subject: 'a renaissance textile master inspecting silk samples under structured studio light',
124
+ productTruth: 'taste is enforced as a deterministic, measurable layer',
125
+ historicalLayer: 'renaissance',
126
+ modernLayer: 'score readouts, composition overlays, accept/reject toggles',
127
+ palette: ['deep crimson', 'ivory', 'oxidised brass', 'soft rose'],
128
+ camera: 'macro',
129
+ },
130
+ },
131
+ {
132
+ id: 'infrastructure',
133
+ roman: 'V',
134
+ eyebrow: 'Infrastructure',
135
+ title: 'The mesh that holds the worlds together.',
136
+ summary: 'Edge runtime, queue workers, and private networking that keep generation fast and durable.',
137
+ technicalDetail: 'Vercel Edge + queue workers + Tailscale mesh. Background jobs use fal.queue webhook callbacks.',
138
+ features: ['Edge runtime', 'Durable queues', 'Mesh networking'],
139
+ accent: '#c8a0f0',
140
+ // background: '/generated/infrastructure.webp',
141
+ atmosphere: {
142
+ background: 'linear-gradient(to bottom, #100b1a 0%, #1a142a 60%, #28203f 100%)',
143
+ },
144
+ visualPrompt: {
145
+ subject: 'a victorian engine room reimagined as a soft, glowing distributed computing diagram',
146
+ productTruth: 'the platform survives spikes and stays cheap at idle',
147
+ historicalLayer: 'industrial',
148
+ modernLayer: 'queue depth gauges, edge region map, latency sparklines',
149
+ palette: ['twilight purple', 'brass', 'steel blue', 'ember orange'],
150
+ camera: 'wide',
151
+ },
152
+ },
153
+ {
154
+ id: 'deploy',
155
+ roman: 'VI',
156
+ eyebrow: 'Deploy Layer',
157
+ title: 'Every release is a reversible plan.',
158
+ summary: 'DAG-based deploys make each step inspectable, pausable, and revertable.',
159
+ technicalDetail: 'Plans compile to a DAG; agents execute steps with explicit approval gates between layers.',
160
+ features: ['DAG planner', 'Approval gates', 'One-click rollback'],
161
+ accent: '#f0c060',
162
+ // background: '/generated/deploy.webp',
163
+ atmosphere: {
164
+ background: 'linear-gradient(to bottom, #1a1308 0%, #2a1f10 60%, #3a2d18 100%)',
165
+ },
166
+ visualPrompt: {
167
+ subject: 'an architectural drafting studio where blueprints flow into a glowing release plan',
168
+ productTruth: 'deployments stop being terrifying',
169
+ historicalLayer: 'architectural',
170
+ modernLayer: 'plan tiles, approval buttons, rollback timeline',
171
+ palette: ['vellum', 'india ink', 'amber', 'bone'],
172
+ camera: 'isometric',
173
+ },
174
+ },
175
+ {
176
+ id: 'integration',
177
+ roman: 'VII',
178
+ eyebrow: 'Integration Surface',
179
+ title: 'Plug into anything you already use.',
180
+ summary: 'FastMCP tools, REST connectors, and webhooks make the platform a citizen of your stack.',
181
+ technicalDetail: 'Adapters expose the same primitives to MCP, REST, and event sources without per-target rewrites.',
182
+ features: ['MCP server', 'REST connectors', 'Webhook gateway'],
183
+ accent: '#60d0e0',
184
+ // background: '/generated/integration.webp',
185
+ atmosphere: {
186
+ background: 'linear-gradient(to bottom, #08161a 0%, #102830 60%, #1a3a47 100%)',
187
+ },
188
+ visualPrompt: {
189
+ subject: 'a telegraph exchange where copper wires resolve into a clean modern integration diagram',
190
+ productTruth: 'integration becomes a configuration, not a project',
191
+ historicalLayer: 'industrial',
192
+ modernLayer: 'tool registry, connector cards, webhook log',
193
+ palette: ['midnight teal', 'copper', 'sky', 'graphite'],
194
+ camera: 'medium',
195
+ },
196
+ },
197
+ {
198
+ id: 'horizon',
199
+ roman: 'VIII',
200
+ eyebrow: 'The Horizon',
201
+ title: 'Compounding advantage, one chapter at a time.',
202
+ summary: 'Each release adds a permanent capability — the platform learns the way an atelier learns.',
203
+ technicalDetail: 'A capability graph captures what the platform can do; agents query it to plan future work.',
204
+ features: ['Capability graph', 'Learned templates', 'Open roadmap'],
205
+ accent: '#e8e0c8',
206
+ // background: '/generated/horizon.webp',
207
+ atmosphere: {
208
+ background: 'linear-gradient(to bottom, #14110a 0%, #1f1b12 60%, #2e2820 100%)',
209
+ },
210
+ visualPrompt: {
211
+ subject: 'a cliff surveyor at dawn measuring a wide unfolding landscape',
212
+ productTruth: 'the system gets better every release without changing what it is',
213
+ historicalLayer: 'atelier',
214
+ modernLayer: 'roadmap card, capability map, version timeline',
215
+ palette: ['parchment', 'rust', 'sky', 'ink'],
216
+ camera: 'wide',
217
+ },
218
+ },
219
+ ];
220
+
221
+ /** Theme map keyed by chapter id, ready to pass to <ScrollBackgroundMorph themes={...}>. */
222
+ export const editionThemes: Record<string, ScrollMorphTheme> = Object.fromEntries(
223
+ editionChapters.map((chapter) => [chapter.id, chapter.atmosphere]),
224
+ );
@@ -0,0 +1,12 @@
1
+ import { fal } from '@fal-ai/client';
2
+
3
+ let configured = false;
4
+
5
+ export function configureFalClient() {
6
+ if (configured) return fal;
7
+ fal.config({
8
+ proxyUrl: '/api/fal/proxy',
9
+ });
10
+ configured = true;
11
+ return fal;
12
+ }
@@ -0,0 +1,86 @@
1
+ import { fal } from '@fal-ai/client';
2
+ import { buildEditionPrompt, EDITION_AVOID, type EditionAssetPrompt } from './prompt-contract';
3
+ import { getModel, resolveModelId, type FalImageModelId, type Orientation } from './fal-models';
4
+
5
+ export type GeneratedEditionAsset = {
6
+ chapterId: string;
7
+ url: string;
8
+ modelId: FalImageModelId;
9
+ requestId?: string;
10
+ raw: unknown;
11
+ };
12
+
13
+ const ORIENTATION_FOR: Record<EditionAssetPrompt['outputRole'], Orientation> = {
14
+ hero: 'landscape',
15
+ 'chapter-bg': 'landscape',
16
+ 'foreground-object': 'portrait',
17
+ poster: 'portrait',
18
+ 'motion-source': 'landscape',
19
+ };
20
+
21
+ /**
22
+ * Synchronous generation — best for prototyping and asset-script batches up to ~5 chapters.
23
+ * For long-running batches or video models, use `submitEditionImage()` (queue + webhook).
24
+ */
25
+ export async function generateEditionImage(
26
+ input: EditionAssetPrompt,
27
+ overrideModelId?: FalImageModelId,
28
+ ): Promise<GeneratedEditionAsset> {
29
+ const modelId = overrideModelId ?? resolveModelId(process.env.FAL_IMAGE_MODEL);
30
+ const model = getModel(modelId);
31
+
32
+ const modelInput = model.buildInput({
33
+ prompt: buildEditionPrompt(input),
34
+ avoid: EDITION_AVOID,
35
+ orientation: ORIENTATION_FOR[input.outputRole],
36
+ seed: input.seed,
37
+ });
38
+
39
+ const result = await fal.subscribe(modelId, {
40
+ input: modelInput,
41
+ logs: true,
42
+ });
43
+
44
+ const url = model.extractUrl(result.data);
45
+ if (!url) {
46
+ throw new Error(`fal.ai (${modelId}) returned no image URL. Raw: ${JSON.stringify(result.data).slice(0, 200)}`);
47
+ }
48
+
49
+ return {
50
+ chapterId: input.chapterId,
51
+ url,
52
+ modelId,
53
+ requestId: result.requestId,
54
+ raw: result.data,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Async submission — returns a request_id immediately, fal posts the result to
60
+ * `webhookUrl` when complete. Use this in production for batch generation, video
61
+ * models, or any chapter set above 5 images.
62
+ *
63
+ * The webhook receiver lives at `app/api/fal/webhook/route.ts`.
64
+ */
65
+ export async function submitEditionImage(
66
+ input: EditionAssetPrompt,
67
+ webhookUrl: string,
68
+ overrideModelId?: FalImageModelId,
69
+ ): Promise<{ requestId: string; modelId: FalImageModelId }> {
70
+ const modelId = overrideModelId ?? resolveModelId(process.env.FAL_IMAGE_MODEL);
71
+ const model = getModel(modelId);
72
+
73
+ const modelInput = model.buildInput({
74
+ prompt: buildEditionPrompt(input),
75
+ avoid: EDITION_AVOID,
76
+ orientation: ORIENTATION_FOR[input.outputRole],
77
+ seed: input.seed,
78
+ });
79
+
80
+ const { request_id } = await fal.queue.submit(modelId, {
81
+ input: modelInput,
82
+ webhookUrl,
83
+ });
84
+
85
+ return { requestId: request_id, modelId };
86
+ }