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,682 @@
1
+ /**
2
+ * src/generators/compositePSD.js
3
+ * ------------------------------
4
+ * Template-based compositor.
5
+ *
6
+ * Reads a designer-made PSD template, finds layers tagged with
7
+ * [SWAP_*] naming conventions, replaces their content with the
8
+ * user's brand data + screenshots, and exports:
9
+ *
10
+ * 1. A flat PNG — App Store-ready
11
+ * 2. A layered PSD — editable, for Pro tier users
12
+ *
13
+ * Swap tags recognised:
14
+ * [SWAP_SCREENSHOT_N] — replace pixel data with app screenshot
15
+ * [SWAP_TEXT_headline] — replace text content (white headline)
16
+ * [SWAP_TEXT_headline_accent] — replace text content (accent headline)
17
+ * [SWAP_COLOR_background] — recolor solid to brand.background
18
+ * [SWAP_COLOR_primary] — recolor solid to brand.primary
19
+ * [SWAP_COLOR_secondary] — recolor solid to brand.secondary
20
+ * [SWAP_COLOR_accent] — recolor solid to brand.accent
21
+ *
22
+ * Falls back to programmatic compositing if no PSD template is found.
23
+ *
24
+ * Dependencies: sharp (image processing), ag-psd (PSD read/write)
25
+ */
26
+
27
+ const path = require('path');
28
+ const fs = require('fs');
29
+ const sharp = require('sharp');
30
+
31
+ // ag-psd for PSD read/write — requires canvas for pixel data
32
+ let agPsd;
33
+ let createCanvas;
34
+ try {
35
+ agPsd = require('ag-psd');
36
+ createCanvas = require('canvas').createCanvas;
37
+ agPsd.initializeCanvas(createCanvas);
38
+ } catch {
39
+ // ag-psd or canvas not available — fallback compositing still works
40
+ }
41
+
42
+ const TEMPLATES_DIR = path.join(__dirname, '../../templates/layouts');
43
+
44
+ // ─────────────────────────────────────────────────────────────────
45
+ // MAIN EXPORT
46
+ // ─────────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * compositePSD(opts)
50
+ *
51
+ * @param {Object} opts
52
+ * @param {string} opts.templateName — layout name (e.g. 'minimal'), or null for auto
53
+ * @param {string} opts.screenshotPath — path to raw screenshot PNG
54
+ * @param {string} opts.headlineWhite — white headline text
55
+ * @param {string} opts.headlineAccent — accent-colored headline text
56
+ * @param {Object} opts.brand — full brand object from detectBrand
57
+ * @param {Object} opts.font — { family, file, weight } from brand detection
58
+ * @param {string} opts.outputPng — path to write final flat PNG
59
+ * @param {string} opts.outputPsd — path to write layered PSD (if writePsd)
60
+ * @param {boolean} opts.writePsd — write PSD file (Pro tier)
61
+ */
62
+ async function compositePSD(opts) {
63
+ const {
64
+ templateName = null,
65
+ screenshotPath,
66
+ headlineWhite,
67
+ headlineAccent,
68
+ brand = {},
69
+ font = {},
70
+ outputPng,
71
+ outputPsd,
72
+ writePsd = false,
73
+ } = opts;
74
+
75
+ // Resolve which template PSD to use
76
+ const templatePath = resolveTemplate(templateName);
77
+
78
+ if (templatePath && agPsd) {
79
+ // ── Template mode: read PSD, swap layers, export ──────────
80
+ await compositeFromTemplate({
81
+ templatePath,
82
+ screenshotPath,
83
+ headlineWhite,
84
+ headlineAccent,
85
+ brand,
86
+ font,
87
+ outputPng,
88
+ outputPsd,
89
+ writePsd,
90
+ });
91
+ } else {
92
+ // ── Fallback mode: programmatic compositing ───────────────
93
+ await compositeFromScratch({
94
+ screenshotPath,
95
+ headlineWhite,
96
+ headlineAccent,
97
+ brand,
98
+ font,
99
+ outputPng,
100
+ outputPsd,
101
+ writePsd,
102
+ });
103
+ }
104
+ }
105
+
106
+ module.exports = { compositePSD };
107
+
108
+
109
+ // ─────────────────────────────────────────────────────────────────
110
+ // TEMPLATE-BASED COMPOSITING
111
+ // ─────────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Find the template PSD file. Checks:
115
+ * 1. templates/layouts/{name}/template.psd
116
+ * 2. templates/layouts/{name}.psd
117
+ * 3. First .psd found in templates/layouts/
118
+ */
119
+ function resolveTemplate(name) {
120
+ // Accept absolute paths directly
121
+ if (name && path.isAbsolute(name) && fs.existsSync(name)) return name;
122
+
123
+ if (!fs.existsSync(TEMPLATES_DIR)) return null;
124
+
125
+ if (name) {
126
+ const dirPath = path.join(TEMPLATES_DIR, name, 'template.psd');
127
+ const flatPath = path.join(TEMPLATES_DIR, name + '.psd');
128
+ if (fs.existsSync(dirPath)) return dirPath;
129
+ if (fs.existsSync(flatPath)) return flatPath;
130
+ }
131
+
132
+ // Auto: pick the first template available
133
+ try {
134
+ for (const entry of fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true })) {
135
+ if (entry.isDirectory()) {
136
+ const p = path.join(TEMPLATES_DIR, entry.name, 'template.psd');
137
+ if (fs.existsSync(p)) return p;
138
+ } else if (entry.name.endsWith('.psd')) {
139
+ return path.join(TEMPLATES_DIR, entry.name);
140
+ }
141
+ }
142
+ } catch {}
143
+
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Read a designer-made PSD, swap [SWAP_*] layers, export PNG + PSD.
149
+ */
150
+ async function compositeFromTemplate(opts) {
151
+ const {
152
+ templatePath,
153
+ screenshotPath,
154
+ headlineWhite,
155
+ headlineAccent,
156
+ brand,
157
+ font,
158
+ outputPng,
159
+ outputPsd,
160
+ writePsd,
161
+ } = opts;
162
+
163
+ // Read the template PSD with full layer image data
164
+ const buffer = fs.readFileSync(templatePath);
165
+ const psd = agPsd.readPsd(buffer, {
166
+ skipCompositeImageData: true,
167
+ skipLayerImageData: false,
168
+ });
169
+
170
+ // Build the color map from brand
171
+ const colorMap = {
172
+ background: brand.background || brand.primary || '#1B2A4A',
173
+ primary: brand.primary || '#1B2A4A',
174
+ secondary: brand.secondary || '#435B98',
175
+ accent: brand.accent || '#9B7FD4',
176
+ };
177
+
178
+ // Load and resize the screenshot to match the template's screenshot slot
179
+ const screenshotSlot = findLayer(psd.children, /\[SWAP_SCREENSHOT/);
180
+
181
+ let screenshotPixels = null;
182
+ if (screenshotSlot) {
183
+ const slotW = screenshotSlot.right - screenshotSlot.left;
184
+ const slotH = screenshotSlot.bottom - screenshotSlot.top;
185
+
186
+ screenshotPixels = await sharp(screenshotPath)
187
+ .resize(slotW, slotH, { fit: 'cover', position: 'top' })
188
+ .ensureAlpha()
189
+ .raw()
190
+ .toBuffer({ resolveWithObject: true });
191
+ }
192
+
193
+ // Walk all layers and perform swaps
194
+ swapLayers(psd.children, {
195
+ colorMap,
196
+ headlineWhite,
197
+ headlineAccent,
198
+ screenshotPixels,
199
+ font,
200
+ canvasW: psd.width,
201
+ canvasH: psd.height,
202
+ });
203
+
204
+ // ── Export flat PNG via sharp ──────────────────────────────────
205
+ // Flatten the PSD layers ourselves using sharp compositing.
206
+ // We walk the layers bottom-to-top and composite each one.
207
+ const pngBuffer = await flattenLayersToSharp(psd);
208
+ fs.writeFileSync(outputPng, pngBuffer);
209
+
210
+ // ── Export layered PSD ────────────────────────────────────────
211
+ const psdBuffer = agPsd.writePsd(psd);
212
+
213
+ // ── Export layered PSD (Pro tier) ─────────────────────────────
214
+ if (writePsd) {
215
+ fs.writeFileSync(outputPsd, Buffer.from(psdBuffer));
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Recursively find a layer whose name matches a regex.
221
+ */
222
+ function findLayer(children, regex) {
223
+ if (!children) return null;
224
+ for (const layer of children) {
225
+ if (regex.test(layer.name)) return layer;
226
+ if (layer.children) {
227
+ const found = findLayer(layer.children, regex);
228
+ if (found) return found;
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * Walk layer tree and swap content based on [SWAP_*] naming.
236
+ */
237
+ function swapLayers(children, ctx) {
238
+ if (!children) return;
239
+
240
+ for (const layer of children) {
241
+ const name = layer.name || '';
242
+
243
+ // ── Color swap: recolor solid layers ───────────────────────
244
+ const colorMatch = name.match(/\[SWAP_COLOR_(\w+)\]/);
245
+ if (colorMatch) {
246
+ const colorKey = colorMatch[1]; // background, primary, secondary, accent
247
+ const hex = ctx.colorMap[colorKey];
248
+ if (hex) {
249
+ recolorLayer(layer, hex);
250
+ }
251
+ }
252
+
253
+ // ── Screenshot swap: replace pixel data ────────────────────
254
+ if (name.match(/\[SWAP_SCREENSHOT/) && ctx.screenshotPixels) {
255
+ const ss = ctx.screenshotPixels;
256
+ const w = ss.info.width;
257
+ const h = ss.info.height;
258
+
259
+ // Create a real Canvas object that ag-psd can write
260
+ const canvas = createCanvas(w, h);
261
+ const imgCtx = canvas.getContext('2d');
262
+ const imgData = imgCtx.createImageData(w, h);
263
+ imgData.data.set(new Uint8ClampedArray(ss.data));
264
+ imgCtx.putImageData(imgData, 0, 0);
265
+
266
+ layer.canvas = canvas;
267
+ layer.right = layer.left + w;
268
+ layer.bottom = layer.top + h;
269
+ }
270
+
271
+ // ── Text swap: replace text content + re-render canvas ─────
272
+ const textMatch = name.match(/\[SWAP_TEXT_(\w+)\]/);
273
+ if (textMatch && layer.text) {
274
+ const tag = textMatch[1]; // 'headline' or 'headline_accent'
275
+ let newText = null;
276
+
277
+ if (tag === 'headline') newText = (ctx.headlineWhite || '').trim();
278
+ if (tag === 'headline_accent') newText = (ctx.headlineAccent || '').trim();
279
+
280
+ if (newText !== null) {
281
+ // For accent text layers, override the fill color with brand accent
282
+ const colorOverride = tag.includes('accent') ? ctx.colorMap.accent : null;
283
+ rerenderTextLayer(layer, newText, ctx.canvasW, ctx.canvasH, colorOverride);
284
+ }
285
+ }
286
+
287
+ // Recurse into groups
288
+ if (layer.children) {
289
+ swapLayers(layer.children, ctx);
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Re-render a text layer's canvas with new text.
296
+ * Reads font, size, color, and alignment from the PSD layer metadata,
297
+ * then draws the text using the canvas npm module.
298
+ */
299
+ function rerenderTextLayer(layer, newText, psdW, psdH, colorOverrideHex) {
300
+ const style = layer.text.style || {};
301
+ const paraStyle = layer.text.paragraphStyle || {};
302
+
303
+ // Extract font info from PSD metadata
304
+ const fontName = style.font?.name || 'Arial';
305
+ const fontSize = style.fontSize || 72;
306
+ const tracking = style.tracking || 0; // per-mille tracking
307
+
308
+ // Determine text color
309
+ let fillColor;
310
+ if (colorOverrideHex) {
311
+ // Brand color override (e.g. accent text uses brand.accent)
312
+ fillColor = hexToRgb(colorOverrideHex);
313
+ } else {
314
+ // Use PSD's own color — check styleRuns for the actual text color
315
+ fillColor = style.fillColor || { r: 255, g: 255, b: 255 };
316
+ if (layer.text.styleRuns && layer.text.styleRuns.length > 0) {
317
+ const lastRun = layer.text.styleRuns[layer.text.styleRuns.length - 1];
318
+ if (lastRun.style?.fillColor) {
319
+ fillColor = lastRun.style.fillColor;
320
+ }
321
+ }
322
+ }
323
+
324
+ const colorStr = `rgb(${Math.round(fillColor.r)}, ${Math.round(fillColor.g)}, ${Math.round(fillColor.b)})`;
325
+
326
+ // Map PSD font name to canvas-compatible font string
327
+ // ProximaNova-Bold → "bold ProximaNova" with fallback
328
+ const fontWeight = fontName.toLowerCase().includes('bold') ? 'bold' : 'normal';
329
+ const familyName = fontName.replace(/-Bold|-Regular|-Light|-Medium|-SemiBold|-ExtraBold/gi, '').replace(/-/g, ' ');
330
+ const fontStr = `${fontWeight} ${fontSize}px "${familyName}", "Helvetica Neue", Arial, sans-serif`;
331
+
332
+ // Measure text to determine canvas size
333
+ const measureCanvas = createCanvas(1, 1);
334
+ const measureCtx = measureCanvas.getContext('2d');
335
+ measureCtx.font = fontStr;
336
+ const metrics = measureCtx.measureText(newText);
337
+
338
+ // Apply tracking (PSD tracking is in 1/1000 em)
339
+ const trackingPx = (tracking / 1000) * fontSize;
340
+ const totalTracking = trackingPx * Math.max(0, newText.length - 1);
341
+ const textWidth = Math.ceil(metrics.width + totalTracking);
342
+
343
+ // Canvas dimensions: full PSD width so text can be centered
344
+ const canvasW = psdW;
345
+ const canvasH = Math.ceil(fontSize * 1.4); // enough for ascenders/descenders
346
+
347
+ const canvas = createCanvas(canvasW, canvasH);
348
+ const ctx = canvas.getContext('2d');
349
+ ctx.font = fontStr;
350
+ ctx.fillStyle = colorStr;
351
+ ctx.textBaseline = 'top';
352
+
353
+ // Alignment
354
+ const isCenter = (paraStyle.justification === 'center');
355
+ let startX;
356
+ if (isCenter) {
357
+ ctx.textAlign = 'center';
358
+ startX = canvasW / 2;
359
+ } else {
360
+ ctx.textAlign = 'left';
361
+ startX = 0;
362
+ }
363
+
364
+ // Draw with tracking (letter-spacing)
365
+ if (Math.abs(trackingPx) > 0.5) {
366
+ // Manual character-by-character drawing for tracking
367
+ ctx.textAlign = 'left'; // override for manual positioning
368
+ const chars = newText.split('');
369
+ let x;
370
+ if (isCenter) {
371
+ x = (canvasW - textWidth) / 2;
372
+ } else {
373
+ x = 0;
374
+ }
375
+ const yOffset = fontSize * 0.15; // small top padding
376
+ for (const ch of chars) {
377
+ ctx.fillText(ch, x, yOffset);
378
+ const charW = measureCtx.measureText(ch).width;
379
+ x += charW + trackingPx;
380
+ }
381
+ } else {
382
+ const yOffset = fontSize * 0.15;
383
+ ctx.fillText(newText, startX, yOffset);
384
+ }
385
+
386
+ // Update the layer
387
+ layer.text.text = newText;
388
+
389
+ // Update style runs to match new text length
390
+ if (layer.text.styleRuns) {
391
+ layer.text.styleRuns = [{
392
+ length: newText.length,
393
+ style: { fillColor },
394
+ }];
395
+ }
396
+
397
+ // Replace the rasterized canvas
398
+ layer.canvas = canvas;
399
+
400
+ // Update bounds — center horizontally at the original vertical position
401
+ const origCenterX = Math.round((layer.left + layer.right) / 2);
402
+ const origTop = layer.top;
403
+ layer.left = 0; // full width canvas
404
+ layer.right = canvasW;
405
+ layer.top = origTop;
406
+ layer.bottom = origTop + canvasH;
407
+ }
408
+
409
+ /**
410
+ * Recolor a solid/shape layer to a new hex color.
411
+ * Replaces every opaque pixel with the new color, preserving alpha.
412
+ * Works with real Canvas objects (from ag-psd + canvas npm module).
413
+ */
414
+ function recolorLayer(layer, hex) {
415
+ const { r, g, b } = hexToRgb(hex);
416
+
417
+ if (!layer.canvas) return;
418
+
419
+ const w = layer.canvas.width;
420
+ const h = layer.canvas.height;
421
+
422
+ // ag-psd returns real Canvas objects — use getContext to access pixels
423
+ if (typeof layer.canvas.getContext === 'function') {
424
+ const ctx = layer.canvas.getContext('2d');
425
+ const imgData = ctx.getImageData(0, 0, w, h);
426
+ const data = imgData.data;
427
+ for (let i = 0; i < data.length; i += 4) {
428
+ if (data[i + 3] > 0) {
429
+ data[i + 0] = r;
430
+ data[i + 1] = g;
431
+ data[i + 2] = b;
432
+ }
433
+ }
434
+ ctx.putImageData(imgData, 0, 0);
435
+ } else if (layer.canvas.data) {
436
+ // Fallback: raw data object
437
+ const data = layer.canvas.data;
438
+ for (let i = 0; i < data.length; i += 4) {
439
+ if (data[i + 3] > 0) {
440
+ data[i + 0] = r;
441
+ data[i + 1] = g;
442
+ data[i + 2] = b;
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+
449
+ /**
450
+ * Flatten PSD layers to a PNG buffer using sharp.
451
+ * Walks layers bottom-to-top, composites each visible layer at its position.
452
+ */
453
+ async function flattenLayersToSharp(psd) {
454
+ const w = psd.width;
455
+ const h = psd.height;
456
+
457
+ // Start with a transparent canvas
458
+ let base = sharp({
459
+ create: { width: w, height: h, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }
460
+ }).png().toBuffer();
461
+
462
+ base = await base;
463
+
464
+ // Collect all layers (flat list, bottom to top)
465
+ const layers = collectVisibleLayers(psd.children || []);
466
+
467
+ // Composite each layer
468
+ const compositeInputs = [];
469
+ for (const layer of layers) {
470
+ if (!layer.canvas) continue;
471
+
472
+ const lw = layer.canvas.width;
473
+ const lh = layer.canvas.height;
474
+ if (lw <= 0 || lh <= 0) continue;
475
+
476
+ // Extract raw RGBA pixels from the canvas
477
+ let rawPixels;
478
+ if (typeof layer.canvas.getContext === 'function') {
479
+ const ctx = layer.canvas.getContext('2d');
480
+ const imgData = ctx.getImageData(0, 0, lw, lh);
481
+ rawPixels = Buffer.from(imgData.data.buffer);
482
+ } else if (layer.canvas.data) {
483
+ rawPixels = Buffer.from(layer.canvas.data.buffer);
484
+ } else {
485
+ continue;
486
+ }
487
+
488
+ // Convert raw pixels to PNG buffer for sharp compositing
489
+ const layerPng = await sharp(rawPixels, {
490
+ raw: { width: lw, height: lh, channels: 4 }
491
+ }).png().toBuffer();
492
+
493
+ compositeInputs.push({
494
+ input: layerPng,
495
+ top: Math.max(0, layer.top || 0),
496
+ left: Math.max(0, layer.left || 0),
497
+ });
498
+ }
499
+
500
+ if (compositeInputs.length === 0) {
501
+ return base;
502
+ }
503
+
504
+ return sharp(base).composite(compositeInputs).png().toBuffer();
505
+ }
506
+
507
+ /**
508
+ * Collect visible layers in bottom-to-top order (PSD layer order).
509
+ */
510
+ function collectVisibleLayers(children) {
511
+ const result = [];
512
+ for (const layer of children) {
513
+ if (layer.hidden) continue;
514
+ if (layer.children) {
515
+ result.push(...collectVisibleLayers(layer.children));
516
+ } else {
517
+ result.push(layer);
518
+ }
519
+ }
520
+ return result;
521
+ }
522
+
523
+
524
+ // ─────────────────────────────────────────────────────────────────
525
+ // FALLBACK: PROGRAMMATIC COMPOSITING (no template PSD)
526
+ // ─────────────────────────────────────────────────────────────────
527
+
528
+ // Fallback constants
529
+ const CANVAS_W = 1290;
530
+ const CANVAS_H = 2796;
531
+ const TEXT_TOP = 120;
532
+ const HEADLINE_SIZE = 88;
533
+ const LINE_GAP = 12;
534
+ const SECTION_GAP = 24;
535
+ const CHARS_PER_LINE = 18;
536
+ const PHONE_TOP = 820;
537
+ const PHONE_LEFT = 80;
538
+ const PHONE_W = 1130;
539
+ const PHONE_H = 1900;
540
+ const SCREEN_OFFSET_X = 50;
541
+ const SCREEN_OFFSET_Y = 80;
542
+ const SCREEN_W = 1030;
543
+ const SCREEN_H = 1740;
544
+
545
+ async function compositeFromScratch(opts) {
546
+ const {
547
+ screenshotPath,
548
+ headlineWhite,
549
+ headlineAccent,
550
+ brand = {},
551
+ font = {},
552
+ outputPng,
553
+ } = opts;
554
+
555
+ const bgColor = brand.primary || '#1B2A4A';
556
+ const accentColor = brand.accent || '#9B7FD4';
557
+
558
+ // 1. Background
559
+ const { r, g, b } = hexToRgb(bgColor);
560
+ const background = await sharp({
561
+ create: { width: CANVAS_W, height: CANVAS_H, channels: 3, background: { r, g, b } }
562
+ }).png().toBuffer();
563
+
564
+ // 2. Resize screenshot
565
+ const screenshotLayer = await sharp(screenshotPath)
566
+ .resize(SCREEN_W, SCREEN_H, { fit: 'cover', position: 'top' })
567
+ .png()
568
+ .toBuffer();
569
+
570
+ const screenLeft = PHONE_LEFT + SCREEN_OFFSET_X;
571
+ const screenTop = PHONE_TOP + SCREEN_OFFSET_Y;
572
+
573
+ let composite = [
574
+ { input: screenshotLayer, top: screenTop, left: screenLeft },
575
+ ];
576
+
577
+ // 3. Phone frame placeholder
578
+ const placeholderFrame = await buildPlaceholderFrame(accentColor);
579
+ composite.push({ input: placeholderFrame, top: PHONE_TOP, left: PHONE_LEFT });
580
+
581
+ // 4. Headline text
582
+ const textSvg = buildHeadlineSvg({
583
+ white: headlineWhite,
584
+ accent: headlineAccent,
585
+ accentColor,
586
+ canvasW: CANVAS_W,
587
+ canvasH: CANVAS_H,
588
+ textTop: TEXT_TOP,
589
+ fontSize: HEADLINE_SIZE,
590
+ brandFont: font,
591
+ });
592
+ composite.push({ input: Buffer.from(textSvg), top: 0, left: 0 });
593
+
594
+ // 5. Write PNG
595
+ await sharp(background).composite(composite).png().toFile(outputPng);
596
+ }
597
+
598
+ async function buildPlaceholderFrame(borderColor) {
599
+ const { r, g, b } = hexToRgb(borderColor);
600
+ const rx = 60;
601
+ const svg = `
602
+ <svg width="${PHONE_W}" height="${PHONE_H}" xmlns="http://www.w3.org/2000/svg">
603
+ <rect x="4" y="4" width="${PHONE_W - 8}" height="${PHONE_H - 8}"
604
+ rx="${rx}" ry="${rx}" fill="none"
605
+ stroke="rgb(${r},${g},${b})" stroke-width="8" opacity="0.8" />
606
+ <rect x="${PHONE_W / 2 - 60}" y="20" width="120" height="34"
607
+ rx="17" ry="17" fill="rgb(${r},${g},${b})" opacity="0.6" />
608
+ </svg>`;
609
+ return Buffer.from(svg);
610
+ }
611
+
612
+ function buildHeadlineSvg({ white, accent, accentColor, canvasW, canvasH, textTop, fontSize, brandFont }) {
613
+ const cx = canvasW / 2;
614
+ const fontFamily = brandFont?.family
615
+ ? `'${brandFont.family}', system-ui, -apple-system, sans-serif`
616
+ : 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
617
+ const fontWeight = '800';
618
+ const whiteLines = wrapText(white, CHARS_PER_LINE);
619
+ const accentLines = wrapText(accent, CHARS_PER_LINE);
620
+ const lineH = fontSize + LINE_GAP;
621
+
622
+ let y = textTop + fontSize;
623
+ const whiteTspans = whiteLines.map((line, i) => {
624
+ const ly = y + i * lineH;
625
+ return ` <tspan x="${cx}" y="${ly}">${escXml(line)}</tspan>`;
626
+ }).join('\n');
627
+
628
+ const accentStartY = y + (whiteLines.length - 1) * lineH + SECTION_GAP + lineH;
629
+ const accentTspans = accentLines.map((line, i) => {
630
+ const ly = accentStartY + i * lineH;
631
+ return ` <tspan x="${cx}" y="${ly}">${escXml(line)}</tspan>`;
632
+ }).join('\n');
633
+
634
+ return `
635
+ <svg width="${canvasW}" height="${canvasH}" xmlns="http://www.w3.org/2000/svg">
636
+ <text text-anchor="middle" font-family="${fontFamily}"
637
+ font-weight="${fontWeight}" font-size="${fontSize}" fill="#FFFFFF">
638
+ ${whiteTspans}
639
+ </text>
640
+ <text text-anchor="middle" font-family="${fontFamily}"
641
+ font-weight="${fontWeight}" font-size="${fontSize}" fill="${accentColor}">
642
+ ${accentTspans}
643
+ </text>
644
+ </svg>`;
645
+ }
646
+
647
+ function wrapText(text, maxChars) {
648
+ if (!text || text.length <= maxChars) return [text || ''];
649
+ const words = text.split(/\s+/);
650
+ const lines = [];
651
+ let current = '';
652
+ for (const word of words) {
653
+ if (current.length === 0) {
654
+ current = word;
655
+ } else if ((current + ' ' + word).length <= maxChars) {
656
+ current += ' ' + word;
657
+ } else {
658
+ lines.push(current);
659
+ current = word;
660
+ }
661
+ }
662
+ if (current.length > 0) lines.push(current);
663
+ return lines.length > 0 ? lines : [''];
664
+ }
665
+
666
+
667
+ // ─────────────────────────────────────────────────────────────────
668
+ // SHARED HELPERS
669
+ // ─────────────────────────────────────────────────────────────────
670
+
671
+ function hexToRgb(hex) {
672
+ const h = (hex || '#000000').replace('#', '');
673
+ return {
674
+ r: parseInt(h.substring(0, 2), 16) || 0,
675
+ g: parseInt(h.substring(2, 4), 16) || 0,
676
+ b: parseInt(h.substring(4, 6), 16) || 0,
677
+ };
678
+ }
679
+
680
+ function escXml(str) {
681
+ return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
682
+ }