@reshotdev/screenshot 0.0.1-beta.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/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
// style-engine.js - Image beautification via Sharp compositing
|
|
2
|
+
// Transforms raw screenshots into polished, marketing-ready assets with
|
|
3
|
+
// window frames, shadows, rounded corners, and custom backgrounds.
|
|
4
|
+
// PNG only (video styling is V2).
|
|
5
|
+
|
|
6
|
+
const chalk = require("chalk");
|
|
7
|
+
|
|
8
|
+
let sharp;
|
|
9
|
+
try {
|
|
10
|
+
sharp = require("sharp");
|
|
11
|
+
} catch (_e) {
|
|
12
|
+
sharp = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default style configuration
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_STYLE_CONFIG = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
frame: "none",
|
|
21
|
+
shadow: "medium",
|
|
22
|
+
padding: 40,
|
|
23
|
+
background: "transparent",
|
|
24
|
+
borderRadius: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shadow presets: { offsetX, offsetY, blur, spread, opacity }
|
|
29
|
+
*/
|
|
30
|
+
const SHADOW_PRESETS = {
|
|
31
|
+
none: null,
|
|
32
|
+
small: { blur: 10, spread: 2, offsetY: 4, opacity: 0.15 },
|
|
33
|
+
medium: { blur: 20, spread: 4, offsetY: 8, opacity: 0.2 },
|
|
34
|
+
large: { blur: 40, spread: 8, offsetY: 16, opacity: 0.25 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Frame title bar heights (in CSS pixels, will be scaled)
|
|
39
|
+
*/
|
|
40
|
+
const FRAME_HEIGHT = 36;
|
|
41
|
+
const TRAFFIC_LIGHT_RADIUS = 6;
|
|
42
|
+
const TRAFFIC_LIGHT_GAP = 8;
|
|
43
|
+
const TRAFFIC_LIGHT_LEFT = 14;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate a style configuration object.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} config
|
|
49
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
50
|
+
*/
|
|
51
|
+
function validateStyleConfig(config) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
|
|
54
|
+
if (!config || typeof config !== "object") {
|
|
55
|
+
return { valid: false, errors: ["Style config must be an object"] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (config.frame && !["none", "macos", "windows"].includes(config.frame)) {
|
|
59
|
+
errors.push(
|
|
60
|
+
`Invalid frame "${config.frame}". Valid: none, macos, windows`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (config.shadow && !SHADOW_PRESETS.hasOwnProperty(config.shadow)) {
|
|
65
|
+
errors.push(
|
|
66
|
+
`Invalid shadow "${config.shadow}". Valid: ${Object.keys(SHADOW_PRESETS).join(", ")}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (config.padding !== undefined) {
|
|
71
|
+
if (
|
|
72
|
+
typeof config.padding !== "number" ||
|
|
73
|
+
config.padding < 0 ||
|
|
74
|
+
config.padding > 200
|
|
75
|
+
) {
|
|
76
|
+
errors.push("padding must be a number between 0 and 200");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (config.borderRadius !== undefined) {
|
|
81
|
+
if (
|
|
82
|
+
typeof config.borderRadius !== "number" ||
|
|
83
|
+
config.borderRadius < 0 ||
|
|
84
|
+
config.borderRadius > 100
|
|
85
|
+
) {
|
|
86
|
+
errors.push("borderRadius must be a number between 0 and 100");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (config.background !== undefined) {
|
|
91
|
+
if (typeof config.background !== "string") {
|
|
92
|
+
errors.push("background must be a string");
|
|
93
|
+
} else if (config.background !== "transparent") {
|
|
94
|
+
// Validate hex or linear-gradient
|
|
95
|
+
const isHex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(
|
|
96
|
+
config.background
|
|
97
|
+
);
|
|
98
|
+
const isGradient = config.background.startsWith("linear-gradient(");
|
|
99
|
+
if (!isHex && !isGradient) {
|
|
100
|
+
errors.push(
|
|
101
|
+
'background must be "transparent", a hex color, or a linear-gradient()'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { valid: errors.length === 0, errors };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Merge global style config with scenario overrides.
|
|
112
|
+
* Style uses flat override (not additive like privacy).
|
|
113
|
+
*
|
|
114
|
+
* @param {Object} globalConfig
|
|
115
|
+
* @param {Object} [overrides]
|
|
116
|
+
* @returns {Object}
|
|
117
|
+
*/
|
|
118
|
+
function mergeStyleConfig(globalConfig, overrides) {
|
|
119
|
+
if (!overrides) return { ...globalConfig };
|
|
120
|
+
if (!globalConfig) return { ...DEFAULT_STYLE_CONFIG, ...overrides };
|
|
121
|
+
|
|
122
|
+
// Filter undefined values
|
|
123
|
+
const cleanOverrides = Object.fromEntries(
|
|
124
|
+
Object.entries(overrides).filter(([, v]) => v !== undefined)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return { ...globalConfig, ...cleanOverrides };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Maximum canvas dimension — Sharp/libvips can't handle larger without running out of memory.
|
|
132
|
+
*/
|
|
133
|
+
const MAX_CANVAS_DIM = 16384;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Minimum width for frame rendering — below this, title bar elements won't fit.
|
|
137
|
+
*/
|
|
138
|
+
const MIN_FRAME_WIDTH = 100;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate a macOS-style title bar SVG.
|
|
142
|
+
*
|
|
143
|
+
* @param {number} width - Width in pixels (already DPI-scaled)
|
|
144
|
+
* @param {boolean} [darkMode=false]
|
|
145
|
+
* @param {number} [scale=1] - DPI scale factor for element sizing
|
|
146
|
+
* @returns {Buffer} SVG as Buffer
|
|
147
|
+
*/
|
|
148
|
+
function generateMacOSTitleBar(width, darkMode = false, scale = 1) {
|
|
149
|
+
const bgColor = darkMode ? "#2d2d2d" : "#e8e8e8";
|
|
150
|
+
const borderColor = darkMode ? "#3d3d3d" : "#d0d0d0";
|
|
151
|
+
const h = FRAME_HEIGHT * scale;
|
|
152
|
+
const r = TRAFFIC_LIGHT_RADIUS * scale;
|
|
153
|
+
const gap = TRAFFIC_LIGHT_GAP * scale;
|
|
154
|
+
const left = TRAFFIC_LIGHT_LEFT * scale;
|
|
155
|
+
const cy = h / 2;
|
|
156
|
+
const startX = left + r;
|
|
157
|
+
const cornerR = 8 * scale;
|
|
158
|
+
|
|
159
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
|
|
160
|
+
<rect width="${width}" height="${h}" fill="${bgColor}" rx="${cornerR}" ry="${cornerR}"/>
|
|
161
|
+
<rect x="0" y="${cornerR}" width="${width}" height="${h - cornerR}" fill="${bgColor}"/>
|
|
162
|
+
<line x1="0" y1="${h - 0.5}" x2="${width}" y2="${h - 0.5}" stroke="${borderColor}" stroke-width="1"/>
|
|
163
|
+
<circle cx="${startX}" cy="${cy}" r="${r}" fill="#ff5f57"/>
|
|
164
|
+
<circle cx="${startX + r * 2 + gap}" cy="${cy}" r="${r}" fill="#febc2e"/>
|
|
165
|
+
<circle cx="${startX + (r * 2 + gap) * 2}" cy="${cy}" r="${r}" fill="#28c840"/>
|
|
166
|
+
</svg>`;
|
|
167
|
+
|
|
168
|
+
return Buffer.from(svg);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Generate a Windows-style title bar SVG.
|
|
173
|
+
*
|
|
174
|
+
* @param {number} width - Width in pixels (already DPI-scaled)
|
|
175
|
+
* @param {boolean} [darkMode=false]
|
|
176
|
+
* @param {number} [scale=1] - DPI scale factor for element sizing
|
|
177
|
+
* @returns {Buffer} SVG as Buffer
|
|
178
|
+
*/
|
|
179
|
+
function generateWindowsTitleBar(width, darkMode = false, scale = 1) {
|
|
180
|
+
const bgColor = darkMode ? "#2d2d2d" : "#f0f0f0";
|
|
181
|
+
const btnColor = darkMode ? "#aaa" : "#666";
|
|
182
|
+
const closeBtnColor = darkMode ? "#aaa" : "#666";
|
|
183
|
+
const btnWidth = 46 * scale;
|
|
184
|
+
const h = FRAME_HEIGHT * scale;
|
|
185
|
+
const rightEdge = width;
|
|
186
|
+
|
|
187
|
+
// Icon geometry scaled
|
|
188
|
+
const minX1 = 18 * scale;
|
|
189
|
+
const minX2 = 28 * scale;
|
|
190
|
+
const maxX = 17 * scale;
|
|
191
|
+
const maxW = 12 * scale;
|
|
192
|
+
const maxH = 10 * scale;
|
|
193
|
+
const closeX1 = 19 * scale;
|
|
194
|
+
const closeX2 = 27 * scale;
|
|
195
|
+
const closeOff = 4 * scale;
|
|
196
|
+
|
|
197
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
|
|
198
|
+
<rect width="${width}" height="${h}" fill="${bgColor}"/>
|
|
199
|
+
<!-- Minimize -->
|
|
200
|
+
<g transform="translate(${rightEdge - btnWidth * 3}, 0)">
|
|
201
|
+
<rect width="${btnWidth}" height="${h}" fill="transparent"/>
|
|
202
|
+
<line x1="${minX1}" y1="${h / 2}" x2="${minX2}" y2="${h / 2}" stroke="${btnColor}" stroke-width="1"/>
|
|
203
|
+
</g>
|
|
204
|
+
<!-- Maximize -->
|
|
205
|
+
<g transform="translate(${rightEdge - btnWidth * 2}, 0)">
|
|
206
|
+
<rect width="${btnWidth}" height="${h}" fill="transparent"/>
|
|
207
|
+
<rect x="${maxX}" y="${h / 2 - maxH / 2}" width="${maxW}" height="${maxH}" fill="none" stroke="${btnColor}" stroke-width="1"/>
|
|
208
|
+
</g>
|
|
209
|
+
<!-- Close -->
|
|
210
|
+
<g transform="translate(${rightEdge - btnWidth}, 0)">
|
|
211
|
+
<rect width="${btnWidth}" height="${h}" fill="transparent"/>
|
|
212
|
+
<line x1="${closeX1}" y1="${h / 2 - closeOff}" x2="${closeX2}" y2="${h / 2 + closeOff}" stroke="${closeBtnColor}" stroke-width="1"/>
|
|
213
|
+
<line x1="${closeX2}" y1="${h / 2 - closeOff}" x2="${closeX1}" y2="${h / 2 + closeOff}" stroke="${closeBtnColor}" stroke-width="1"/>
|
|
214
|
+
</g>
|
|
215
|
+
</svg>`;
|
|
216
|
+
|
|
217
|
+
return Buffer.from(svg);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Generate a shadow layer as an SVG, then blur it with Sharp.
|
|
222
|
+
*
|
|
223
|
+
* @param {number} contentWidth - Width of the content (including frame)
|
|
224
|
+
* @param {number} contentHeight - Height of the content (including frame)
|
|
225
|
+
* @param {Object} shadowConfig - Shadow preset config
|
|
226
|
+
* @param {number} borderRadius
|
|
227
|
+
* @returns {Promise<Buffer>} Shadow layer as PNG buffer
|
|
228
|
+
*/
|
|
229
|
+
async function generateShadowLayer(
|
|
230
|
+
contentWidth,
|
|
231
|
+
contentHeight,
|
|
232
|
+
shadowConfig,
|
|
233
|
+
borderRadius
|
|
234
|
+
) {
|
|
235
|
+
if (!sharp || !shadowConfig) return null;
|
|
236
|
+
|
|
237
|
+
const { blur, spread, offsetY, opacity } = shadowConfig;
|
|
238
|
+
// Extra padding around shadow for blur to spread into
|
|
239
|
+
const extra = blur * 2 + spread;
|
|
240
|
+
const totalWidth = contentWidth + extra * 2;
|
|
241
|
+
const totalHeight = contentHeight + extra * 2 + offsetY;
|
|
242
|
+
|
|
243
|
+
const rx = Math.min(borderRadius, contentWidth / 2, contentHeight / 2);
|
|
244
|
+
const alphaHex = Math.round(opacity * 255)
|
|
245
|
+
.toString(16)
|
|
246
|
+
.padStart(2, "0");
|
|
247
|
+
|
|
248
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
249
|
+
<rect x="${extra}" y="${extra + offsetY}" width="${contentWidth}" height="${contentHeight}" rx="${rx}" ry="${rx}" fill="#000000${alphaHex}"/>
|
|
250
|
+
</svg>`;
|
|
251
|
+
|
|
252
|
+
const shadowBuffer = await sharp(Buffer.from(svg))
|
|
253
|
+
.blur(blur)
|
|
254
|
+
.png()
|
|
255
|
+
.toBuffer();
|
|
256
|
+
|
|
257
|
+
return { buffer: shadowBuffer, extra, offsetY };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Parse a background value into a Sharp-compatible format.
|
|
262
|
+
* Supports: "transparent", hex colors, linear-gradient()
|
|
263
|
+
*
|
|
264
|
+
* @param {string} background
|
|
265
|
+
* @param {number} width
|
|
266
|
+
* @param {number} height
|
|
267
|
+
* @returns {Promise<{ channels: number, background: Object }|Buffer>}
|
|
268
|
+
*/
|
|
269
|
+
async function resolveBackground(background, width, height) {
|
|
270
|
+
if (!background || background === "transparent") {
|
|
271
|
+
return { r: 0, g: 0, b: 0, alpha: 0 };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Hex color
|
|
275
|
+
const hexMatch = background.match(
|
|
276
|
+
/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i
|
|
277
|
+
);
|
|
278
|
+
if (hexMatch) {
|
|
279
|
+
return {
|
|
280
|
+
r: parseInt(hexMatch[1], 16),
|
|
281
|
+
g: parseInt(hexMatch[2], 16),
|
|
282
|
+
b: parseInt(hexMatch[3], 16),
|
|
283
|
+
alpha: hexMatch[4] ? parseInt(hexMatch[4], 16) / 255 : 1,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Short hex
|
|
288
|
+
const shortHexMatch = background.match(
|
|
289
|
+
/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i
|
|
290
|
+
);
|
|
291
|
+
if (shortHexMatch) {
|
|
292
|
+
return {
|
|
293
|
+
r: parseInt(shortHexMatch[1] + shortHexMatch[1], 16),
|
|
294
|
+
g: parseInt(shortHexMatch[2] + shortHexMatch[2], 16),
|
|
295
|
+
b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16),
|
|
296
|
+
alpha: 1,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Linear gradient - render as SVG
|
|
301
|
+
const gradientMatch = background.match(
|
|
302
|
+
/^linear-gradient\((.+)\)$/i
|
|
303
|
+
);
|
|
304
|
+
if (gradientMatch && sharp) {
|
|
305
|
+
try {
|
|
306
|
+
return await renderGradientBackground(gradientMatch[1], width, height);
|
|
307
|
+
} catch (_e) {
|
|
308
|
+
// Fall back to transparent
|
|
309
|
+
return { r: 0, g: 0, b: 0, alpha: 0 };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Fallback
|
|
314
|
+
return { r: 0, g: 0, b: 0, alpha: 0 };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Split a gradient body into parts, respecting parentheses depth.
|
|
319
|
+
* e.g. "135deg, rgba(255,0,0,0.5) 30%, #764ba2" → ["135deg", "rgba(255,0,0,0.5) 30%", "#764ba2"]
|
|
320
|
+
*
|
|
321
|
+
* @param {string} body
|
|
322
|
+
* @returns {string[]}
|
|
323
|
+
*/
|
|
324
|
+
function splitGradientParts(body) {
|
|
325
|
+
const parts = [];
|
|
326
|
+
let depth = 0;
|
|
327
|
+
let current = "";
|
|
328
|
+
|
|
329
|
+
for (let i = 0; i < body.length; i++) {
|
|
330
|
+
const ch = body[i];
|
|
331
|
+
if (ch === "(") depth++;
|
|
332
|
+
else if (ch === ")") depth--;
|
|
333
|
+
|
|
334
|
+
if (ch === "," && depth === 0) {
|
|
335
|
+
parts.push(current.trim());
|
|
336
|
+
current = "";
|
|
337
|
+
} else {
|
|
338
|
+
current += ch;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (current.trim()) parts.push(current.trim());
|
|
342
|
+
return parts;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Direction keyword → angle mapping (including diagonals).
|
|
347
|
+
*/
|
|
348
|
+
const DIRECTION_ANGLES = {
|
|
349
|
+
"to right": 90,
|
|
350
|
+
"to left": 270,
|
|
351
|
+
"to bottom": 180,
|
|
352
|
+
"to top": 0,
|
|
353
|
+
"to top right": 45,
|
|
354
|
+
"to right top": 45,
|
|
355
|
+
"to bottom right": 135,
|
|
356
|
+
"to right bottom": 135,
|
|
357
|
+
"to bottom left": 225,
|
|
358
|
+
"to left bottom": 225,
|
|
359
|
+
"to top left": 315,
|
|
360
|
+
"to left top": 315,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Render a CSS linear-gradient as a PNG buffer via SVG.
|
|
365
|
+
*
|
|
366
|
+
* @param {string} gradientBody - The content inside linear-gradient(...)
|
|
367
|
+
* @param {number} width
|
|
368
|
+
* @param {number} height
|
|
369
|
+
* @returns {Promise<Buffer>} PNG buffer
|
|
370
|
+
*/
|
|
371
|
+
async function renderGradientBackground(gradientBody, width, height) {
|
|
372
|
+
const parts = splitGradientParts(gradientBody);
|
|
373
|
+
let angle = 180; // default: top to bottom
|
|
374
|
+
let colorStops = parts;
|
|
375
|
+
|
|
376
|
+
// Check if first part is an angle or direction keyword
|
|
377
|
+
const angleMatch = parts[0].match(/^(\d+)deg$/);
|
|
378
|
+
if (angleMatch) {
|
|
379
|
+
angle = parseInt(angleMatch[1], 10);
|
|
380
|
+
colorStops = parts.slice(1);
|
|
381
|
+
} else if (DIRECTION_ANGLES.hasOwnProperty(parts[0])) {
|
|
382
|
+
angle = DIRECTION_ANGLES[parts[0]];
|
|
383
|
+
colorStops = parts.slice(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Convert angle to SVG gradient coordinates
|
|
387
|
+
const rad = ((angle - 90) * Math.PI) / 180;
|
|
388
|
+
const x1 = Math.round(50 - Math.cos(rad) * 50);
|
|
389
|
+
const y1 = Math.round(50 - Math.sin(rad) * 50);
|
|
390
|
+
const x2 = Math.round(50 + Math.cos(rad) * 50);
|
|
391
|
+
const y2 = Math.round(50 + Math.sin(rad) * 50);
|
|
392
|
+
|
|
393
|
+
// Build SVG stops — handle explicit percentage offsets and rgba() colors
|
|
394
|
+
const stops = colorStops
|
|
395
|
+
.map((stop, i) => {
|
|
396
|
+
// Extract trailing percentage: "rgba(255,0,0,0.5) 30%" → color="rgba(255,0,0,0.5)", pct="30%"
|
|
397
|
+
const pctMatch = stop.match(/^(.+?)\s+(\d+(?:\.\d+)?%)\s*$/);
|
|
398
|
+
let color, offset;
|
|
399
|
+
if (pctMatch) {
|
|
400
|
+
color = pctMatch[1].trim();
|
|
401
|
+
offset = pctMatch[2];
|
|
402
|
+
} else {
|
|
403
|
+
color = stop.trim();
|
|
404
|
+
offset = colorStops.length > 1
|
|
405
|
+
? `${Math.round((i / (colorStops.length - 1)) * 100)}%`
|
|
406
|
+
: "0%";
|
|
407
|
+
}
|
|
408
|
+
return `<stop offset="${offset}" stop-color="${color}"/>`;
|
|
409
|
+
})
|
|
410
|
+
.join("\n ");
|
|
411
|
+
|
|
412
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
413
|
+
<defs>
|
|
414
|
+
<linearGradient id="bg" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
|
|
415
|
+
${stops}
|
|
416
|
+
</linearGradient>
|
|
417
|
+
</defs>
|
|
418
|
+
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
|
419
|
+
</svg>`;
|
|
420
|
+
|
|
421
|
+
return sharp(Buffer.from(svg)).png().toBuffer();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Apply rounded corners to an image buffer using an SVG mask.
|
|
426
|
+
*
|
|
427
|
+
* @param {Buffer} buffer - Input PNG buffer
|
|
428
|
+
* @param {number} borderRadius
|
|
429
|
+
* @param {number} width
|
|
430
|
+
* @param {number} height
|
|
431
|
+
* @returns {Promise<Buffer>}
|
|
432
|
+
*/
|
|
433
|
+
async function roundCorners(buffer, borderRadius, width, height) {
|
|
434
|
+
if (!borderRadius || borderRadius <= 0 || !sharp) return buffer;
|
|
435
|
+
|
|
436
|
+
const r = Math.min(borderRadius, width / 2, height / 2);
|
|
437
|
+
|
|
438
|
+
const mask = Buffer.from(
|
|
439
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
440
|
+
<rect width="${width}" height="${height}" rx="${r}" ry="${r}" fill="white"/>
|
|
441
|
+
</svg>`
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return sharp(buffer)
|
|
445
|
+
.composite([{ input: mask, blend: "dest-in" }])
|
|
446
|
+
.png()
|
|
447
|
+
.toBuffer();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Apply the full style pipeline to a screenshot buffer.
|
|
452
|
+
*
|
|
453
|
+
* Pipeline order:
|
|
454
|
+
* 1. Round corners on the screenshot
|
|
455
|
+
* 2. Add window frame (title bar composited on top)
|
|
456
|
+
* 3. Generate shadow layer
|
|
457
|
+
* 4. Compose everything onto padded background canvas
|
|
458
|
+
*
|
|
459
|
+
* @param {Buffer} inputBuffer - Raw PNG screenshot
|
|
460
|
+
* @param {Object} styleConfig - Style configuration
|
|
461
|
+
* @param {Function} [logger] - Logging function
|
|
462
|
+
* @returns {Promise<Buffer>} Beautified PNG buffer
|
|
463
|
+
*/
|
|
464
|
+
async function applyStyle(inputBuffer, styleConfig, logger, dpr = 1) {
|
|
465
|
+
if (!sharp) {
|
|
466
|
+
if (logger) {
|
|
467
|
+
logger(
|
|
468
|
+
chalk.yellow(
|
|
469
|
+
" ⚠ Sharp not available, skipping style processing. Run: npm install sharp"
|
|
470
|
+
)
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return inputBuffer;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!styleConfig || !styleConfig.enabled) {
|
|
477
|
+
return inputBuffer;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const {
|
|
481
|
+
frame = "none",
|
|
482
|
+
shadow = "medium",
|
|
483
|
+
padding = 40,
|
|
484
|
+
background = "transparent",
|
|
485
|
+
borderRadius = 0,
|
|
486
|
+
} = styleConfig;
|
|
487
|
+
|
|
488
|
+
// If everything is disabled/default, skip processing
|
|
489
|
+
if (
|
|
490
|
+
frame === "none" &&
|
|
491
|
+
shadow === "none" &&
|
|
492
|
+
padding === 0 &&
|
|
493
|
+
borderRadius === 0
|
|
494
|
+
) {
|
|
495
|
+
return inputBuffer;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const metadata = await sharp(inputBuffer).metadata();
|
|
500
|
+
const imgWidth = metadata.width;
|
|
501
|
+
const imgHeight = metadata.height;
|
|
502
|
+
|
|
503
|
+
// Canvas size guard — prevent out-of-memory on huge images
|
|
504
|
+
if (imgWidth > MAX_CANVAS_DIM || imgHeight > MAX_CANVAS_DIM) {
|
|
505
|
+
if (logger) {
|
|
506
|
+
logger(chalk.yellow(` ⚠ Image too large for style processing (${imgWidth}x${imgHeight}), skipping`));
|
|
507
|
+
}
|
|
508
|
+
return inputBuffer;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Min-width guard — title bar elements won't fit on tiny images
|
|
512
|
+
if ((frame === "macos" || frame === "windows") && imgWidth < MIN_FRAME_WIDTH) {
|
|
513
|
+
if (logger) {
|
|
514
|
+
logger(chalk.yellow(` ⚠ Image too narrow for ${frame} frame (${imgWidth}px < ${MIN_FRAME_WIDTH}px), skipping frame`));
|
|
515
|
+
}
|
|
516
|
+
// Continue without frame
|
|
517
|
+
return applyStyle(inputBuffer, { ...styleConfig, frame: "none" }, logger, dpr);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Scale CSS-pixel constants by DPR for retina-accurate rendering
|
|
521
|
+
const scaledPadding = Math.round(padding * dpr);
|
|
522
|
+
const scaledBorderRadius = Math.round(borderRadius * dpr);
|
|
523
|
+
|
|
524
|
+
// Detect dark mode from config metadata
|
|
525
|
+
const darkMode = styleConfig._darkMode || false;
|
|
526
|
+
|
|
527
|
+
// --- Step 1: Round corners on screenshot (only when no frame — frame gets its own rounding) ---
|
|
528
|
+
let contentBuffer = inputBuffer;
|
|
529
|
+
if (scaledBorderRadius > 0 && frame === "none") {
|
|
530
|
+
contentBuffer = await roundCorners(
|
|
531
|
+
contentBuffer,
|
|
532
|
+
scaledBorderRadius,
|
|
533
|
+
imgWidth,
|
|
534
|
+
imgHeight
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Step 2: Add window frame ---
|
|
539
|
+
let frameHeight = 0;
|
|
540
|
+
let framedBuffer = contentBuffer;
|
|
541
|
+
|
|
542
|
+
if (frame === "macos" || frame === "windows") {
|
|
543
|
+
frameHeight = Math.round(FRAME_HEIGHT * dpr);
|
|
544
|
+
const titleBarSvg =
|
|
545
|
+
frame === "macos"
|
|
546
|
+
? generateMacOSTitleBar(imgWidth, darkMode, dpr)
|
|
547
|
+
: generateWindowsTitleBar(imgWidth, darkMode, dpr);
|
|
548
|
+
|
|
549
|
+
const titleBarBuffer = await sharp(titleBarSvg).png().toBuffer();
|
|
550
|
+
|
|
551
|
+
// Create a canvas for frame + content
|
|
552
|
+
const framedHeight = imgHeight + frameHeight;
|
|
553
|
+
framedBuffer = await sharp({
|
|
554
|
+
create: {
|
|
555
|
+
width: imgWidth,
|
|
556
|
+
height: framedHeight,
|
|
557
|
+
channels: 4,
|
|
558
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
.composite([
|
|
562
|
+
{ input: titleBarBuffer, top: 0, left: 0 },
|
|
563
|
+
{ input: contentBuffer, top: frameHeight, left: 0 },
|
|
564
|
+
])
|
|
565
|
+
.png()
|
|
566
|
+
.toBuffer();
|
|
567
|
+
|
|
568
|
+
// Round the outer corners of the framed composite
|
|
569
|
+
if (scaledBorderRadius > 0) {
|
|
570
|
+
framedBuffer = await roundCorners(
|
|
571
|
+
framedBuffer,
|
|
572
|
+
scaledBorderRadius,
|
|
573
|
+
imgWidth,
|
|
574
|
+
framedHeight
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const framedMeta = await sharp(framedBuffer).metadata();
|
|
580
|
+
const contentWidth = framedMeta.width;
|
|
581
|
+
const contentHeight = framedMeta.height;
|
|
582
|
+
|
|
583
|
+
// --- Step 3: Shadow (scaled by DPR) ---
|
|
584
|
+
const rawShadowConfig = SHADOW_PRESETS[shadow] || null;
|
|
585
|
+
const shadowConfig = rawShadowConfig ? {
|
|
586
|
+
blur: Math.round(rawShadowConfig.blur * dpr),
|
|
587
|
+
spread: Math.round(rawShadowConfig.spread * dpr),
|
|
588
|
+
offsetY: Math.round(rawShadowConfig.offsetY * dpr),
|
|
589
|
+
opacity: rawShadowConfig.opacity,
|
|
590
|
+
} : null;
|
|
591
|
+
const shadowResult = shadowConfig
|
|
592
|
+
? await generateShadowLayer(
|
|
593
|
+
contentWidth,
|
|
594
|
+
contentHeight,
|
|
595
|
+
shadowConfig,
|
|
596
|
+
scaledBorderRadius
|
|
597
|
+
)
|
|
598
|
+
: null;
|
|
599
|
+
|
|
600
|
+
// --- Step 4: Background + padding ---
|
|
601
|
+
// Calculate final canvas dimensions
|
|
602
|
+
const shadowExtra = shadowResult ? shadowResult.extra : 0;
|
|
603
|
+
const shadowOffsetY = shadowResult ? shadowResult.offsetY : 0;
|
|
604
|
+
const canvasWidth = contentWidth + scaledPadding * 2 + shadowExtra * 2;
|
|
605
|
+
const canvasHeight =
|
|
606
|
+
contentHeight + scaledPadding * 2 + shadowExtra * 2 + shadowOffsetY;
|
|
607
|
+
|
|
608
|
+
// Resolve background color/gradient
|
|
609
|
+
const bgColor = await resolveBackground(background, canvasWidth, canvasHeight);
|
|
610
|
+
|
|
611
|
+
// Create the final canvas
|
|
612
|
+
let canvas;
|
|
613
|
+
if (Buffer.isBuffer(bgColor)) {
|
|
614
|
+
// Gradient background - use the rendered gradient as base
|
|
615
|
+
canvas = sharp(bgColor).resize(canvasWidth, canvasHeight);
|
|
616
|
+
} else {
|
|
617
|
+
canvas = sharp({
|
|
618
|
+
create: {
|
|
619
|
+
width: canvasWidth,
|
|
620
|
+
height: canvasHeight,
|
|
621
|
+
channels: 4,
|
|
622
|
+
background: bgColor,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Build composite layers
|
|
628
|
+
const composites = [];
|
|
629
|
+
|
|
630
|
+
// Shadow layer (behind content)
|
|
631
|
+
if (shadowResult) {
|
|
632
|
+
composites.push({
|
|
633
|
+
input: shadowResult.buffer,
|
|
634
|
+
top: scaledPadding,
|
|
635
|
+
left: scaledPadding,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Content (framed screenshot)
|
|
640
|
+
const contentLeft = scaledPadding + shadowExtra;
|
|
641
|
+
const contentTop = scaledPadding + shadowExtra;
|
|
642
|
+
composites.push({
|
|
643
|
+
input: framedBuffer,
|
|
644
|
+
top: contentTop,
|
|
645
|
+
left: contentLeft,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const finalBuffer = await canvas.composite(composites).png().toBuffer();
|
|
649
|
+
|
|
650
|
+
return finalBuffer;
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (logger) {
|
|
653
|
+
logger(
|
|
654
|
+
chalk.yellow(` ⚠ Style processing failed: ${error.message}`)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
return inputBuffer;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Check if Sharp is available for style processing.
|
|
663
|
+
* @returns {boolean}
|
|
664
|
+
*/
|
|
665
|
+
function isStyleAvailable() {
|
|
666
|
+
return sharp !== null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
module.exports = {
|
|
670
|
+
DEFAULT_STYLE_CONFIG,
|
|
671
|
+
SHADOW_PRESETS,
|
|
672
|
+
FRAME_HEIGHT,
|
|
673
|
+
MAX_CANVAS_DIM,
|
|
674
|
+
MIN_FRAME_WIDTH,
|
|
675
|
+
validateStyleConfig,
|
|
676
|
+
mergeStyleConfig,
|
|
677
|
+
generateMacOSTitleBar,
|
|
678
|
+
generateWindowsTitleBar,
|
|
679
|
+
generateShadowLayer,
|
|
680
|
+
resolveBackground,
|
|
681
|
+
roundCorners,
|
|
682
|
+
applyStyle,
|
|
683
|
+
isStyleAvailable,
|
|
684
|
+
};
|