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,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "editions-release-page",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"setup": "node scripts/setup.mjs",
|
|
7
|
+
"generate": "node scripts/generate-chapter-assets.mjs",
|
|
8
|
+
"generate:dry": "node scripts/generate-chapter-assets.mjs --dry-run",
|
|
9
|
+
"dev": "next dev",
|
|
10
|
+
"build": "next build",
|
|
11
|
+
"start": "next start",
|
|
12
|
+
"lint": "next lint",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@fal-ai/client": "^1.10.0",
|
|
17
|
+
"@fal-ai/server-proxy": "^1.2.0",
|
|
18
|
+
"@gsap/react": "^2.1.2",
|
|
19
|
+
"choreo-3d": "1.0.0",
|
|
20
|
+
"framer-motion": "^12.0.0",
|
|
21
|
+
"gsap": "^3.13.0",
|
|
22
|
+
"lenis": "^1.3.23",
|
|
23
|
+
"next": "^15.5.18",
|
|
24
|
+
"react": "^19.0.0",
|
|
25
|
+
"react-dom": "^19.0.0"
|
|
26
|
+
},
|
|
27
|
+
"overrides": {
|
|
28
|
+
"postcss": "^8.5.15"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"@types/react": "^19.0.0",
|
|
33
|
+
"@types/react-dom": "^19.0.0",
|
|
34
|
+
"autoprefixer": "^10.4.0",
|
|
35
|
+
"eslint": "^9.0.0",
|
|
36
|
+
"eslint-config-next": "^15.1.0",
|
|
37
|
+
"postcss": "^8.5.15",
|
|
38
|
+
"tailwindcss": "^3.4.0",
|
|
39
|
+
"typescript": "^5.6.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies_to_add": {
|
|
3
|
+
"choreo-3d": "^1.0.0",
|
|
4
|
+
"@fal-ai/client": "^1.10.0",
|
|
5
|
+
"@fal-ai/server-proxy": "^1.2.0",
|
|
6
|
+
"framer-motion": "^12.0.0",
|
|
7
|
+
"gsap": "^3.12.0",
|
|
8
|
+
"lenis": "^1.3.23"
|
|
9
|
+
},
|
|
10
|
+
"dependencies_forbidden": {
|
|
11
|
+
"@studio-freight/lenis": "Package renamed to lenis. Latest old scope version is 1.0.42 only — do NOT use ^1.0.45 or any invented version."
|
|
12
|
+
},
|
|
13
|
+
"env_required": [
|
|
14
|
+
"FAL_KEY"
|
|
15
|
+
],
|
|
16
|
+
"env_optional": [
|
|
17
|
+
"FAL_IMAGE_MODEL",
|
|
18
|
+
"FAL_VIDEO_MODEL",
|
|
19
|
+
"NEXT_PUBLIC_SITE_NAME"
|
|
20
|
+
],
|
|
21
|
+
"fal_model_defaults": {
|
|
22
|
+
"image": "fal-ai/flux-2-pro",
|
|
23
|
+
"image_draft": "fal-ai/flux-2/turbo",
|
|
24
|
+
"image_max": "fal-ai/flux-2-max",
|
|
25
|
+
"image_budget": "fal-ai/flux-pro/v1.1",
|
|
26
|
+
"image_dev_only": "fal-ai/flux/dev (NON-COMMERCIAL - never use in production)"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Batch chapter-asset generator.
|
|
4
|
+
*
|
|
5
|
+
* Reads `lib/editions-manifest.ts`, calls fal.ai for each chapter's hero image,
|
|
6
|
+
* and writes the output URLs into `public/generated/manifest.json` plus
|
|
7
|
+
* downloads the binary to `public/generated/<id>.webp` (or whatever format
|
|
8
|
+
* the chosen model returns).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/generate-chapter-assets.mjs # generate all chapters
|
|
12
|
+
* node scripts/generate-chapter-assets.mjs --dry-run # print prompts only
|
|
13
|
+
* node scripts/generate-chapter-assets.mjs --only prologue,studio
|
|
14
|
+
* node scripts/generate-chapter-assets.mjs --model fal-ai/gemini-3-pro-image-preview
|
|
15
|
+
*
|
|
16
|
+
* Requires:
|
|
17
|
+
* FAL_KEY=key_id:key_secret (in .env.local)
|
|
18
|
+
* FAL_IMAGE_MODEL=fal-ai/flux-2-pro (optional override)
|
|
19
|
+
*
|
|
20
|
+
* NOTE: this is a Node script (not Next runtime) so we use the raw HTTP
|
|
21
|
+
* endpoint at https://fal.run/<model-id> with the key directly. We do NOT
|
|
22
|
+
* use the proxy — the proxy is only for browser → server calls.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
26
|
+
import { resolve, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { argv, env, exit } from 'node:process';
|
|
29
|
+
|
|
30
|
+
// ─── arg parsing ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const args = argv.slice(2);
|
|
33
|
+
const flags = {
|
|
34
|
+
dryRun: args.includes('--dry-run'),
|
|
35
|
+
only: (args.find((a) => a.startsWith('--only='))?.split('=')[1] ?? '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.filter(Boolean),
|
|
38
|
+
model: args.find((a) => a.startsWith('--model='))?.split('=')[1],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// `--only foo,bar` (space form)
|
|
42
|
+
const onlyIdx = args.indexOf('--only');
|
|
43
|
+
if (onlyIdx !== -1 && args[onlyIdx + 1] && !args[onlyIdx + 1].startsWith('--')) {
|
|
44
|
+
flags.only = args[onlyIdx + 1].split(',').filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
const modelIdx = args.indexOf('--model');
|
|
47
|
+
if (modelIdx !== -1 && args[modelIdx + 1] && !args[modelIdx + 1].startsWith('--')) {
|
|
48
|
+
flags.model = args[modelIdx + 1];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── env ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const FAL_KEY = env.FAL_KEY;
|
|
54
|
+
const MODEL_ID = flags.model ?? env.FAL_IMAGE_MODEL ?? 'fal-ai/flux-2-pro';
|
|
55
|
+
|
|
56
|
+
if (!FAL_KEY && !flags.dryRun) {
|
|
57
|
+
console.error('\n[generate-chapter-assets] FAL_KEY missing. Set it in .env.local or your shell.\n');
|
|
58
|
+
exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── load .env.local manually (so the script works without `dotenv`) ──────
|
|
62
|
+
|
|
63
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const projectRoot = resolve(__dirname, '..');
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const envText = await (await import('node:fs/promises')).readFile(
|
|
68
|
+
resolve(projectRoot, '.env.local'),
|
|
69
|
+
'utf8',
|
|
70
|
+
);
|
|
71
|
+
for (const line of envText.split('\n')) {
|
|
72
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"?([^"\n]*)"?\s*$/);
|
|
73
|
+
if (m && !env[m[1]]) env[m[1]] = m[2];
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// .env.local optional
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── per-model input builder (mirrors lib/fal-models.ts) ──────────────────
|
|
80
|
+
|
|
81
|
+
const FLUX_IMAGE_SIZE = { landscape: 'landscape_16_9', portrait: 'portrait_4_3', square: 'square_hd' };
|
|
82
|
+
const GEMINI_ASPECT = { landscape: '16:9', portrait: '3:4', square: '1:1' };
|
|
83
|
+
|
|
84
|
+
function buildInput(modelId, prompt, orientation) {
|
|
85
|
+
if (modelId.startsWith('fal-ai/flux')) {
|
|
86
|
+
return {
|
|
87
|
+
prompt,
|
|
88
|
+
image_size: FLUX_IMAGE_SIZE[orientation],
|
|
89
|
+
output_format: 'jpeg',
|
|
90
|
+
enable_safety_checker: true,
|
|
91
|
+
safety_tolerance: '2',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (modelId.startsWith('fal-ai/gemini')) {
|
|
95
|
+
return {
|
|
96
|
+
prompt,
|
|
97
|
+
aspect_ratio: GEMINI_ASPECT[orientation],
|
|
98
|
+
output_format: 'png',
|
|
99
|
+
resolution: '1K',
|
|
100
|
+
num_images: 1,
|
|
101
|
+
safety_tolerance: '4',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// imagen + fallback
|
|
105
|
+
return {
|
|
106
|
+
prompt,
|
|
107
|
+
aspect_ratio: GEMINI_ASPECT[orientation],
|
|
108
|
+
num_images: 1,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── prompt builder (mirrors lib/prompt-contract.ts) ──────────────────────
|
|
113
|
+
|
|
114
|
+
const HISTORICAL = {
|
|
115
|
+
renaissance: 'Renaissance composition: layered chiaroscuro, dramatic fabric, sfumato edges, museum-grade lighting.',
|
|
116
|
+
baroque: 'Baroque composition: theatrical movement, deep shadow contrast, gilded textures, dynamic diagonals.',
|
|
117
|
+
atelier: 'Painterly atelier composition: visible brushwork, warm sepia base, soft natural studio light.',
|
|
118
|
+
architectural: 'Architectural drafting composition: orthographic clarity, parchment tones, fine ink lines.',
|
|
119
|
+
industrial: 'Industrial-era composition: forged metal, oxidised brass, steam-warm light, mechanical detail.',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const CAMERA = {
|
|
123
|
+
wide: 'Wide cinematic shot, deep field, atmospheric perspective.',
|
|
124
|
+
medium: 'Medium shot, balanced subject framing.',
|
|
125
|
+
macro: 'Macro detail shot, shallow depth of field, tactile material focus.',
|
|
126
|
+
isometric: 'Clean isometric projection, technical clarity, even lighting.',
|
|
127
|
+
'low-angle': 'Low-angle hero shot, monumental perspective.',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const AVOID =
|
|
131
|
+
'brand logos, unreadable text overlays, fake UI labels, watermarks, low resolution, distorted hands, generic AI gloss';
|
|
132
|
+
|
|
133
|
+
function buildPrompt(vp) {
|
|
134
|
+
return [
|
|
135
|
+
'Original editorial product-scene image for a high-craft interactive release website.',
|
|
136
|
+
`Scene: ${vp.subject}.`,
|
|
137
|
+
`Product truth: ${vp.productTruth}.`,
|
|
138
|
+
HISTORICAL[vp.historicalLayer],
|
|
139
|
+
`Modern layer: ${vp.modernLayer} — integrated naturally, not stickered on.`,
|
|
140
|
+
`Palette: ${vp.palette.join(', ')}.`,
|
|
141
|
+
CAMERA[vp.camera],
|
|
142
|
+
'Background plate. Subject de-emphasised, atmosphere primary, suitable for radial vignette overlay.',
|
|
143
|
+
'No brand logos, no readable text, no imitation of named living artists.',
|
|
144
|
+
`Avoid: ${AVOID}.`,
|
|
145
|
+
].join(' ');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── load chapters from the TS manifest at runtime via tiny regex ─────────
|
|
149
|
+
// We avoid `ts-node` so the script stays dependency-free. Chapters are
|
|
150
|
+
// exported as a `const editionChapters` array literal — readable by JSON.parse
|
|
151
|
+
// after light cleanup.
|
|
152
|
+
|
|
153
|
+
async function loadChapters() {
|
|
154
|
+
const { readFile } = await import('node:fs/promises');
|
|
155
|
+
const src = await readFile(resolve(projectRoot, 'lib/editions-manifest.ts'), 'utf8');
|
|
156
|
+
const m = src.match(/export const editionChapters[^=]*=\s*(\[[\s\S]*?\n\]);/);
|
|
157
|
+
if (!m) throw new Error('Could not locate editionChapters in lib/editions-manifest.ts');
|
|
158
|
+
// Strip TS-only bits: trailing commas, single quotes, keys without quotes.
|
|
159
|
+
const jsonish = m[1]
|
|
160
|
+
.replace(/'/g, '"')
|
|
161
|
+
.replace(/,(\s*[}\]])/g, '$1')
|
|
162
|
+
.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
|
|
163
|
+
return JSON.parse(jsonish);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── main ─────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async function main() {
|
|
169
|
+
const chapters = await loadChapters();
|
|
170
|
+
const targets = flags.only.length ? chapters.filter((c) => flags.only.includes(c.id)) : chapters;
|
|
171
|
+
|
|
172
|
+
console.log(`\n[generate-chapter-assets] model=${MODEL_ID} chapters=${targets.length} dryRun=${flags.dryRun}\n`);
|
|
173
|
+
|
|
174
|
+
const outDir = resolve(projectRoot, 'public/generated');
|
|
175
|
+
await mkdir(outDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
const manifest = { model: MODEL_ID, generatedAt: new Date().toISOString(), assets: {} };
|
|
178
|
+
|
|
179
|
+
for (const ch of targets) {
|
|
180
|
+
const prompt = buildPrompt(ch.visualPrompt);
|
|
181
|
+
const input = buildInput(MODEL_ID, prompt, 'landscape');
|
|
182
|
+
|
|
183
|
+
console.log(`─── ${ch.id} (${ch.roman}) ──────────────────────────────`);
|
|
184
|
+
if (flags.dryRun) {
|
|
185
|
+
console.log(prompt);
|
|
186
|
+
console.log('input:', JSON.stringify(input, null, 2));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const startedAt = Date.now();
|
|
192
|
+
const resp = await fetch(`https://fal.run/${MODEL_ID}`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: {
|
|
195
|
+
Authorization: `Key ${FAL_KEY}`,
|
|
196
|
+
'Content-Type': 'application/json',
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify(input),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (!resp.ok) {
|
|
202
|
+
const text = await resp.text();
|
|
203
|
+
throw new Error(`HTTP ${resp.status}: ${text.slice(0, 300)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const data = await resp.json();
|
|
207
|
+
const url = data?.images?.[0]?.url;
|
|
208
|
+
if (!url) throw new Error(`No image URL in response. Raw: ${JSON.stringify(data).slice(0, 200)}`);
|
|
209
|
+
|
|
210
|
+
// Download the binary so the site works offline / without fal CDN dependency.
|
|
211
|
+
const ext = url.split('.').pop()?.split('?')[0]?.toLowerCase() ?? 'jpg';
|
|
212
|
+
const safeExt = ['jpg', 'jpeg', 'png', 'webp'].includes(ext) ? ext : 'jpg';
|
|
213
|
+
const localPath = `public/generated/${ch.id}.${safeExt}`;
|
|
214
|
+
const bin = await fetch(url).then((r) => r.arrayBuffer());
|
|
215
|
+
await writeFile(resolve(projectRoot, localPath), Buffer.from(bin));
|
|
216
|
+
|
|
217
|
+
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
218
|
+
console.log(` ok ${seconds}s → /${localPath}`);
|
|
219
|
+
manifest.assets[ch.id] = {
|
|
220
|
+
url,
|
|
221
|
+
local: `/generated/${ch.id}.${safeExt}`,
|
|
222
|
+
seed: data?.seed,
|
|
223
|
+
model: MODEL_ID,
|
|
224
|
+
};
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error(` ERROR ${err.message}`);
|
|
227
|
+
manifest.assets[ch.id] = { error: String(err.message) };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!flags.dryRun) {
|
|
232
|
+
await writeFile(
|
|
233
|
+
resolve(outDir, 'manifest.json'),
|
|
234
|
+
JSON.stringify(manifest, null, 2),
|
|
235
|
+
);
|
|
236
|
+
console.log(`\n[generate-chapter-assets] manifest → public/generated/manifest.json\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
main().catch((err) => {
|
|
241
|
+
console.error(err);
|
|
242
|
+
exit(1);
|
|
243
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Interactive setup wizard — run with `npm run setup`.
|
|
4
|
+
*
|
|
5
|
+
* Walks a brand-new buyer through the entire fal.ai bootstrap in ~2 minutes:
|
|
6
|
+
* 1. Detects whether .env.local already exists
|
|
7
|
+
* 2. Asks if they want to wire up fal.ai now or stay in demo mode
|
|
8
|
+
* 3. If yes: opens the fal.ai key dashboard in their default browser
|
|
9
|
+
* 4. Captures the pasted key, validates the shape, writes .env.local
|
|
10
|
+
* 5. Offers to generate one test image to prove the round-trip works
|
|
11
|
+
*
|
|
12
|
+
* Zero dependencies — pure Node + readline.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createInterface } from 'node:readline/promises';
|
|
16
|
+
import { stdin, stdout, env, platform, exit } from 'node:process';
|
|
17
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { resolve, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const projectRoot = resolve(__dirname, '..');
|
|
24
|
+
const envPath = resolve(projectRoot, '.env.local');
|
|
25
|
+
const envExamplePath = resolve(projectRoot, '.env.example');
|
|
26
|
+
|
|
27
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
28
|
+
const ask = (q) => rl.question(q);
|
|
29
|
+
const log = (s) => stdout.write(s + '\n');
|
|
30
|
+
const hr = () => log('\n────────────────────────────────────────────────────────\n');
|
|
31
|
+
|
|
32
|
+
const FAL_DASHBOARD = 'https://fal.ai/dashboard/keys';
|
|
33
|
+
|
|
34
|
+
async function fileExists(p) {
|
|
35
|
+
try { await access(p); return true; } catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function openInBrowser(url) {
|
|
39
|
+
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
|
|
40
|
+
try {
|
|
41
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
log('\nEditions Scroll Generator — setup wizard\n');
|
|
50
|
+
log('This wizard wires up fal.ai for AI-generated chapter images.');
|
|
51
|
+
log('You can skip it and the page still renders beautifully with CSS-only visuals.\n');
|
|
52
|
+
|
|
53
|
+
// ── Step 1: existing .env.local? ────────────────────────────────────────
|
|
54
|
+
if (await fileExists(envPath)) {
|
|
55
|
+
const overwrite = (await ask(' .env.local already exists. Overwrite? [y/N] ')).trim().toLowerCase();
|
|
56
|
+
if (overwrite !== 'y' && overwrite !== 'yes') {
|
|
57
|
+
log('\n Keeping existing .env.local. Run `npm run dev` to start.\n');
|
|
58
|
+
rl.close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hr();
|
|
64
|
+
log(' Choose your mode:\n');
|
|
65
|
+
log(' [1] Demo mode — no fal.ai needed, CSS-only chapter visuals (default)');
|
|
66
|
+
log(' [2] Real images — set up fal.ai now and generate AI chapter heroes');
|
|
67
|
+
log('');
|
|
68
|
+
const mode = (await ask(' Pick 1 or 2 [1]: ')).trim() || '1';
|
|
69
|
+
|
|
70
|
+
if (mode === '1') {
|
|
71
|
+
// Write a minimal demo-mode .env.local so Next.js boots clean
|
|
72
|
+
const example = (await fileExists(envExamplePath)) ? await readFile(envExamplePath, 'utf8') : '';
|
|
73
|
+
const minimal = [
|
|
74
|
+
'# Demo mode — no fal.ai key required.',
|
|
75
|
+
'# To enable real AI image generation, re-run `npm run setup` and choose option 2.',
|
|
76
|
+
'NEXT_PUBLIC_SITE_NAME="Editions Demo"',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n');
|
|
79
|
+
await writeFile(envPath, example.includes('NEXT_PUBLIC_SITE_NAME') ? minimal : minimal);
|
|
80
|
+
hr();
|
|
81
|
+
log(' Demo mode is ready. Now run:\n');
|
|
82
|
+
log(' npm run dev\n');
|
|
83
|
+
log(' Open http://localhost:3000 to see your 8-chapter page.\n');
|
|
84
|
+
rl.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Step 2: fal.ai key flow ─────────────────────────────────────────────
|
|
89
|
+
hr();
|
|
90
|
+
log(' Step 1 of 3: Get a fal.ai API key\n');
|
|
91
|
+
log(` Opening ${FAL_DASHBOARD} in your browser...`);
|
|
92
|
+
log(' (If it doesn\'t open, copy the URL above manually.)\n');
|
|
93
|
+
openInBrowser(FAL_DASHBOARD);
|
|
94
|
+
log(' On the fal.ai dashboard:');
|
|
95
|
+
log(' 1. Sign in (GitHub or email)');
|
|
96
|
+
log(' 2. Click "Create API Key"');
|
|
97
|
+
log(' 3. Copy the key — format is key_id:key_secret (two parts, separated by ":")\n');
|
|
98
|
+
|
|
99
|
+
let key = '';
|
|
100
|
+
while (!key) {
|
|
101
|
+
key = (await ask(' Paste your fal.ai key here: ')).trim();
|
|
102
|
+
if (!key.includes(':') || key.length < 20) {
|
|
103
|
+
log(' ! That doesn\'t look right. The key has the format key_id:key_secret\n');
|
|
104
|
+
key = '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
hr();
|
|
109
|
+
log(' Step 2 of 3: Pick a default model\n');
|
|
110
|
+
log(' [1] fal-ai/flux-2-pro (recommended — best balance, ~$0.06/img, ~4s)');
|
|
111
|
+
log(' [2] fal-ai/flux-2/turbo (fast drafts, ~$0.02/img, ~2s)');
|
|
112
|
+
log(' [3] fal-ai/gemini-3-pro-image-preview (Nano Banana Pro, best at text-in-image, ~$0.15/img)');
|
|
113
|
+
log(' [4] Other (I\'ll pick from MODELS.md later — defaults to flux-2-pro)\n');
|
|
114
|
+
const m = (await ask(' Pick 1–4 [1]: ')).trim() || '1';
|
|
115
|
+
const modelMap = {
|
|
116
|
+
1: 'fal-ai/flux-2-pro',
|
|
117
|
+
2: 'fal-ai/flux-2/turbo',
|
|
118
|
+
3: 'fal-ai/gemini-3-pro-image-preview',
|
|
119
|
+
4: 'fal-ai/flux-2-pro',
|
|
120
|
+
};
|
|
121
|
+
const model = modelMap[m] ?? 'fal-ai/flux-2-pro';
|
|
122
|
+
|
|
123
|
+
// ── Step 3: write .env.local ────────────────────────────────────────────
|
|
124
|
+
const contents = [
|
|
125
|
+
'# Generated by `npm run setup`',
|
|
126
|
+
`FAL_KEY="${key}"`,
|
|
127
|
+
`FAL_IMAGE_MODEL="${model}"`,
|
|
128
|
+
'NEXT_PUBLIC_SITE_NAME="Editions Demo"',
|
|
129
|
+
'',
|
|
130
|
+
].join('\n');
|
|
131
|
+
await writeFile(envPath, contents);
|
|
132
|
+
|
|
133
|
+
hr();
|
|
134
|
+
log(' .env.local written. FAL_KEY stays on the server (never sent to the browser).\n');
|
|
135
|
+
|
|
136
|
+
// ── Step 4: test image ──────────────────────────────────────────────────
|
|
137
|
+
log(' Step 3 of 3: Generate one test image to verify everything works');
|
|
138
|
+
log(' This costs roughly $0.06 in fal.ai credits.\n');
|
|
139
|
+
const test = (await ask(' Generate one test image now? [Y/n] ')).trim().toLowerCase();
|
|
140
|
+
|
|
141
|
+
if (test === '' || test === 'y' || test === 'yes') {
|
|
142
|
+
log('\n Running: node scripts/generate-chapter-assets.mjs --only prologue\n');
|
|
143
|
+
env.FAL_KEY = key;
|
|
144
|
+
env.FAL_IMAGE_MODEL = model;
|
|
145
|
+
await new Promise((res) => {
|
|
146
|
+
const child = spawn('node', ['scripts/generate-chapter-assets.mjs', '--only', 'prologue'], {
|
|
147
|
+
cwd: projectRoot,
|
|
148
|
+
stdio: 'inherit',
|
|
149
|
+
env,
|
|
150
|
+
});
|
|
151
|
+
child.on('close', res);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
hr();
|
|
156
|
+
log(' Setup complete. Run:\n');
|
|
157
|
+
log(' npm run dev\n');
|
|
158
|
+
log(' Open http://localhost:3000\n');
|
|
159
|
+
log(' To generate the remaining 7 chapter images:');
|
|
160
|
+
log(' node scripts/generate-chapter-assets.mjs\n');
|
|
161
|
+
log(' To swap models later, just edit FAL_IMAGE_MODEL in .env.local — no code changes needed.\n');
|
|
162
|
+
|
|
163
|
+
rl.close();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
main().catch((err) => {
|
|
167
|
+
console.error('\n setup failed:', err.message);
|
|
168
|
+
rl.close();
|
|
169
|
+
exit(1);
|
|
170
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Config } from 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
content: [
|
|
5
|
+
'./app/**/*.{ts,tsx}',
|
|
6
|
+
'./components/**/*.{ts,tsx}',
|
|
7
|
+
'./lib/**/*.{ts,tsx}',
|
|
8
|
+
],
|
|
9
|
+
theme: {
|
|
10
|
+
extend: {
|
|
11
|
+
fontFamily: {
|
|
12
|
+
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
|
13
|
+
},
|
|
14
|
+
// Fluid type scale — pairs with --fluid-* CSS vars in globals.css
|
|
15
|
+
fontSize: {
|
|
16
|
+
'fluid-eyebrow': ['var(--fluid-eyebrow)', { lineHeight: '1.4', letterSpacing: '0.18em' }],
|
|
17
|
+
'fluid-body': ['var(--fluid-body)', { lineHeight: '1.55' }],
|
|
18
|
+
'fluid-headline': ['var(--fluid-headline)', { lineHeight: '1.02', letterSpacing: '-0.025em' }],
|
|
19
|
+
'fluid-display': ['var(--fluid-display)', { lineHeight: '0.92', letterSpacing: '-0.045em' }],
|
|
20
|
+
},
|
|
21
|
+
screens: {
|
|
22
|
+
// Standard sm/md/lg/xl/2xl plus tall-phone + tablet-portrait helpers
|
|
23
|
+
xs: '420px',
|
|
24
|
+
'ipad-portrait': { raw: '(min-width: 768px) and (max-width: 1024px) and (orientation: portrait)' },
|
|
25
|
+
},
|
|
26
|
+
colors: {
|
|
27
|
+
ink: '#0b0907',
|
|
28
|
+
},
|
|
29
|
+
boxShadow: {
|
|
30
|
+
'glow-sm': '0 0 24px rgba(255,255,255,0.08)',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
plugins: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default config;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": {
|
|
18
|
+
"@/*": ["./*"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|