screenshot-beautify 1.1.1 → 1.2.1
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/README.md +12 -0
- package/dist/index.js +198 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,18 +64,30 @@ screenshot-beautify presets
|
|
|
64
64
|
|
|
65
65
|
| Category | Presets |
|
|
66
66
|
|----------|---------|
|
|
67
|
+
| **Smart** | `auto` - analyzes your screenshot and creates a matching gradient |
|
|
67
68
|
| Warm | `sunset`, `sunrise`, `peach` |
|
|
68
69
|
| Cool | `ocean`, `sky`, `northern` |
|
|
69
70
|
| Dark | `charcoal`, `midnight`, `space` |
|
|
70
71
|
| Vibrant | `neon`, `fire`, `aurora` |
|
|
71
72
|
| Soft | `lavender`, `mint`, `rose` |
|
|
72
73
|
|
|
74
|
+
### Auto Preset
|
|
75
|
+
|
|
76
|
+
The `auto` preset extracts the dominant colors from your screenshot and generates a complementary gradient background. This ensures your beautified screenshot always looks cohesive without manual color selection.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
screenshot-beautify screenshot.png --preset auto
|
|
80
|
+
```
|
|
81
|
+
|
|
73
82
|
## Examples
|
|
74
83
|
|
|
75
84
|
```bash
|
|
76
85
|
# Basic usage
|
|
77
86
|
screenshot-beautify screenshot.png
|
|
78
87
|
|
|
88
|
+
# Auto-match background to screenshot colors
|
|
89
|
+
screenshot-beautify screenshot.png --preset auto
|
|
90
|
+
|
|
79
91
|
# With sunset gradient
|
|
80
92
|
screenshot-beautify screenshot.png --preset sunset
|
|
81
93
|
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,124 @@ import sharp2 from "sharp";
|
|
|
11
11
|
|
|
12
12
|
// src/presets.ts
|
|
13
13
|
import sharp from "sharp";
|
|
14
|
+
function rgbToHex(r, g, b) {
|
|
15
|
+
return "#" + [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
16
|
+
}
|
|
17
|
+
function rgbToHsl(r, g, b) {
|
|
18
|
+
r /= 255;
|
|
19
|
+
g /= 255;
|
|
20
|
+
b /= 255;
|
|
21
|
+
const max = Math.max(r, g, b);
|
|
22
|
+
const min = Math.min(r, g, b);
|
|
23
|
+
let h = 0, s = 0;
|
|
24
|
+
const l = (max + min) / 2;
|
|
25
|
+
if (max !== min) {
|
|
26
|
+
const d = max - min;
|
|
27
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
28
|
+
switch (max) {
|
|
29
|
+
case r:
|
|
30
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
31
|
+
break;
|
|
32
|
+
case g:
|
|
33
|
+
h = ((b - r) / d + 2) / 6;
|
|
34
|
+
break;
|
|
35
|
+
case b:
|
|
36
|
+
h = ((r - g) / d + 4) / 6;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { h: h * 360, s: s * 100, l: l * 100 };
|
|
41
|
+
}
|
|
42
|
+
function hslToRgb(h, s, l) {
|
|
43
|
+
h /= 360;
|
|
44
|
+
s /= 100;
|
|
45
|
+
l /= 100;
|
|
46
|
+
let r, g, b;
|
|
47
|
+
if (s === 0) {
|
|
48
|
+
r = g = b = l;
|
|
49
|
+
} else {
|
|
50
|
+
const hue2rgb = (p2, q2, t) => {
|
|
51
|
+
if (t < 0) t += 1;
|
|
52
|
+
if (t > 1) t -= 1;
|
|
53
|
+
if (t < 1 / 6) return p2 + (q2 - p2) * 6 * t;
|
|
54
|
+
if (t < 1 / 2) return q2;
|
|
55
|
+
if (t < 2 / 3) return p2 + (q2 - p2) * (2 / 3 - t) * 6;
|
|
56
|
+
return p2;
|
|
57
|
+
};
|
|
58
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
59
|
+
const p = 2 * l - q;
|
|
60
|
+
r = hue2rgb(p, q, h + 1 / 3);
|
|
61
|
+
g = hue2rgb(p, q, h);
|
|
62
|
+
b = hue2rgb(p, q, h - 1 / 3);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
r: Math.round(r * 255),
|
|
66
|
+
g: Math.round(g * 255),
|
|
67
|
+
b: Math.round(b * 255)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function extractDominantColor(imagePath) {
|
|
71
|
+
const { data, info } = await sharp(imagePath).resize(50, 50, { fit: "cover" }).raw().toBuffer({ resolveWithObject: true });
|
|
72
|
+
const colorCounts = /* @__PURE__ */ new Map();
|
|
73
|
+
let totalSaturation = 0;
|
|
74
|
+
let pixelCount = 0;
|
|
75
|
+
for (let i = 0; i < data.length; i += info.channels) {
|
|
76
|
+
const r = data[i];
|
|
77
|
+
const g = data[i + 1];
|
|
78
|
+
const b = data[i + 2];
|
|
79
|
+
const pixelHsl = rgbToHsl(r, g, b);
|
|
80
|
+
totalSaturation += pixelHsl.s;
|
|
81
|
+
pixelCount++;
|
|
82
|
+
const qr = Math.round(r / 32) * 32;
|
|
83
|
+
const qg = Math.round(g / 32) * 32;
|
|
84
|
+
const qb = Math.round(b / 32) * 32;
|
|
85
|
+
const key = `${qr},${qg},${qb}`;
|
|
86
|
+
const existing = colorCounts.get(key);
|
|
87
|
+
if (existing) {
|
|
88
|
+
existing.count++;
|
|
89
|
+
} else {
|
|
90
|
+
colorCounts.set(key, { count: 1, r: qr, g: qg, b: qb });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const sorted = [...colorCounts.values()].sort((a, b) => b.count - a.count);
|
|
94
|
+
const dominant = sorted[0];
|
|
95
|
+
const hsl = rgbToHsl(dominant.r, dominant.g, dominant.b);
|
|
96
|
+
const avgSaturation = totalSaturation / pixelCount;
|
|
97
|
+
const rgbRange = Math.max(dominant.r, dominant.g, dominant.b) - Math.min(dominant.r, dominant.g, dominant.b);
|
|
98
|
+
const isGreyish = rgbRange < 40;
|
|
99
|
+
const isNeutral = avgSaturation < 25 || hsl.s < 15 || isGreyish;
|
|
100
|
+
let color1Hsl, color2Hsl;
|
|
101
|
+
if (isNeutral) {
|
|
102
|
+
color1Hsl = {
|
|
103
|
+
h: hsl.h,
|
|
104
|
+
s: 0,
|
|
105
|
+
l: Math.min(hsl.l + 8, 55)
|
|
106
|
+
};
|
|
107
|
+
color2Hsl = {
|
|
108
|
+
h: hsl.h,
|
|
109
|
+
s: 0,
|
|
110
|
+
l: Math.max(hsl.l - 5, 20)
|
|
111
|
+
};
|
|
112
|
+
} else {
|
|
113
|
+
color1Hsl = {
|
|
114
|
+
h: hsl.h,
|
|
115
|
+
s: Math.min(hsl.s + 5, 60),
|
|
116
|
+
l: Math.min(hsl.l + 10, 60)
|
|
117
|
+
};
|
|
118
|
+
color2Hsl = {
|
|
119
|
+
h: (hsl.h + 15) % 360,
|
|
120
|
+
// Slight hue shift
|
|
121
|
+
s: hsl.s,
|
|
122
|
+
l: Math.max(hsl.l - 5, 25)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const color1 = hslToRgb(color1Hsl.h, color1Hsl.s, color1Hsl.l);
|
|
126
|
+
const color2 = hslToRgb(color2Hsl.h, color2Hsl.s, color2Hsl.l);
|
|
127
|
+
return [
|
|
128
|
+
rgbToHex(color1.r, color1.g, color1.b),
|
|
129
|
+
rgbToHex(color2.r, color2.g, color2.b)
|
|
130
|
+
];
|
|
131
|
+
}
|
|
14
132
|
var GRADIENT_PRESETS = {
|
|
15
133
|
// Warm tones
|
|
16
134
|
sunset: {
|
|
@@ -94,7 +212,7 @@ var GRADIENT_PRESETS = {
|
|
|
94
212
|
}
|
|
95
213
|
};
|
|
96
214
|
function listPresets() {
|
|
97
|
-
return Object.keys(GRADIENT_PRESETS);
|
|
215
|
+
return ["auto", ...Object.keys(GRADIENT_PRESETS)];
|
|
98
216
|
}
|
|
99
217
|
async function createPresetBackground(presetName, width, height) {
|
|
100
218
|
const preset = GRADIENT_PRESETS[presetName];
|
|
@@ -131,6 +249,7 @@ async function createBackground(options) {
|
|
|
131
249
|
height,
|
|
132
250
|
imagePath,
|
|
133
251
|
preset,
|
|
252
|
+
sourceImagePath,
|
|
134
253
|
// Dark charcoal gradient as default
|
|
135
254
|
gradientStart = "#2d3436",
|
|
136
255
|
gradientEnd = "#636e72"
|
|
@@ -141,6 +260,10 @@ async function createBackground(options) {
|
|
|
141
260
|
position: "center"
|
|
142
261
|
}).png().toBuffer();
|
|
143
262
|
}
|
|
263
|
+
if (preset === "auto" && sourceImagePath) {
|
|
264
|
+
const [autoStart, autoEnd] = await extractDominantColor(sourceImagePath);
|
|
265
|
+
return createAutoBackground(width, height, autoStart, autoEnd);
|
|
266
|
+
}
|
|
144
267
|
if (preset && GRADIENT_PRESETS[preset]) {
|
|
145
268
|
return createPresetBackground(preset, width, height);
|
|
146
269
|
}
|
|
@@ -157,6 +280,20 @@ async function createBackground(options) {
|
|
|
157
280
|
`;
|
|
158
281
|
return sharp2(Buffer.from(svg)).png().toBuffer();
|
|
159
282
|
}
|
|
283
|
+
async function createAutoBackground(width, height, gradientStart, gradientEnd) {
|
|
284
|
+
const svg = `
|
|
285
|
+
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
286
|
+
<defs>
|
|
287
|
+
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
288
|
+
<stop offset="0%" style="stop-color:${gradientStart};stop-opacity:1" />
|
|
289
|
+
<stop offset="100%" style="stop-color:${gradientEnd};stop-opacity:1" />
|
|
290
|
+
</linearGradient>
|
|
291
|
+
</defs>
|
|
292
|
+
<rect width="100%" height="100%" fill="url(#grad)" />
|
|
293
|
+
</svg>
|
|
294
|
+
`;
|
|
295
|
+
return sharp2(Buffer.from(svg)).png().toBuffer();
|
|
296
|
+
}
|
|
160
297
|
|
|
161
298
|
// src/frame.ts
|
|
162
299
|
import sharp3 from "sharp";
|
|
@@ -229,7 +366,8 @@ async function beautify(inputPath, outputPath, options = {}) {
|
|
|
229
366
|
imagePath: backgroundImage,
|
|
230
367
|
preset: backgroundPreset,
|
|
231
368
|
gradientStart,
|
|
232
|
-
gradientEnd
|
|
369
|
+
gradientEnd,
|
|
370
|
+
sourceImagePath: backgroundPreset === "auto" ? inputPath : void 0
|
|
233
371
|
});
|
|
234
372
|
const frame = await createFrame({
|
|
235
373
|
width: framedWidth,
|
|
@@ -241,6 +379,35 @@ async function beautify(inputPath, outputPath, options = {}) {
|
|
|
241
379
|
const frameY = padding;
|
|
242
380
|
const screenshotX = frameX;
|
|
243
381
|
const screenshotY = frameY + titleBarHeight;
|
|
382
|
+
let shadow = null;
|
|
383
|
+
if (backgroundPreset === "auto") {
|
|
384
|
+
const shadowBlur = 30;
|
|
385
|
+
const shadowOffsetX = 0;
|
|
386
|
+
const shadowOffsetY = 8;
|
|
387
|
+
const shadowOpacity = 0.4;
|
|
388
|
+
const shadowWidth = framedWidth + shadowBlur * 2;
|
|
389
|
+
const shadowHeight = framedHeight + shadowBlur * 2;
|
|
390
|
+
const shadowSvg = `
|
|
391
|
+
<svg width="${shadowWidth}" height="${shadowHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
392
|
+
<defs>
|
|
393
|
+
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
394
|
+
<feGaussianBlur in="SourceGraphic" stdDeviation="${shadowBlur / 2}" />
|
|
395
|
+
</filter>
|
|
396
|
+
</defs>
|
|
397
|
+
<rect
|
|
398
|
+
x="${shadowBlur}"
|
|
399
|
+
y="${shadowBlur}"
|
|
400
|
+
width="${framedWidth}"
|
|
401
|
+
height="${framedHeight}"
|
|
402
|
+
rx="${cornerRadius}"
|
|
403
|
+
ry="${cornerRadius}"
|
|
404
|
+
fill="rgba(0, 0, 0, ${shadowOpacity})"
|
|
405
|
+
filter="url(#shadow)"
|
|
406
|
+
/>
|
|
407
|
+
</svg>
|
|
408
|
+
`;
|
|
409
|
+
shadow = await sharp4(Buffer.from(shadowSvg)).png().toBuffer();
|
|
410
|
+
}
|
|
244
411
|
const cornerMask = Buffer.from(`
|
|
245
412
|
<svg width="${imgWidth}" height="${imgHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
246
413
|
<rect
|
|
@@ -263,20 +430,27 @@ async function beautify(inputPath, outputPath, options = {}) {
|
|
|
263
430
|
blend: "dest-in"
|
|
264
431
|
}
|
|
265
432
|
]).png().toBuffer();
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
433
|
+
const layers = [];
|
|
434
|
+
if (shadow) {
|
|
435
|
+
const shadowBlur = 30;
|
|
436
|
+
const shadowOffsetY = 8;
|
|
437
|
+
layers.push({
|
|
438
|
+
input: shadow,
|
|
439
|
+
left: Math.round(frameX - shadowBlur),
|
|
440
|
+
top: Math.round(frameY - shadowBlur + shadowOffsetY)
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
layers.push({
|
|
444
|
+
input: frame,
|
|
445
|
+
left: Math.round(frameX),
|
|
446
|
+
top: Math.round(frameY)
|
|
447
|
+
});
|
|
448
|
+
layers.push({
|
|
449
|
+
input: roundedScreenshot,
|
|
450
|
+
left: Math.round(screenshotX),
|
|
451
|
+
top: Math.round(screenshotY)
|
|
452
|
+
});
|
|
453
|
+
await sharp4(background).composite(layers).png().toFile(outputPath);
|
|
280
454
|
}
|
|
281
455
|
|
|
282
456
|
// src/tray.ts
|
|
@@ -463,7 +637,8 @@ var program = new Command();
|
|
|
463
637
|
program.name("screenshot-beautify").description("Beautify screenshots with window frames, backgrounds, and shadows").version("1.0.0");
|
|
464
638
|
program.command("presets").description("List available background presets").action(() => {
|
|
465
639
|
console.log("Available background presets:\n");
|
|
466
|
-
|
|
640
|
+
console.log(" - auto (analyzes image and creates matching gradient)");
|
|
641
|
+
const presets = listPresets().filter((p) => p !== "auto");
|
|
467
642
|
presets.forEach((name) => {
|
|
468
643
|
console.log(` - ${name}`);
|
|
469
644
|
});
|
|
@@ -579,7 +754,7 @@ program.command("tray <source> <output>").description("Run as a system tray app
|
|
|
579
754
|
deleteOriginal: opts.deleteOriginal
|
|
580
755
|
});
|
|
581
756
|
});
|
|
582
|
-
program.command("file <input>", { isDefault: true }).description("Beautify a single screenshot").option("-o, --output <path>", "Output file path").option("--padding <number>", "Padding around the screenshot", "80").option("--background <path>", "Background image path").option("--preset <name>", "Background preset (run 'presets' to see options)").action(async (input, opts) => {
|
|
757
|
+
program.command("file <input>", { isDefault: true }).description("Beautify a single screenshot").option("-o, --output <path>", "Output file path").option("--padding <number>", "Padding around the screenshot", "80").option("--background <path>", "Background image path").option("--preset <name>", "Background preset (run 'presets' to see options)").option("--delete-original", "Delete original file after beautifying", false).action(async (input, opts) => {
|
|
583
758
|
try {
|
|
584
759
|
const inputPath = resolve2(input);
|
|
585
760
|
const padding = parseInt(opts.padding || "80", 10);
|
|
@@ -592,6 +767,11 @@ program.command("file <input>", { isDefault: true }).description("Beautify a sin
|
|
|
592
767
|
console.log(`Beautifying: ${inputPath}`);
|
|
593
768
|
await beautify(inputPath, outputPath, { padding, backgroundImage, backgroundPreset });
|
|
594
769
|
console.log(`Saved to: ${outputPath}`);
|
|
770
|
+
if (opts.deleteOriginal) {
|
|
771
|
+
const { unlink } = await import("fs/promises");
|
|
772
|
+
await unlink(inputPath);
|
|
773
|
+
console.log(`\u{1F5D1}\uFE0F Deleted original: ${basename2(inputPath)}`);
|
|
774
|
+
}
|
|
595
775
|
} catch (error) {
|
|
596
776
|
console.error("Error:", error instanceof Error ? error.message : error);
|
|
597
777
|
process.exit(1);
|