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,471 @@
1
+ /**
2
+ * src/generators/analyzeStyleReference.js
3
+ * ----------------------------------------
4
+ * Sends a reference screenshot image to Claude Vision and extracts
5
+ * a structured StyleProfile that drives the compositor.
6
+ *
7
+ * The StyleProfile controls:
8
+ * - How many phones are shown per frame
9
+ * - Phone tilt angles, scales, and positions
10
+ * - Background color/texture treatment
11
+ * - Phone frame style (white, dark, colored border, none)
12
+ * - Typography style and placement
13
+ * - Overall spacing density
14
+ *
15
+ * For complex layouts (stacked phones, floating elements, etc.)
16
+ * the function also generates a custom layout function string
17
+ * that gets written to src/layouts/ and cached for reuse.
18
+ *
19
+ * Usage:
20
+ * const { analyzeStyleReference } = require('./analyzeStyleReference');
21
+ * const profile = await analyzeStyleReference('./reference.png', brand);
22
+ * // profile is a StyleProfile object ready to pass to compositePSD()
23
+ *
24
+ * Test:
25
+ * node test/test_style_analysis.js ./reference.png
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
32
+ const LAYOUTS_CACHE = path.join(__dirname, '../../src/layouts');
33
+
34
+ // ─────────────────────────────────────────────────────────────────
35
+ // MAIN EXPORT
36
+ // ─────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * analyzeStyleReference(imagePath, brand)
40
+ *
41
+ * @param {string} imagePath — path to the reference image (PNG/JPG)
42
+ * @param {object} brand — detected brand (primary, accent, appName, etc.)
43
+ * @returns {StyleProfile} — structured style config for the compositor
44
+ */
45
+ async function analyzeStyleReference(imagePath, brand) {
46
+ const apiKey = process.env.ANTHROPIC_API_KEY;
47
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
48
+
49
+ console.log(' ↳ Sending reference image to Claude Vision...');
50
+
51
+ const imageBuffer = fs.readFileSync(imagePath);
52
+ const imageB64 = imageBuffer.toString('base64');
53
+ const mimeType = imagePath.toLowerCase().endsWith('.jpg') ? 'image/jpeg' : 'image/png';
54
+
55
+ const response = await fetch(ANTHROPIC_API, {
56
+ method: 'POST',
57
+ headers: {
58
+ 'Content-Type': 'application/json',
59
+ 'x-api-key': apiKey,
60
+ 'anthropic-version': '2023-06-01',
61
+ },
62
+ body: JSON.stringify({
63
+ model: 'claude-opus-4-5',
64
+ max_tokens: 2000,
65
+ system: SYSTEM_PROMPT,
66
+ messages: [{
67
+ role: 'user',
68
+ content: [
69
+ {
70
+ type: 'image',
71
+ source: { type: 'base64', media_type: mimeType, data: imageB64 },
72
+ },
73
+ {
74
+ type: 'text',
75
+ text: `Analyze this App Store screenshot layout as a style reference.
76
+
77
+ The app that will use this style has:
78
+ - Primary brand color: ${brand.primary}
79
+ - Accent color: ${brand.accent}
80
+ - App name: ${brand.appName || 'the app'}
81
+
82
+ Extract the complete StyleProfile JSON. Focus on what makes this layout distinctive
83
+ and how to replicate the visual feel with different screenshots and brand colors.
84
+
85
+ Return ONLY the JSON object — no explanation, no markdown fences.`,
86
+ }
87
+ ],
88
+ }],
89
+ }),
90
+ });
91
+
92
+ if (!response.ok) {
93
+ throw new Error(`Claude API error: ${response.status}`);
94
+ }
95
+
96
+ const data = await response.json();
97
+ const raw = data.content?.[0]?.text || '';
98
+
99
+ // Parse the JSON response
100
+ let profile;
101
+ try {
102
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
103
+ if (!jsonMatch) throw new Error('No JSON found in response');
104
+ profile = JSON.parse(jsonMatch[0]);
105
+ } catch (err) {
106
+ console.warn(' ⚠ Could not parse style profile, using defaults');
107
+ profile = defaultStyleProfile(brand);
108
+ }
109
+
110
+ // Validate and fill missing fields with defaults
111
+ profile = mergeWithDefaults(profile, brand);
112
+
113
+ // Adapt extracted colors to the user's actual brand
114
+ profile = adaptColorsToBrand(profile, brand);
115
+
116
+ // If layout is complex, generate + cache a layout function
117
+ if (profile.layout.type === 'multi-phone' || profile.layout.type === 'grid') {
118
+ profile.layoutFunctionPath = await generateLayoutFunction(profile, brand);
119
+ }
120
+
121
+ console.log(' ✓ Style profile extracted');
122
+ console.log(` Layout: ${profile.layout.type} (${profile.layout.phoneCount} phone${profile.layout.phoneCount > 1 ? 's' : ''})`);
123
+ console.log(` Background: ${profile.background.color}`);
124
+ console.log(` Frame: ${profile.phoneFrame.style}`);
125
+ console.log(` Typography: ${profile.typography.headlineStyle}`);
126
+
127
+ return profile;
128
+ }
129
+
130
+ // ─────────────────────────────────────────────────────────────────
131
+ // SYSTEM PROMPT
132
+ // ─────────────────────────────────────────────────────────────────
133
+
134
+ const SYSTEM_PROMPT = `You are a computer vision system that analyzes App Store marketing screenshot layouts.
135
+
136
+ Your job is to extract a precise StyleProfile JSON from a reference image. This profile will be used
137
+ to programmatically recreate a similar layout with different app screenshots and brand colors.
138
+
139
+ Return ONLY a JSON object with this exact structure (fill every field):
140
+
141
+ {
142
+ "layout": {
143
+ "type": "single" | "multi-phone" | "grid" | "hero-with-detail",
144
+ "phoneCount": <number 1-4>,
145
+ "arrangement": "single" | "stacked-cascade" | "side-by-side" | "floating-cluster" | "hero-plus-inset",
146
+ "tiltAngles": [<degrees per phone, negative=left tilt, 0=straight>],
147
+ "phoneScales": [<relative scale 0.5-1.0 per phone, 1.0=largest/front>],
148
+ "phonePositions": [
149
+ { "anchor": "center" | "left" | "right" | "top-left" | "bottom-right", "xOffset": <-50 to 50 percent>, "yOffset": <-50 to 50 percent> }
150
+ ],
151
+ "textPosition": "top-left" | "top-center" | "bottom-left" | "bottom-right" | "left-of-phone" | "right-of-phone" | "above-phone"
152
+ },
153
+
154
+ "background": {
155
+ "type": "solid" | "gradient" | "noise-texture" | "split",
156
+ "color": "<dominant background hex color>",
157
+ "gradientEndColor": "<second color if gradient, else null>",
158
+ "gradientAngle": <degrees, null if not gradient>,
159
+ "noiseAmount": <0.0-1.0, 0 if no texture>,
160
+ "adaptToBrand": <true if background should use user's brand primary color, false if it should stay as-is>
161
+ },
162
+
163
+ "phoneFrame": {
164
+ "style": "white-minimal" | "dark" | "colored-border" | "no-frame" | "shadow-only",
165
+ "borderColor": "<hex if colored-border, else null>",
166
+ "borderWidth": <px equivalent, 0 if none>,
167
+ "shadowStyle": "none" | "soft" | "medium" | "dramatic",
168
+ "shadowColor": "<hex or null>",
169
+ "cornerRadius": <approximate corner radius 0-60>,
170
+ "adaptBorderToBrand": <true if border color should use user's accent color>
171
+ },
172
+
173
+ "typography": {
174
+ "headlineStyle": "large-serif" | "large-sans" | "large-bold-sans" | "mixed-weight",
175
+ "headlineSize": "sm" | "md" | "lg" | "xl" | "xxl",
176
+ "headlineCase": "title" | "upper" | "mixed",
177
+ "headlinePosition": "top-left" | "top-center" | "bottom-left" | "overlay",
178
+ "subheadPresent": <boolean>,
179
+ "subheadSize": "sm" | "md",
180
+ "usesEmoji": <boolean — are emojis used as accent elements>,
181
+ "textColor": "<primary text hex color>",
182
+ "accentTextColor": "<accent/highlight text hex color, null if none>",
183
+ "lineStyle": "tight" | "normal" | "loose",
184
+ "fontPersonality": "serif-editorial" | "bold-sans" | "friendly-rounded" | "minimal-light"
185
+ },
186
+
187
+ "spacing": {
188
+ "density": "dense" | "balanced" | "airy",
189
+ "phonePaddingPercent": <approximate padding around phone as % of canvas width, 5-25>,
190
+ "textToPhoneGap": "tight" | "medium" | "generous"
191
+ },
192
+
193
+ "colorExtraction": {
194
+ "dominantBgColor": "<main background hex>",
195
+ "phoneFrameColor": "<phone frame color hex>",
196
+ "headlineTextColor": "<main headline color hex>",
197
+ "accentColor": "<any accent color used, null if none>",
198
+ "overallMood": "warm" | "cool" | "neutral" | "dark" | "bright"
199
+ },
200
+
201
+ "layoutDescription": "<one sentence describing the key visual technique that makes this layout distinctive>"
202
+ }
203
+
204
+ Be precise about tiltAngles and phoneScales — these directly control the compositor output.
205
+ If multiple phones have different tilts/scales, list each one.
206
+ For single-phone layouts, tiltAngles and phoneScales should have exactly one value each.`;
207
+
208
+ // ─────────────────────────────────────────────────────────────────
209
+ // COLOR ADAPTATION
210
+ // ─────────────────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Replace extracted reference colors with the user's brand colors
214
+ * where the profile says adaptToBrand/adaptBorderToBrand = true.
215
+ */
216
+ function adaptColorsToBrand(profile, brand) {
217
+ const adapted = JSON.parse(JSON.stringify(profile)); // deep clone
218
+
219
+ // Background: if the reference used a solid brand-ish color, swap it
220
+ if (adapted.background.adaptToBrand) {
221
+ adapted.background.color = brand.primary;
222
+ if (adapted.background.gradientEndColor) {
223
+ adapted.background.gradientEndColor = darken(brand.primary, 0.15);
224
+ }
225
+ }
226
+
227
+ // Phone border: if colored border, use brand accent
228
+ if (adapted.phoneFrame.adaptBorderToBrand && adapted.phoneFrame.style === 'colored-border') {
229
+ adapted.phoneFrame.borderColor = brand.accent;
230
+ adapted.phoneFrame.shadowColor = brand.accent + '44'; // 27% opacity
231
+ }
232
+
233
+ // Accent text color: use brand accent
234
+ if (adapted.typography.accentTextColor) {
235
+ adapted.typography.accentTextColor = brand.accent;
236
+ }
237
+
238
+ return adapted;
239
+ }
240
+
241
+ // ─────────────────────────────────────────────────────────────────
242
+ // LAYOUT FUNCTION GENERATOR
243
+ // ─────────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * For multi-phone layouts, generate a JavaScript compositor function
247
+ * that sharp can execute. Writes to src/layouts/<layoutId>.js
248
+ * and returns the path.
249
+ *
250
+ * This is the "write custom code" step — Claude generates the
251
+ * actual compositing algorithm based on the extracted profile.
252
+ */
253
+ async function generateLayoutFunction(profile, brand) {
254
+ const layoutId = buildLayoutId(profile);
255
+ const cachePath = path.join(LAYOUTS_CACHE, `${layoutId}.js`);
256
+
257
+ // Return cached version if it exists
258
+ if (fs.existsSync(cachePath)) {
259
+ console.log(` ↳ Using cached layout: ${layoutId}`);
260
+ return cachePath;
261
+ }
262
+
263
+ console.log(' ↳ Generating custom layout function...');
264
+
265
+ const apiKey = process.env.ANTHROPIC_API_KEY;
266
+
267
+ const codePrompt = `Write a Node.js function that composites ${profile.layout.phoneCount} app screenshot(s)
268
+ into a single ${1290}×${2796}px App Store marketing image using the sharp image library.
269
+
270
+ Layout spec:
271
+ ${JSON.stringify(profile.layout, null, 2)}
272
+
273
+ Background spec:
274
+ ${JSON.stringify(profile.background, null, 2)}
275
+
276
+ Phone frame spec:
277
+ ${JSON.stringify(profile.phoneFrame, null, 2)}
278
+
279
+ Typography spec:
280
+ ${JSON.stringify(profile.typography, null, 2)}
281
+
282
+ The function signature must be:
283
+ async function composeLayout(screenshots, brand, texts, outputPath)
284
+
285
+ Where:
286
+ screenshots = array of { file: string } — paths to resized phone screen PNGs
287
+ brand = { primary, accent, appName }
288
+ texts = array of { white: string, accent: string } — headline text per screen
289
+ outputPath = string — where to write the output PNG
290
+
291
+ Rules:
292
+ - Use only sharp for image compositing (already imported at top)
293
+ - No external font files — use SVG text with system-ui font
294
+ - Phone frames are PNG overlays loaded from __dirname + '/../../templates/device_frame.png'
295
+ (fall back to a simple rounded rect SVG if file not found)
296
+ - Apply tilt using sharp's rotate() with transparent background fill
297
+ - Apply drop shadow by compositing a blurred/offset dark layer before the phone
298
+ - Background gradient or noise texture via SVG or sharp operations
299
+ - The headline text goes above or beside each phone per the typography spec
300
+ - Return the path to the written PNG
301
+
302
+ Write clean, well-commented code. Export the function as module.exports = { composeLayout }.`;
303
+
304
+ const response = await fetch(ANTHROPIC_API, {
305
+ method: 'POST',
306
+ headers: {
307
+ 'Content-Type': 'application/json',
308
+ 'x-api-key': apiKey,
309
+ 'anthropic-version': '2023-06-01',
310
+ },
311
+ body: JSON.stringify({
312
+ model: 'claude-opus-4-5',
313
+ max_tokens: 4000,
314
+ messages: [{
315
+ role: 'user',
316
+ content: codePrompt,
317
+ }],
318
+ }),
319
+ });
320
+
321
+ const data = await response.json();
322
+ const raw = data.content?.[0]?.text || '';
323
+
324
+ // Extract code block
325
+ const codeMatch = raw.match(/```(?:javascript|js)?\n([\s\S]*?)```/) || raw.match(/(const sharp[\s\S]*module\.exports[\s\S]*?\})/);
326
+ const code = codeMatch ? codeMatch[1] : raw;
327
+
328
+ // Write to layouts cache
329
+ fs.mkdirSync(LAYOUTS_CACHE, { recursive: true });
330
+
331
+ const fileContent = `/**
332
+ * Auto-generated layout: ${layoutId}
333
+ * Generated: ${new Date().toISOString()}
334
+ * Layout: ${profile.layoutDescription || profile.layout.arrangement}
335
+ *
336
+ * DO NOT EDIT — regenerate by deleting this file and re-running analyzeStyleReference()
337
+ */
338
+
339
+ const sharp = require('sharp');
340
+ const path = require('path');
341
+ const fs = require('fs');
342
+
343
+ ${code}
344
+ `;
345
+
346
+ fs.writeFileSync(cachePath, fileContent);
347
+ console.log(` ✓ Layout function saved: src/layouts/${layoutId}.js`);
348
+
349
+ return cachePath;
350
+ }
351
+
352
+ // ─────────────────────────────────────────────────────────────────
353
+ // DEFAULTS AND VALIDATION
354
+ // ─────────────────────────────────────────────────────────────────
355
+
356
+ function defaultStyleProfile(brand) {
357
+ return {
358
+ layout: {
359
+ type: "single",
360
+ phoneCount: 1,
361
+ arrangement: "single",
362
+ tiltAngles: [-8],
363
+ phoneScales: [1.0],
364
+ phonePositions:[{ anchor: "center", xOffset: 0, yOffset: 10 }],
365
+ textPosition: "top-center",
366
+ },
367
+ background: {
368
+ type: "solid",
369
+ color: brand.primary,
370
+ gradientEndColor: null,
371
+ gradientAngle: null,
372
+ noiseAmount: 0,
373
+ adaptToBrand: true,
374
+ },
375
+ phoneFrame: {
376
+ style: "colored-border",
377
+ borderColor: brand.accent,
378
+ borderWidth: 6,
379
+ shadowStyle: "soft",
380
+ shadowColor: null,
381
+ cornerRadius: 44,
382
+ adaptBorderToBrand: true,
383
+ },
384
+ typography: {
385
+ headlineStyle: "large-bold-sans",
386
+ headlineSize: "xl",
387
+ headlineCase: "title",
388
+ headlinePosition: "top-center",
389
+ subheadPresent: false,
390
+ subheadSize: "sm",
391
+ usesEmoji: false,
392
+ textColor: "#FFFFFF",
393
+ accentTextColor: brand.accent,
394
+ lineStyle: "tight",
395
+ fontPersonality: "bold-sans",
396
+ },
397
+ spacing: {
398
+ density: "balanced",
399
+ phonePaddingPercent: 8,
400
+ textToPhoneGap: "medium",
401
+ },
402
+ colorExtraction: {
403
+ dominantBgColor: brand.primary,
404
+ phoneFrameColor: "#FFFFFF",
405
+ headlineTextColor: "#FFFFFF",
406
+ accentColor: brand.accent,
407
+ overallMood: "neutral",
408
+ },
409
+ layoutDescription: "Single phone, slightly tilted, centered on brand background",
410
+ };
411
+ }
412
+
413
+ function mergeWithDefaults(extracted, brand) {
414
+ const defaults = defaultStyleProfile(brand);
415
+ return deepMerge(defaults, extracted);
416
+ }
417
+
418
+ function deepMerge(base, override) {
419
+ const result = { ...base };
420
+ for (const key of Object.keys(override || {})) {
421
+ if (override[key] !== null && typeof override[key] === 'object' && !Array.isArray(override[key])) {
422
+ result[key] = deepMerge(base[key] || {}, override[key]);
423
+ } else if (override[key] !== null && override[key] !== undefined) {
424
+ result[key] = override[key];
425
+ }
426
+ }
427
+ return result;
428
+ }
429
+
430
+ // ─────────────────────────────────────────────────────────────────
431
+ // UTILITIES
432
+ // ─────────────────────────────────────────────────────────────────
433
+
434
+ /**
435
+ * Build a stable layout ID from the profile for caching.
436
+ * e.g. "multi-phone_stacked-cascade_3_serif-editorial"
437
+ */
438
+ function buildLayoutId(profile) {
439
+ const parts = [
440
+ profile.layout.type,
441
+ profile.layout.arrangement,
442
+ profile.layout.phoneCount,
443
+ profile.typography.fontPersonality,
444
+ profile.phoneFrame.style,
445
+ ];
446
+ return parts.join('_').replace(/[^a-z0-9_-]/gi, '-').toLowerCase();
447
+ }
448
+
449
+ /**
450
+ * Darken a hex color by a percentage.
451
+ */
452
+ function darken(hex, amount) {
453
+ const { r, g, b } = hexToRgb(hex);
454
+ const d = 1 - amount;
455
+ return rgbToHex(Math.round(r * d), Math.round(g * d), Math.round(b * d));
456
+ }
457
+
458
+ function hexToRgb(hex) {
459
+ const h = hex.replace('#', '');
460
+ return {
461
+ r: parseInt(h.substring(0, 2), 16),
462
+ g: parseInt(h.substring(2, 4), 16),
463
+ b: parseInt(h.substring(4, 6), 16),
464
+ };
465
+ }
466
+
467
+ function rgbToHex(r, g, b) {
468
+ return '#' + [r, g, b].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
469
+ }
470
+
471
+ module.exports = { analyzeStyleReference };