@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.
Files changed (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. 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
+ };