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.
- package/.claude/settings.local.json +30 -0
- package/.env.example +3 -0
- package/MCP_README.md +200 -0
- package/README.md +148 -0
- package/bin/screencraft.js +61 -0
- package/package.json +31 -0
- package/src/auth/keystore.js +148 -0
- package/src/commands/init.js +119 -0
- package/src/commands/launch.js +405 -0
- package/src/detectors/detectBrand.js +1222 -0
- package/src/detectors/simulator.js +317 -0
- package/src/generators/analyzeStyleReference.js +471 -0
- package/src/generators/compositePSD.js +682 -0
- package/src/generators/copy.js +147 -0
- package/src/mcp/index.js +394 -0
- package/src/pipeline/aeSwap.js +369 -0
- package/src/pipeline/download.js +32 -0
- package/src/pipeline/queue.js +101 -0
- package/src/server/index.js +627 -0
- package/src/server/public/app.js +738 -0
- package/src/server/public/index.html +255 -0
- package/src/server/public/style.css +751 -0
- package/src/server/session.js +36 -0
- package/templates/ae/(Footage)/Assets/This Hip-Hop Upbeat (Short version).wav +0 -0
- package/templates/ae/(Footage)/Assets/screen_01_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_02_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_03_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_04_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_05_raw.png +0 -0
- package/templates/ae/(Footage)/Assets/screen_06_raw.png +0 -0
- package/templates/ae/Motion Forge Test 1.0 (converted).aep +0 -0
- package/templates/ae_swap.jsx +284 -0
- package/templates/layouts/minimal.psd +0 -0
- package/templates/screencraft.config.example.js +165 -0
- package/test/output/layout_test.png +0 -0
- package/test/output/style_profile.json +64 -0
- package/test/reference.png +0 -0
- package/test/test_brand.js +69 -0
- package/test/test_psd.js +83 -0
- 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 };
|