screencraft 0.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 (40) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/.env.example +3 -0
  3. package/MCP_README.md +200 -0
  4. package/README.md +148 -0
  5. package/bin/screencraft.js +61 -0
  6. package/package.json +31 -0
  7. package/src/auth/keystore.js +148 -0
  8. package/src/commands/init.js +119 -0
  9. package/src/commands/launch.js +405 -0
  10. package/src/detectors/detectBrand.js +1222 -0
  11. package/src/detectors/simulator.js +317 -0
  12. package/src/generators/analyzeStyleReference.js +471 -0
  13. package/src/generators/compositePSD.js +682 -0
  14. package/src/generators/copy.js +147 -0
  15. package/src/mcp/index.js +394 -0
  16. package/src/pipeline/aeSwap.js +369 -0
  17. package/src/pipeline/download.js +32 -0
  18. package/src/pipeline/queue.js +101 -0
  19. package/src/server/index.js +627 -0
  20. package/src/server/public/app.js +738 -0
  21. package/src/server/public/index.html +255 -0
  22. package/src/server/public/style.css +751 -0
  23. package/src/server/session.js +36 -0
  24. package/templates/ae/(Footage)/Assets/This Hip-Hop Upbeat (Short version).wav +0 -0
  25. package/templates/ae/(Footage)/Assets/screen_01_raw.png +0 -0
  26. package/templates/ae/(Footage)/Assets/screen_02_raw.png +0 -0
  27. package/templates/ae/(Footage)/Assets/screen_03_raw.png +0 -0
  28. package/templates/ae/(Footage)/Assets/screen_04_raw.png +0 -0
  29. package/templates/ae/(Footage)/Assets/screen_05_raw.png +0 -0
  30. package/templates/ae/(Footage)/Assets/screen_06_raw.png +0 -0
  31. package/templates/ae/Motion Forge Test 1.0 (converted).aep +0 -0
  32. package/templates/ae_swap.jsx +284 -0
  33. package/templates/layouts/minimal.psd +0 -0
  34. package/templates/screencraft.config.example.js +165 -0
  35. package/test/output/layout_test.png +0 -0
  36. package/test/output/style_profile.json +64 -0
  37. package/test/reference.png +0 -0
  38. package/test/test_brand.js +69 -0
  39. package/test/test_psd.js +83 -0
  40. package/test/test_style_analysis.js +114 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * src/commands/init.js
3
+ * --------------------
4
+ * Generates screencraft.config.js and .mcp.json in the project root.
5
+ * Auto-fills detected values where possible.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const chalk = require('chalk');
11
+
12
+ const { detectFramework, detectBrand } = require('../detectors/detectBrand');
13
+
14
+ module.exports = async function init() {
15
+ const ROOT = process.cwd();
16
+
17
+ // ── Detect what we can ────────────────────────────────────────
18
+ let framework = { name: 'unknown', platform: 'ios' };
19
+ let brand = {};
20
+
21
+ try {
22
+ console.log(chalk.dim(' Detecting project...'));
23
+ framework = await detectFramework(ROOT);
24
+ brand = await detectBrand(ROOT, {});
25
+ console.log(chalk.hex('#6BC46A')(` ✓ Detected: ${framework.name}`));
26
+ } catch {
27
+ console.log(chalk.dim(' Could not auto-detect framework — using defaults'));
28
+ }
29
+
30
+ // ── screencraft.config.js ─────────────────────────────────────
31
+ const configPath = path.join(ROOT, 'screencraft.config.js');
32
+
33
+ if (fs.existsSync(configPath)) {
34
+ console.log(chalk.dim(' screencraft.config.js already exists — skipping'));
35
+ } else {
36
+ const configContent = `/**
37
+ * screencraft.config.js
38
+ * ---------------------
39
+ * All fields are optional. ScreenCraft auto-detects everything it can.
40
+ * Override here if detection is wrong or you want different values.
41
+ *
42
+ * Docs: https://screencraft.dev/docs
43
+ */
44
+
45
+ module.exports = {
46
+ app: {
47
+ name: ${brand.appName ? `"${brand.appName}"` : 'null'}, // App display name
48
+ tagline: null, // One-line tagline (used in video title card)
49
+ },
50
+
51
+ brand: {
52
+ primary: ${brand.primary ? `"${brand.primary}"` : 'null'}, // e.g. "#1B3A6B" — null = auto-detect
53
+ secondary: ${brand.secondary ? `"${brand.secondary}"` : 'null'},
54
+ accent: ${brand.accent ? `"${brand.accent}"` : 'null'},
55
+ background: ${brand.background ? `"${brand.background}"` : 'null'},
56
+ icon: null, // Path to app icon PNG (1024x1024). null = auto-detect
57
+ font: null, // e.g. "Inter" — null = auto-detect
58
+ },
59
+
60
+ screenshots: [
61
+ // Define screens for auto-capture (optional).
62
+ // If omitted, use the web UI to capture interactively.
63
+ // { route: "/home", label: "Home screen" },
64
+ // { route: "/settings", label: "Settings" },
65
+ ],
66
+
67
+ output: {
68
+ dir: "./launch-kit", // Output directory
69
+ },
70
+ };
71
+ `;
72
+ fs.writeFileSync(configPath, configContent);
73
+ console.log(chalk.hex('#6BC46A')(' ✓ Created screencraft.config.js'));
74
+ }
75
+
76
+ // ── .mcp.json ─────────────────────────────────────────────────
77
+ const mcpPath = path.join(ROOT, '.mcp.json');
78
+
79
+ if (fs.existsSync(mcpPath)) {
80
+ // Check if screencraft is already in it
81
+ try {
82
+ const existing = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
83
+ if (existing.mcpServers?.screencraft) {
84
+ console.log(chalk.dim(' .mcp.json already has screencraft — skipping'));
85
+ } else {
86
+ // Add screencraft to existing config
87
+ existing.mcpServers = existing.mcpServers || {};
88
+ existing.mcpServers.screencraft = {
89
+ type: 'stdio',
90
+ command: 'npx',
91
+ args: ['screencraft', '--mcp'],
92
+ };
93
+ fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n');
94
+ console.log(chalk.hex('#6BC46A')(' ✓ Added screencraft to .mcp.json'));
95
+ }
96
+ } catch {
97
+ console.log(chalk.dim(' .mcp.json exists but could not parse — skipping'));
98
+ }
99
+ } else {
100
+ const mcpContent = {
101
+ mcpServers: {
102
+ screencraft: {
103
+ type: 'stdio',
104
+ command: 'npx',
105
+ args: ['screencraft', '--mcp'],
106
+ },
107
+ },
108
+ };
109
+ fs.writeFileSync(mcpPath, JSON.stringify(mcpContent, null, 2) + '\n');
110
+ console.log(chalk.hex('#6BC46A')(' ✓ Created .mcp.json (MCP integration for Claude Code/Desktop)'));
111
+ }
112
+
113
+ // ── Done ──────────────────────────────────────────────────────
114
+ console.log('');
115
+ console.log(chalk.dim(' Next steps:'));
116
+ console.log(' ' + chalk.white('screencraft serve') + chalk.dim(' — open the web UI'));
117
+ console.log(' ' + chalk.white('screencraft launch') + chalk.dim(' — run the full pipeline'));
118
+ console.log('');
119
+ };
@@ -0,0 +1,405 @@
1
+ /**
2
+ * src/commands/launch.js
3
+ * ----------------------
4
+ * Main pipeline orchestrator. Runs in sequence:
5
+ * 1. Detect framework + brand
6
+ * 2. Capture or load screenshots
7
+ * 3. Suggest headline text via Claude API
8
+ * 4. User approves/edits text interactively
9
+ * 5. Gate: check license key
10
+ * 6. Composite PSDs
11
+ * 7. Submit render job (AE video)
12
+ * 8. Download + unpack bundle
13
+ * 9. Open output folder
14
+ */
15
+
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const chalk = require('chalk');
19
+ const ora = require('ora');
20
+ const inquirer = require('inquirer');
21
+
22
+ const { detectFramework, detectBrand } = require('../detectors/detectBrand');
23
+ const { captureScreenshots } = require('../detectors/simulator');
24
+ const { suggestHeadlines } = require('../generators/copy');
25
+ const { compositePSD } = require('../generators/compositePSD');
26
+ const { validateKey, getStoredKey } = require('../auth/keystore');
27
+ const { submitRenderJob, pollJob } = require('../pipeline/queue');
28
+ const { downloadBundle } = require('../pipeline/download');
29
+ const { prepareAEProject, renderLocally } = require('../pipeline/aeSwap');
30
+
31
+ const ROOT = process.cwd();
32
+
33
+ module.exports = async function launch(args) {
34
+ let screenshotsOnly = args.includes('--screenshots-only');
35
+
36
+ // ── Load config ───────────────────────────────────────────────
37
+ const configPath = path.join(ROOT, 'screencraft.config.js');
38
+ const config = fs.existsSync(configPath)
39
+ ? require(configPath)
40
+ : {};
41
+
42
+ const outputDir = path.join(ROOT, config.output?.dir || './launch-kit');
43
+ fs.mkdirSync(outputDir, { recursive: true });
44
+ fs.mkdirSync(path.join(outputDir, 'screenshots'), { recursive: true });
45
+ fs.mkdirSync(path.join(outputDir, 'video'), { recursive: true });
46
+ fs.mkdirSync(path.join(outputDir, 'source'), { recursive: true });
47
+ fs.mkdirSync(path.join(outputDir, 'brand'), { recursive: true });
48
+
49
+ // ── PHASE 1: Detect ───────────────────────────────────────────
50
+ log.step('1/5', 'Detecting project...');
51
+ const framework = await detectFramework(ROOT);
52
+ const brand = await detectBrand(ROOT, config);
53
+
54
+ log.success(`Framework: ${framework.name}`);
55
+ log.success(`App name: ${brand.appName || '(not found — set app.name in config)'}`);
56
+ log.success(`Primary: ${brand.primary}`);
57
+ log.success(`Accent: ${brand.accent}`);
58
+ log.success(`Icon: ${brand.icon ? 'found' : chalk.yellow('not found')}`);
59
+ log.success(`Font: ${brand.font?.family || chalk.yellow('not found (using system font)')}`);
60
+ log.success(`Logo: ${brand.logo ? 'found' : chalk.yellow('not found')}`);
61
+ console.log('');
62
+
63
+ // ── PHASE 2: Screenshots ──────────────────────────────────────
64
+ log.step('2/5', 'Capturing screenshots...');
65
+
66
+ let screenshots;
67
+
68
+ try {
69
+ screenshots = await captureScreenshots(ROOT, framework, {
70
+ screenConfigs: config.screenshots || null,
71
+ outputDir: outputDir,
72
+ log,
73
+ });
74
+ } catch (err) {
75
+ log.warn(err.message);
76
+ process.exit(1);
77
+ }
78
+ console.log('');
79
+
80
+ // ── PHASE 3: AI Headline Suggestions ─────────────────────────
81
+ log.step('3/5', 'Generating headline suggestions with AI...');
82
+ log.info('Sending screenshots to Claude...');
83
+
84
+ const suggestions = await suggestHeadlines(screenshots, brand, config);
85
+ console.log('');
86
+
87
+ // ── PHASE 4: Interactive approval ────────────────────────────
88
+ log.step('4/5', 'Approve headline text for each screen');
89
+ console.log(chalk.dim(' Format: white text + accent color word'));
90
+ console.log(chalk.dim(' Press enter to accept suggestion, or type a replacement'));
91
+ console.log('');
92
+
93
+ const approvedTexts = [];
94
+
95
+ for (let i = 0; i < screenshots.length; i++) {
96
+ const screen = screenshots[i];
97
+ const opts = suggestions[i];
98
+
99
+ console.log(chalk.hex('#A78BFA')(` Screen ${i + 1}:`), chalk.dim(path.basename(screen.file)));
100
+ console.log(chalk.dim(` ↳ ${opts.description || ''}`));
101
+ console.log('');
102
+
103
+ // Show the 3 options
104
+ opts.options.forEach((o, idx) => {
105
+ console.log(` ${chalk.dim(`[${idx + 1}]`)} ${chalk.white(o.white)} ${chalk.hex('#A78BFA')(o.accent)}`);
106
+ });
107
+ console.log(` ${chalk.dim('[c]')} custom`);
108
+ console.log('');
109
+
110
+ const { choice } = await inquirer.prompt([{
111
+ type: 'input',
112
+ name: 'choice',
113
+ message: chalk.dim(' Select (1/2/3/c) or press enter for [1]:'),
114
+ default: '1',
115
+ }]);
116
+
117
+ let approved;
118
+ if (choice === 'c' || choice === 'C') {
119
+ const { custom } = await inquirer.prompt([{
120
+ type: 'input',
121
+ name: 'custom',
122
+ message: chalk.dim(' Enter text (use | to split white|accent):'),
123
+ }]);
124
+ const [w, a] = custom.includes('|') ? custom.split('|') : [custom, ''];
125
+ approved = { white: w.trim(), accent: a.trim() };
126
+ } else {
127
+ const idx = Math.max(0, Math.min(2, parseInt(choice || '1') - 1));
128
+ approved = opts.options[idx] || opts.options[0];
129
+ }
130
+
131
+ approvedTexts.push(approved);
132
+ log.success(`Approved: "${approved.white} ${approved.accent}"`);
133
+ console.log('');
134
+ }
135
+
136
+ // ── GATE: License check ───────────────────────────────────────
137
+ if (!screenshotsOnly) {
138
+ console.log(chalk.dim('─────────────────────────────────────'));
139
+ const key = getStoredKey();
140
+ let tier = null;
141
+
142
+ if (key) {
143
+ log.info('Validating license key...');
144
+ tier = await validateKey(key);
145
+ if (tier) {
146
+ log.success(`License: ${tier} tier active`);
147
+ } else {
148
+ log.warn('License key invalid or expired.');
149
+ }
150
+ }
151
+
152
+ if (!tier) {
153
+ console.log('');
154
+ console.log(chalk.hex('#C9A84C')(' A license key is required to generate the video + AE file.'));
155
+ console.log(chalk.dim(' Get one at: ') + chalk.white('screencraft.dev/activate'));
156
+ console.log('');
157
+
158
+ const { enteredKey } = await inquirer.prompt([{
159
+ type: 'input',
160
+ name: 'enteredKey',
161
+ message: chalk.dim(' Enter license key (or press enter to generate screenshots only):'),
162
+ }]);
163
+
164
+ if (enteredKey.trim()) {
165
+ tier = await validateKey(enteredKey.trim());
166
+ if (!tier) {
167
+ log.warn('Invalid key. Continuing with screenshots only.');
168
+ screenshotsOnly = true;
169
+ }
170
+ } else {
171
+ screenshotsOnly = true;
172
+ }
173
+ }
174
+ console.log('');
175
+ }
176
+
177
+ // ── PHASE 5: Generate ─────────────────────────────────────────
178
+ log.step('5/5', 'Generating launch kit...');
179
+ console.log('');
180
+
181
+ // Always: composite App Store PNGs
182
+ const spinner = ora({ text: 'Compositing device mockups...', color: 'magenta' }).start();
183
+
184
+ const psdOutputs = [];
185
+ for (let i = 0; i < screenshots.length; i++) {
186
+ const screen = screenshots[i];
187
+ const text = approvedTexts[i];
188
+ const outPng = path.join(outputDir, 'screenshots', `screen_${String(i + 1).padStart(2, '0')}.png`);
189
+ const outPsd = path.join(outputDir, 'source', `screen_${String(i + 1).padStart(2, '0')}.psd`);
190
+
191
+ spinner.text = `Compositing screen ${i + 1} of ${screenshots.length}...`;
192
+
193
+ await compositePSD({
194
+ screenshotPath: screen.file,
195
+ headlineWhite: text.white,
196
+ headlineAccent: text.accent,
197
+ brand,
198
+ font: brand.font || {},
199
+ outputPng: outPng,
200
+ outputPsd: outPsd,
201
+ writePsd: !screenshotsOnly,
202
+ });
203
+
204
+ psdOutputs.push({ png: outPng, psd: outPsd });
205
+ }
206
+
207
+ spinner.succeed('Screenshots composited');
208
+
209
+ // Copy brand assets into output folder
210
+ const assetsDir = path.join(outputDir, 'brand');
211
+ fs.mkdirSync(assetsDir, { recursive: true });
212
+
213
+ const copyAsset = (src, destName) => {
214
+ try {
215
+ if (src && fs.existsSync(src)) {
216
+ fs.copyFileSync(src, path.join(assetsDir, destName));
217
+ return true;
218
+ }
219
+ } catch {}
220
+ return false;
221
+ };
222
+
223
+ copyAsset(brand.icon, `app-icon${path.extname(brand.icon || '.png')}`);
224
+ copyAsset(brand.logo, `logo${path.extname(brand.logo || '.png')}`);
225
+ if (brand.font?.file) copyAsset(brand.font.file, `font${path.extname(brand.font.file)}`);
226
+
227
+ // Generate brand kit README
228
+ generateReadme(outputDir, brand, approvedTexts, screenshots, screenshotsOnly);
229
+
230
+ if (!screenshotsOnly) {
231
+ // Prepare AE project with swapped footage + manifest
232
+ const aeSpinner = ora({ text: 'Preparing After Effects project...', color: 'magenta' }).start();
233
+
234
+ try {
235
+ const ae = await prepareAEProject({
236
+ brand,
237
+ texts: approvedTexts,
238
+ screenshots,
239
+ outputDir,
240
+ log,
241
+ });
242
+
243
+ if (ae.aepPath) {
244
+ aeSpinner.succeed('AE project prepared');
245
+
246
+ // Try local render if aerender is available
247
+ if (ae.canRender) {
248
+ const renderSpinner = ora({ text: 'Rendering video...', color: 'magenta' }).start();
249
+ try {
250
+ const videoPath = await renderLocally({
251
+ aepPath: ae.aepPath,
252
+ outputDir,
253
+ onProgress: (step) => { renderSpinner.text = step; },
254
+ });
255
+ renderSpinner.succeed('Video rendered');
256
+ } catch (err) {
257
+ renderSpinner.warn('Local render failed — open source/app-launch.aep in After Effects');
258
+ }
259
+ } else {
260
+ log.info('Open source/app-launch.aep in After Effects to render the video');
261
+ log.info('Or run: File → Scripts → Run Script File → ae_swap.jsx');
262
+ }
263
+ } else {
264
+ aeSpinner.warn('No AE template found — skipping video');
265
+ }
266
+ } catch (err) {
267
+ aeSpinner.warn('AE project preparation failed: ' + err.message);
268
+ }
269
+ }
270
+
271
+ // ── Done ──────────────────────────────────────────────────────
272
+ console.log('');
273
+ console.log(chalk.dim('─────────────────────────────────────'));
274
+ console.log(chalk.hex('#6BC46A')(' ✓ Launch kit ready → ') + chalk.white(outputDir));
275
+ console.log('');
276
+ console.log(chalk.dim(' screenshots/') + chalk.hex('#6BC46A')(` ← ${screenshots.length} composited + ${screenshots.length} raw PNGs`));
277
+ console.log(chalk.dim(' brand/ ') + chalk.hex('#6BC46A')(' ← icon, logo, font'));
278
+
279
+ if (!screenshotsOnly) {
280
+ console.log(chalk.dim(' source/ ') + chalk.hex('#6BC46A')(' ← app-launch.aep + PSDs'));
281
+ console.log(chalk.dim(' video/ ') + chalk.hex('#6BC46A')(' ← preview.mp4'));
282
+ }
283
+
284
+ console.log(chalk.dim(' README.md ') + chalk.hex('#6BC46A')(' ← brand kit + checklist'));
285
+ console.log('');
286
+
287
+ if (screenshotsOnly) {
288
+ console.log(chalk.hex('#C9A84C')(' Video + AE file → screencraft.dev/activate'));
289
+ console.log('');
290
+ }
291
+
292
+ // Open the output folder
293
+ const { exec } = require('child_process');
294
+ exec(`open "${outputDir}"`);
295
+ };
296
+
297
+ // ── Helpers ──────────────────────────────────────────────────────
298
+ const log = {
299
+ step: (n, msg) => console.log(chalk.dim(`[${n}]`) + ' ' + chalk.white(msg)),
300
+ success: (msg) => console.log(' ' + chalk.hex('#6BC46A')('✓') + ' ' + chalk.dim(msg)),
301
+ info: (msg) => console.log(' ' + chalk.hex('#6AABDD')('↳') + ' ' + chalk.dim(msg)),
302
+ warn: (msg) => console.log(' ' + chalk.hex('#C9A84C')('⚠') + ' ' + chalk.hex('#C9A84C')(msg)),
303
+ };
304
+
305
+ function generateReadme(outputDir, brand, texts, screenshots, screenshotsOnly) {
306
+ const appName = brand.appName || 'App';
307
+ const date = new Date().toLocaleDateString();
308
+
309
+ // ── Brand Colors section
310
+ const colors = [
311
+ ['Primary', brand.primary],
312
+ ['Secondary', brand.secondary],
313
+ ['Accent', brand.accent],
314
+ ['Background', brand.background],
315
+ ].filter(([, hex]) => hex);
316
+
317
+ const colorRows = colors
318
+ .map(([name, hex]) => `| ${name} | \`${hex}\` | ![${name}](https://via.placeholder.com/24/${hex.replace('#', '')}/${hex.replace('#', '')}.png) |`)
319
+ .join('\n');
320
+
321
+ // ── Brand Assets section
322
+ const assetRows = [];
323
+ if (brand.icon) assetRows.push('| App Icon | `brand/app-icon' + path.extname(brand.icon) + '` | 1024×1024 PNG |');
324
+ if (brand.logo) assetRows.push('| Logo | `brand/logo' + path.extname(brand.logo) + '` | Vector/PNG |');
325
+ if (brand.font?.file) assetRows.push(`| Font | \`brand/font${path.extname(brand.font.file)}\` | ${brand.font.family || 'Custom'}${brand.font.weight ? ' ' + brand.font.weight : ''} |`);
326
+
327
+ // ── Screenshots section
328
+ const screenRows = screenshots.map((s, i) => {
329
+ const t = texts[i];
330
+ const headline = t ? `"${t.white} **${t.accent}**"` : '—';
331
+ return `| ${i + 1} | \`screenshots/screen_${String(i + 1).padStart(2, '0')}.png\` | ${headline} | \`screenshots/${path.basename(s.file)}\` |`;
332
+ }).join('\n');
333
+
334
+ const readme = `# Launch Kit — ${appName}
335
+
336
+ > Generated by [ScreenCraft](https://screencraft.dev) on ${date}
337
+
338
+ ---
339
+
340
+ ## Brand Colors
341
+
342
+ | Name | Hex | Swatch |
343
+ |------|-----|--------|
344
+ ${colorRows}
345
+
346
+ ## Brand Assets
347
+
348
+ | Asset | File | Details |
349
+ |-------|------|---------|
350
+ ${assetRows.length > 0 ? assetRows.join('\n') : '| — | No assets detected | Set in screencraft.config.js |'}
351
+
352
+ ${brand.font?.family ? `**Font:** ${brand.font.family}${brand.font.weight ? ' (' + brand.font.weight + ')' : ''}` : '**Font:** System default (no custom font detected)'}
353
+
354
+ ---
355
+
356
+ ## Screenshots
357
+
358
+ | # | Composited | Headline | Raw |
359
+ |---|------------|----------|-----|
360
+ ${screenRows}
361
+
362
+ ---
363
+
364
+ ## Files
365
+
366
+ | Path | Description |
367
+ |------|-------------|
368
+ | \`screenshots/\` | App Store-ready composited PNGs (1290×2796) |
369
+ | \`screenshots/*_raw.png\` | Original simulator captures |
370
+ | \`brand/\` | Extracted brand assets (icon, logo, font) |
371
+ ${screenshotsOnly ? '' : `| \`source/app-launch.aep\` | Editable After Effects project |
372
+ | \`source/*.psd\` | Layered Photoshop files |
373
+ | \`video/preview.mp4\` | App Store preview video |
374
+ `}| \`README.md\` | This file — brand kit reference |
375
+
376
+ ---
377
+
378
+ ## App Store Submission Checklist
379
+
380
+ ### iOS App Store
381
+ - [ ] Screenshots: 6.7" (1290×2796) — required
382
+ - [ ] Screenshots: 6.1" (1179×2556) — recommended
383
+ - [ ] App preview video: up to 30s, 9:16 vertical
384
+ - [ ] Icon: 1024×1024 PNG, no alpha, no rounded corners
385
+
386
+ ### Google Play
387
+ - [ ] Feature graphic: 1024×500
388
+ - [ ] Screenshots: min 2, up to 8, 16:9 or 9:16
389
+ - [ ] Hi-res icon: 512×512
390
+
391
+ ---
392
+
393
+ ## Regenerate
394
+
395
+ \`\`\`bash
396
+ cd your-app-project
397
+ npx screencraft launch
398
+ \`\`\`
399
+
400
+ ${screenshotsOnly ? `> Video + AE project file available with a license key — [screencraft.dev/activate](https://screencraft.dev/activate)\n` : ''}---
401
+ [screencraft.dev](https://screencraft.dev)
402
+ `;
403
+
404
+ fs.writeFileSync(path.join(outputDir, 'README.md'), readme);
405
+ }