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,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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
682
|
+
}
|