@dooph-software/fluid-gradients 1.0.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.txt +21 -0
- package/README.md +200 -0
- package/bin/init-skills.mjs +91 -0
- package/dist/chunk-CRRF2Q64.cjs +260 -0
- package/dist/chunk-CRRF2Q64.cjs.map +1 -0
- package/dist/chunk-KYCZHKFE.js +260 -0
- package/dist/chunk-KYCZHKFE.js.map +1 -0
- package/dist/cli.js +604 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +210 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +105 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +210 -0
- package/dist/index.js.map +1 -0
- package/dist/palettes.cjs +25 -0
- package/dist/palettes.cjs.map +1 -0
- package/dist/palettes.d.cts +261 -0
- package/dist/palettes.d.ts +261 -0
- package/dist/palettes.js +25 -0
- package/dist/palettes.js.map +1 -0
- package/package.json +70 -0
- package/skills/using-fluid-gradients/SKILL.md +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
import { mkdirSync, realpathSync, writeFileSync } from "fs";
|
|
6
|
+
import { dirname, extname, join } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { parseArgs } from "util";
|
|
9
|
+
|
|
10
|
+
// src/render.ts
|
|
11
|
+
import { createCanvas, ImageData } from "@napi-rs/canvas";
|
|
12
|
+
import sharp from "sharp";
|
|
13
|
+
import { createNoise2D } from "simplex-noise";
|
|
14
|
+
|
|
15
|
+
// src/color.ts
|
|
16
|
+
var ACHROMATIC_CHROMA = 0.01;
|
|
17
|
+
var HUE_TRUST_CHROMA = 0.03;
|
|
18
|
+
var VIVID_L_MIN = 50;
|
|
19
|
+
var VIVID_L_MAX = 80;
|
|
20
|
+
var VIVID_MAX_CHROMA = 0.2;
|
|
21
|
+
var CHROMA_SEARCH_MAX = 0.4;
|
|
22
|
+
var GAMUT_STEPS = 24;
|
|
23
|
+
var toLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
24
|
+
var toGamma = (c) => c <= 31308e-7 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
25
|
+
function hexToRgb(hex) {
|
|
26
|
+
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
27
|
+
}
|
|
28
|
+
function rgbToHex(rgb) {
|
|
29
|
+
return "#" + rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
function hexToOklab(hex) {
|
|
32
|
+
const [r, g, b] = hexToRgb(hex).map(toLinear);
|
|
33
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
34
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
35
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
36
|
+
return [
|
|
37
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
38
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
39
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
function oklabToSrgb([L, a, b]) {
|
|
43
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
44
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
45
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
46
|
+
return [
|
|
47
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
48
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
49
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
|
|
50
|
+
].map(toGamma);
|
|
51
|
+
}
|
|
52
|
+
var inGamut = (rgb) => rgb.every((c) => c >= 0 && c <= 1);
|
|
53
|
+
var atLch = (L, C, hue) => oklabToSrgb([L, C * Math.cos(hue), C * Math.sin(hue)]);
|
|
54
|
+
function maxChroma(L, hue) {
|
|
55
|
+
let low = 0;
|
|
56
|
+
let high = CHROMA_SEARCH_MAX;
|
|
57
|
+
for (let step = 0; step < GAMUT_STEPS; step++) {
|
|
58
|
+
const mid = (low + high) / 2;
|
|
59
|
+
if (inGamut(atLch(L, mid, hue))) low = mid;
|
|
60
|
+
else high = mid;
|
|
61
|
+
}
|
|
62
|
+
return low;
|
|
63
|
+
}
|
|
64
|
+
function vividOpposite(a, b) {
|
|
65
|
+
const hue = Math.atan2(b, a) + Math.PI;
|
|
66
|
+
let bestL = VIVID_L_MIN / 100;
|
|
67
|
+
let bestC = -1;
|
|
68
|
+
for (let l = VIVID_L_MIN; l <= VIVID_L_MAX; l++) {
|
|
69
|
+
const chroma = maxChroma(l / 100, hue);
|
|
70
|
+
if (chroma > bestC) {
|
|
71
|
+
bestC = chroma;
|
|
72
|
+
bestL = l / 100;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return rgbToHex(atLch(bestL, Math.min(bestC, VIVID_MAX_CHROMA), hue));
|
|
76
|
+
}
|
|
77
|
+
function oppositeGray(L) {
|
|
78
|
+
return rgbToHex(atLch(L > 0.5 ? 0.25 : 0.85, 0, 0));
|
|
79
|
+
}
|
|
80
|
+
function paletteComplementColor(accent, ramp) {
|
|
81
|
+
const [L, a, b] = hexToOklab(accent);
|
|
82
|
+
if (Math.hypot(a, b) >= HUE_TRUST_CHROMA) return vividOpposite(a, b);
|
|
83
|
+
let sumA = 0;
|
|
84
|
+
let sumB = 0;
|
|
85
|
+
for (const color of ramp) {
|
|
86
|
+
const [, ca, cb] = hexToOklab(color);
|
|
87
|
+
sumA += ca;
|
|
88
|
+
sumB += cb;
|
|
89
|
+
}
|
|
90
|
+
const meanA = sumA / ramp.length;
|
|
91
|
+
const meanB = sumB / ramp.length;
|
|
92
|
+
return Math.hypot(meanA, meanB) < ACHROMATIC_CHROMA / 2 ? oppositeGray(L) : vividOpposite(meanA, meanB);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/random.ts
|
|
96
|
+
function hashStringToInt(seed) {
|
|
97
|
+
let hash = 2166136261 >>> 0;
|
|
98
|
+
for (let i = 0; i < seed.length; i++) {
|
|
99
|
+
hash ^= seed.charCodeAt(i);
|
|
100
|
+
hash = Math.imul(hash, 16777619);
|
|
101
|
+
}
|
|
102
|
+
return hash >>> 0;
|
|
103
|
+
}
|
|
104
|
+
function createSeededRandom(seedInt) {
|
|
105
|
+
let state = seedInt;
|
|
106
|
+
return function nextRandom() {
|
|
107
|
+
state |= 0;
|
|
108
|
+
state = state + 1831565813 | 0;
|
|
109
|
+
let mixed = Math.imul(state ^ state >>> 15, 1 | state);
|
|
110
|
+
mixed = mixed + Math.imul(mixed ^ mixed >>> 7, 61 | mixed) ^ mixed;
|
|
111
|
+
return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/palettes.ts
|
|
116
|
+
var MIN_PALETTE_COLORS = 3;
|
|
117
|
+
var MAX_PALETTE_COLORS = 5;
|
|
118
|
+
var colorPalettes = [
|
|
119
|
+
// ── Warm: amber / cream / clay / terracotta (analogous) ──
|
|
120
|
+
{ name: "amber-latte", colors: ["#faf4ea", "#f2d29b", "#e0a45c", "#b9722f"] },
|
|
121
|
+
{ name: "terracotta-sand", colors: ["#f6ede1", "#e8c9a0", "#cf8f63", "#9c4e33"] },
|
|
122
|
+
{ name: "peach-blush", colors: ["#fdf1ec", "#f8cbb6", "#ef9a8a", "#d76e73"] },
|
|
123
|
+
{ name: "coral-cream", colors: ["#fbe9df", "#f5b79a", "#e8735a", "#c33f36"] },
|
|
124
|
+
{ name: "honey-espresso", colors: ["#f7ecd9", "#e5b877", "#b9793f", "#5f3418"] },
|
|
125
|
+
{ name: "apricot-plum", colors: ["#fdf0e6", "#f6c79a", "#e58b6f", "#7d4a6b"] },
|
|
126
|
+
{ name: "golden-hour", colors: ["#fdf3e1", "#f7cf8a", "#e89a5a", "#9a4a3a"] },
|
|
127
|
+
{ name: "vermilion-blush", colors: ["#fbe8e2", "#f7c6a8", "#e4562f", "#a82a1c"] },
|
|
128
|
+
{ name: "ivory-gilt", colors: ["#fffdf7", "#f7ecd0", "#e0c47e", "#b08c3a"] },
|
|
129
|
+
{ name: "rose-gold", colors: ["#fceee8", "#f2cdbd", "#dba18c", "#b3705c"] },
|
|
130
|
+
{ name: "peony-glow", colors: ["#fdeef3", "#f9b3c8", "#f78a5c", "#f2a93f"] },
|
|
131
|
+
// ── Pink / violet / mauve ──
|
|
132
|
+
{ name: "lavender-rose", colors: ["#f4eefb", "#d9c2ef", "#c98fb8", "#8a5fd0"] },
|
|
133
|
+
{ name: "violet-mist", colors: ["#f1eefb", "#c9b8ef", "#8f7fe0", "#4a3d9c"] },
|
|
134
|
+
{ name: "mauve-plum", colors: ["#f6f0f2", "#d8b8c6", "#a5738c", "#5f3a55"] },
|
|
135
|
+
{ name: "periwinkle-blush", colors: ["#f0f0fb", "#c3c6f0", "#cf9fc6", "#6a5fd0"] },
|
|
136
|
+
{ name: "orchid-frost", colors: ["#f6eff6", "#e0c2e0", "#b98fc4", "#7a5aa6", "#3f2f5c"] },
|
|
137
|
+
{ name: "quartz-rose", colors: ["#faf1f1", "#ecc9cc", "#c99aa3", "#8a5f6a"] },
|
|
138
|
+
{ name: "wisteria-sky", colors: ["#f2f0fb", "#cfc6ef", "#9fb2e6", "#4f6aa6"] },
|
|
139
|
+
{ name: "zephyr-dawn", colors: ["#fdf3f5", "#f2d6e2", "#c6d6f2", "#7f9fd8"] },
|
|
140
|
+
{ name: "candy-sky", colors: ["#f6e8ff", "#f7a3e8", "#9aa8f5", "#3fa0f5"] },
|
|
141
|
+
// ── Cool: blue / slate / sky (analogous) ──
|
|
142
|
+
{ name: "sky-slate", colors: ["#eef4fb", "#bcd2ef", "#6f8fc4", "#2f3f66"] },
|
|
143
|
+
{ name: "teal-fog", colors: ["#eef7f6", "#b6dcd8", "#5fa39c", "#234a49"] },
|
|
144
|
+
{ name: "arctic-ink", colors: ["#f2f6f8", "#cddbe2", "#7f96a3", "#1c2630"] },
|
|
145
|
+
{ name: "cloud-navy", colors: ["#f0f3f7", "#c6d1de", "#7488a3", "#1f2c44"] },
|
|
146
|
+
{ name: "denim-clay", colors: ["#eef2f6", "#aebfd0", "#5f7794", "#b06a4e"] },
|
|
147
|
+
{ name: "cobalt-ice", colors: ["#e8eeff", "#b9cdfb", "#2f4fd8", "#1836b8"] },
|
|
148
|
+
// ── Green: sage / moss / mint / olive (analogous) ──
|
|
149
|
+
{ name: "sage-mist", colors: ["#f2f5ee", "#cdd8bd", "#8fa579", "#4a5c3a"] },
|
|
150
|
+
{ name: "mint-sea", colors: ["#eef8f4", "#b6e2d2", "#5fb39c", "#1f5c4d"] },
|
|
151
|
+
{ name: "olive-cream", colors: ["#f6f3e6", "#dbd09b", "#a39a5c", "#5f5a2f"] },
|
|
152
|
+
{ name: "eucalyptus-fog", colors: ["#eff4f1", "#cbdcd2", "#94b3a6", "#4f6b60"] },
|
|
153
|
+
{ name: "kelp-forest", colors: ["#eef3ea", "#a9c49a", "#4f7a5a", "#1f3a2f"] },
|
|
154
|
+
{ name: "yuzu-mint", colors: ["#fbfbe6", "#eef29a", "#b3d98a", "#3f8f7a"] },
|
|
155
|
+
{ name: "lime-lagoon", colors: ["#f8ffe8", "#c8f56a", "#6fe3d8", "#2fc6d8"] },
|
|
156
|
+
{ name: "bliss-meadow", colors: ["#f4fbff", "#8ed0f2", "#6cb03a", "#2d6b22"] },
|
|
157
|
+
// ── Analogous + a single contrasting splash ──
|
|
158
|
+
{ name: "periwinkle-gold", colors: ["#dfe6ff", "#a9b6f0", "#5b6bd6", "#e6c25a"] },
|
|
159
|
+
{ name: "lilac-lemon", colors: ["#f3eefb", "#d3c2ef", "#9f8fd0", "#e6d25a"] },
|
|
160
|
+
{ name: "blush-teal", colors: ["#fbeee9", "#f2c2b0", "#d78a8f", "#3f9c94"] },
|
|
161
|
+
{ name: "sage-coral", colors: ["#f2f5ee", "#cdd8bd", "#8fa579", "#e0755a"] },
|
|
162
|
+
{ name: "slate-amber", colors: ["#eef2f6", "#bcc9d8", "#5f7794", "#e0a24e"] },
|
|
163
|
+
{ name: "ultramarine-sand", colors: ["#dfe8ff", "#e6c27a", "#4757dd", "#232a9c"] },
|
|
164
|
+
{ name: "amber-tide", colors: ["#eef4f8", "#a9c8ef", "#f2b96b", "#df8a33"] },
|
|
165
|
+
// ── Colorful / multi-hue (rich & saturated, jewel tones) ──
|
|
166
|
+
{ name: "sunset-rose", colors: ["#fce9d6", "#f6a95c", "#e8617a", "#8a3d7a"] },
|
|
167
|
+
{ name: "berry-bloom", colors: ["#fbe9f2", "#e88fb8", "#c94f8f", "#7a2f6a"] },
|
|
168
|
+
{ name: "jewel-peacock", colors: ["#e6f5f0", "#4fb3a6", "#1f7a99", "#243f8a"] },
|
|
169
|
+
{ name: "iris-meadow", colors: ["#eaf5ec", "#7cc48f", "#3f8fb3", "#5f4fb0"] },
|
|
170
|
+
{ name: "autumn-spice", colors: ["#f7ecd6", "#dba24f", "#b5522f", "#6e2f4a"] },
|
|
171
|
+
{ name: "ultraviolet-sea", colors: ["#e9f0fb", "#6f9fe0", "#4a5fc4", "#6a3da6"] },
|
|
172
|
+
{ name: "purple-rain", colors: ["#f1ebf7", "#b69ad6", "#6f3fa8", "#2a1740"] },
|
|
173
|
+
{ name: "nectarine-fizz", colors: ["#fff1e6", "#ffc59a", "#ff8a5c", "#d9485f"] },
|
|
174
|
+
{ name: "raspberry-cream", colors: ["#fdeef1", "#f4a9bd", "#d9537a", "#8a1f45"] },
|
|
175
|
+
{ name: "pastel-confetti", colors: ["#ffffff", "#f9f3cf", "#cbbcf2", "#e8798f"] },
|
|
176
|
+
{ name: "burgundy-velvet", colors: ["#f2dfe0", "#c98a91", "#8a2740", "#4a0f22"] },
|
|
177
|
+
{ name: "indigo-beam", colors: ["#ffffff", "#a9b6f7", "#4a3fe0", "#2a1fb0"] },
|
|
178
|
+
{ name: "crimson-ice", colors: ["#f2f7fc", "#f8d7e0", "#e0517f", "#a81050"] },
|
|
179
|
+
// ── Muted / dusty / earthy ──
|
|
180
|
+
{ name: "fog-plum", colors: ["#f2f0f3", "#cfc6d2", "#9a8a9c", "#5f4a5c"] },
|
|
181
|
+
{ name: "sand-sky", colors: ["#f4efe4", "#e0d3b8", "#a9bcc4", "#5f7f8c"] },
|
|
182
|
+
{ name: "xanadu-sage", colors: ["#f1f4f1", "#c8d3c8", "#738678", "#36443a"] },
|
|
183
|
+
{ name: "walnut-grain", colors: ["#e8d5b7", "#c49a6c", "#8a5a34", "#4a2c17"] },
|
|
184
|
+
// ── Dark-focused: ramps that start mid-tone instead of near-white, so the
|
|
185
|
+
// image reads dark overall with the lighter stop as a glow ──
|
|
186
|
+
{ name: "onyx-gold", colors: ["#e8c877", "#b8862f", "#3a2f14", "#0b0b0d"] },
|
|
187
|
+
{ name: "onyx-silver", colors: ["#d7dade", "#8b9199", "#3a3f45", "#0c0d0f"] },
|
|
188
|
+
{ name: "graphite-royal", colors: ["#9fb0e8", "#3f56c4", "#2a2e38", "#101218"] },
|
|
189
|
+
{ name: "onyx-ultraviolet", colors: ["#c9a6ff", "#7b3ff2", "#2a1450", "#08070d"] },
|
|
190
|
+
{ name: "onyx-snow", colors: ["#ffffff", "#b0b0b0", "#4a4a4a", "#000000"] },
|
|
191
|
+
{ name: "midnight-ember", colors: ["#f0a468", "#c23f1f", "#2a1410", "#0a0a0c"] },
|
|
192
|
+
{ name: "slate-harbor", colors: ["#aeb7bd", "#6f7b84", "#3c454c", "#1a1f24"] },
|
|
193
|
+
// ── Neutral / mono ──
|
|
194
|
+
{ name: "ivory-ink", colors: ["#f7f4ef", "#d8d2c8", "#8a8378", "#20201d"] },
|
|
195
|
+
{ name: "paper-graphite", colors: ["#f4f4f5", "#cfcfd4", "#83838f", "#1c1c22"] },
|
|
196
|
+
{ name: "bone-coffee", colors: ["#f5efe6", "#d9c9b3", "#9c8468", "#3a2c1e"] },
|
|
197
|
+
{ name: "porcelain-greige", colors: ["#f6f3ef", "#ddd4c9", "#b0a596", "#6a5f52"] },
|
|
198
|
+
{ name: "battleship-steel", colors: ["#e6e9e8", "#b9c0be", "#7d8683", "#3f4745"] },
|
|
199
|
+
{ name: "gunmetal-fog", colors: ["#eceef1", "#c2c7cd", "#7b838d", "#343a42"] }
|
|
200
|
+
];
|
|
201
|
+
function paletteAccent(palette) {
|
|
202
|
+
const colors = palette.colors;
|
|
203
|
+
return colors[Math.floor(colors.length / 2)];
|
|
204
|
+
}
|
|
205
|
+
function paletteComplement(palette) {
|
|
206
|
+
return paletteComplementColor(paletteAccent(palette), palette.colors);
|
|
207
|
+
}
|
|
208
|
+
var HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
209
|
+
function validatePalette(palette) {
|
|
210
|
+
const label = `Palette "${palette?.name}"`;
|
|
211
|
+
if (typeof palette?.name !== "string" || palette.name.length === 0) {
|
|
212
|
+
throw new Error("Palette needs a non-empty name.");
|
|
213
|
+
}
|
|
214
|
+
const { colors } = palette;
|
|
215
|
+
if (!Array.isArray(colors) || colors.length < MIN_PALETTE_COLORS || colors.length > MAX_PALETTE_COLORS) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${label} needs ${MIN_PALETTE_COLORS}\u2013${MAX_PALETTE_COLORS} colors. Got ${Array.isArray(colors) ? colors.length : typeof colors}.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
for (const color of colors) {
|
|
221
|
+
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
|
|
222
|
+
throw new Error(`${label} has an invalid color "${color}". Use #rrggbb.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function validatePalettes(palettes) {
|
|
227
|
+
if (palettes.length === 0) throw new Error("At least one palette is required.");
|
|
228
|
+
const seen = /* @__PURE__ */ new Set();
|
|
229
|
+
for (const palette of palettes) {
|
|
230
|
+
validatePalette(palette);
|
|
231
|
+
if (seen.has(palette.name)) throw new Error(`Duplicate palette name "${palette.name}".`);
|
|
232
|
+
seen.add(palette.name);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function findPalette(name, palettes = colorPalettes) {
|
|
236
|
+
const palette = palettes.find((p) => p.name === name);
|
|
237
|
+
if (!palette) {
|
|
238
|
+
throw new Error(`Unknown palette "${name}". Valid: ${palettes.map((p) => p.name).join(", ")}`);
|
|
239
|
+
}
|
|
240
|
+
return palette;
|
|
241
|
+
}
|
|
242
|
+
function pickPalette(seed, palettes = colorPalettes) {
|
|
243
|
+
let best;
|
|
244
|
+
let bestScore = -1;
|
|
245
|
+
for (const palette of palettes) {
|
|
246
|
+
const score = createSeededRandom(hashStringToInt(`${seed}::palette::${palette.name}`))();
|
|
247
|
+
if (score > bestScore || score === bestScore && palette.name < best.name) {
|
|
248
|
+
best = palette;
|
|
249
|
+
bestScore = score;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!best) throw new Error("At least one palette is required.");
|
|
253
|
+
return best;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/render.ts
|
|
257
|
+
var lookDescriptions = {
|
|
258
|
+
baseNoiseFrequency: "Lower values give broader, softer color regions.",
|
|
259
|
+
domainWarpStrength: "How much the color field swirls.",
|
|
260
|
+
fractalOctaveCount: "More octaves add finer detail.",
|
|
261
|
+
colorTransitionContrast: "Sharpness of the edges between colors.",
|
|
262
|
+
grainOpacity: "Strength of the film-grain overlay."
|
|
263
|
+
};
|
|
264
|
+
var defaultLook = {
|
|
265
|
+
baseNoiseFrequency: 0.3,
|
|
266
|
+
domainWarpStrength: 1,
|
|
267
|
+
fractalOctaveCount: 2,
|
|
268
|
+
colorTransitionContrast: 1.75,
|
|
269
|
+
grainOpacity: 0.09
|
|
270
|
+
};
|
|
271
|
+
var DEFAULT_WIDTH = 1200;
|
|
272
|
+
var DEFAULT_HEIGHT = 1500;
|
|
273
|
+
var DEFAULT_WEBP_QUALITY = 82;
|
|
274
|
+
function hexToRgb2(hex) {
|
|
275
|
+
const packed = parseInt(hex.slice(1), 16);
|
|
276
|
+
return [packed >> 16 & 255, packed >> 8 & 255, packed & 255];
|
|
277
|
+
}
|
|
278
|
+
function sampleColorRamp(rgbStops, position) {
|
|
279
|
+
const clamped = Math.max(0, Math.min(1, position));
|
|
280
|
+
const scaled = clamped * (rgbStops.length - 1);
|
|
281
|
+
const lowerIndex = Math.min(rgbStops.length - 2, Math.floor(scaled));
|
|
282
|
+
const blend = scaled - lowerIndex;
|
|
283
|
+
const [lowR, lowG, lowB] = rgbStops[lowerIndex];
|
|
284
|
+
const [highR, highG, highB] = rgbStops[lowerIndex + 1];
|
|
285
|
+
return [
|
|
286
|
+
lowR + (highR - lowR) * blend,
|
|
287
|
+
lowG + (highG - lowG) * blend,
|
|
288
|
+
lowB + (highB - lowB) * blend
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
var smoothStep = (edge) => edge * edge * (3 - 2 * edge);
|
|
292
|
+
async function renderCanvas(options) {
|
|
293
|
+
const { seed, palette, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT } = options;
|
|
294
|
+
const look = { ...defaultLook, ...options.look };
|
|
295
|
+
const random = createSeededRandom(hashStringToInt(seed));
|
|
296
|
+
const rgbStops = palette.colors.map(hexToRgb2);
|
|
297
|
+
const horizontalWarpNoise = createNoise2D(random);
|
|
298
|
+
const verticalWarpNoise = createNoise2D(random);
|
|
299
|
+
const colorFieldNoise = createNoise2D(random);
|
|
300
|
+
const sampleFractalNoise = (noise, sampleX, sampleY) => {
|
|
301
|
+
let total = 0;
|
|
302
|
+
let amplitude = 0.5;
|
|
303
|
+
let frequency = 1;
|
|
304
|
+
for (let octave = 0; octave < look.fractalOctaveCount; octave++) {
|
|
305
|
+
total += amplitude * noise(sampleX * frequency, sampleY * frequency);
|
|
306
|
+
amplitude *= 0.5;
|
|
307
|
+
frequency *= 2;
|
|
308
|
+
}
|
|
309
|
+
return total;
|
|
310
|
+
};
|
|
311
|
+
const canvas = createCanvas(width, height);
|
|
312
|
+
const context = canvas.getContext("2d");
|
|
313
|
+
const image = context.createImageData(width, height);
|
|
314
|
+
const pixels = image.data;
|
|
315
|
+
const aspectRatio = width / height;
|
|
316
|
+
for (let pixelY = 0; pixelY < height; pixelY++) {
|
|
317
|
+
for (let pixelX = 0; pixelX < width; pixelX++) {
|
|
318
|
+
const sampleX = pixelX / width * look.baseNoiseFrequency * aspectRatio;
|
|
319
|
+
const sampleY = pixelY / height * look.baseNoiseFrequency;
|
|
320
|
+
const horizontalWarpOffset = sampleFractalNoise(horizontalWarpNoise, sampleX, sampleY);
|
|
321
|
+
const verticalWarpOffset = sampleFractalNoise(verticalWarpNoise, sampleX + 5.2, sampleY + 1.3);
|
|
322
|
+
const rawFieldValue = sampleFractalNoise(
|
|
323
|
+
colorFieldNoise,
|
|
324
|
+
sampleX + look.domainWarpStrength * horizontalWarpOffset,
|
|
325
|
+
sampleY + look.domainWarpStrength * verticalWarpOffset
|
|
326
|
+
);
|
|
327
|
+
const normalizedValue = (rawFieldValue + 1) / 2;
|
|
328
|
+
const contrastedValue = smoothStep(
|
|
329
|
+
Math.max(0, Math.min(1, (normalizedValue - 0.5) * look.colorTransitionContrast + 0.5))
|
|
330
|
+
);
|
|
331
|
+
const [red, green, blue] = sampleColorRamp(rgbStops, contrastedValue);
|
|
332
|
+
const pixelIndex = (pixelY * width + pixelX) * 4;
|
|
333
|
+
pixels[pixelIndex] = red;
|
|
334
|
+
pixels[pixelIndex + 1] = green;
|
|
335
|
+
pixels[pixelIndex + 2] = blue;
|
|
336
|
+
pixels[pixelIndex + 3] = 255;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
context.putImageData(image, 0, 0);
|
|
340
|
+
const grainPixels = new Uint8ClampedArray(width * height * 4);
|
|
341
|
+
for (let i = 0; i < width * height; i++) {
|
|
342
|
+
const brightness = Math.floor(random() * 256);
|
|
343
|
+
grainPixels[i * 4] = brightness;
|
|
344
|
+
grainPixels[i * 4 + 1] = brightness;
|
|
345
|
+
grainPixels[i * 4 + 2] = brightness;
|
|
346
|
+
grainPixels[i * 4 + 3] = 255;
|
|
347
|
+
}
|
|
348
|
+
const grainCanvas = createCanvas(width, height);
|
|
349
|
+
grainCanvas.getContext("2d").putImageData(new ImageData(grainPixels, width, height), 0, 0);
|
|
350
|
+
applyOverlay(context, grainCanvas, look.grainOpacity);
|
|
351
|
+
return { canvas, palette, accent: paletteAccent(palette), complement: paletteComplement(palette) };
|
|
352
|
+
}
|
|
353
|
+
function applyOverlay(context, overlay, opacity) {
|
|
354
|
+
context.save();
|
|
355
|
+
context.globalAlpha = opacity;
|
|
356
|
+
context.globalCompositeOperation = "overlay";
|
|
357
|
+
context.drawImage(overlay, 0, 0);
|
|
358
|
+
context.restore();
|
|
359
|
+
}
|
|
360
|
+
async function generateImage(options) {
|
|
361
|
+
const { format = "webp", quality = DEFAULT_WEBP_QUALITY } = options;
|
|
362
|
+
const { canvas, palette, accent, complement } = await renderCanvas(options);
|
|
363
|
+
const pngBuffer = canvas.encodeSync("png");
|
|
364
|
+
let buffer = pngBuffer;
|
|
365
|
+
if (format === "webp") {
|
|
366
|
+
buffer = await sharp(pngBuffer).webp({ quality }).toBuffer();
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
buffer,
|
|
370
|
+
format,
|
|
371
|
+
width: canvas.width,
|
|
372
|
+
height: canvas.height,
|
|
373
|
+
seed: options.seed,
|
|
374
|
+
palette,
|
|
375
|
+
accent,
|
|
376
|
+
complement
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/flags.ts
|
|
381
|
+
var positiveInteger = (flag) => (raw) => {
|
|
382
|
+
const value = Number(raw);
|
|
383
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
384
|
+
throw new Error(`--${flag} must be a positive integer. Got: ${raw}`);
|
|
385
|
+
}
|
|
386
|
+
return value;
|
|
387
|
+
};
|
|
388
|
+
var finiteNumber = (flag) => (raw) => {
|
|
389
|
+
const value = Number(raw);
|
|
390
|
+
if (!Number.isFinite(value)) throw new Error(`--${flag} must be a number. Got: ${raw}`);
|
|
391
|
+
return value;
|
|
392
|
+
};
|
|
393
|
+
var optionFlags = {
|
|
394
|
+
seed: {
|
|
395
|
+
flag: "seed",
|
|
396
|
+
placeholder: "<string>",
|
|
397
|
+
describe: "Seed string. Same seed \u2192 same image. Default: random.",
|
|
398
|
+
parse: (raw) => raw
|
|
399
|
+
},
|
|
400
|
+
palette: {
|
|
401
|
+
flag: "palette",
|
|
402
|
+
placeholder: "<name>",
|
|
403
|
+
describe: "Built-in palette name. Default: a deterministic pick from the seed.",
|
|
404
|
+
parse: (raw) => raw
|
|
405
|
+
},
|
|
406
|
+
width: {
|
|
407
|
+
flag: "width",
|
|
408
|
+
placeholder: "<px>",
|
|
409
|
+
describe: `Output width. Default ${DEFAULT_WIDTH}.`,
|
|
410
|
+
parse: positiveInteger("width")
|
|
411
|
+
},
|
|
412
|
+
height: {
|
|
413
|
+
flag: "height",
|
|
414
|
+
placeholder: "<px>",
|
|
415
|
+
describe: `Output height. Default ${DEFAULT_HEIGHT}.`,
|
|
416
|
+
parse: positiveInteger("height")
|
|
417
|
+
},
|
|
418
|
+
quality: {
|
|
419
|
+
flag: "quality",
|
|
420
|
+
placeholder: "<1-100>",
|
|
421
|
+
describe: `WebP quality. Default ${DEFAULT_WEBP_QUALITY}.`,
|
|
422
|
+
parse: positiveInteger("quality")
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
var lookFlags = {
|
|
426
|
+
baseNoiseFrequency: {
|
|
427
|
+
flag: "base-noise-frequency",
|
|
428
|
+
placeholder: "<n>",
|
|
429
|
+
describe: `${lookDescriptions.baseNoiseFrequency} Default ${defaultLook.baseNoiseFrequency}.`,
|
|
430
|
+
parse: finiteNumber("base-noise-frequency")
|
|
431
|
+
},
|
|
432
|
+
domainWarpStrength: {
|
|
433
|
+
flag: "domain-warp-strength",
|
|
434
|
+
placeholder: "<n>",
|
|
435
|
+
describe: `${lookDescriptions.domainWarpStrength} Default ${defaultLook.domainWarpStrength}.`,
|
|
436
|
+
parse: finiteNumber("domain-warp-strength")
|
|
437
|
+
},
|
|
438
|
+
fractalOctaveCount: {
|
|
439
|
+
flag: "fractal-octave-count",
|
|
440
|
+
placeholder: "<n>",
|
|
441
|
+
describe: `${lookDescriptions.fractalOctaveCount} Default ${defaultLook.fractalOctaveCount}.`,
|
|
442
|
+
parse: positiveInteger("fractal-octave-count")
|
|
443
|
+
},
|
|
444
|
+
colorTransitionContrast: {
|
|
445
|
+
flag: "color-transition-contrast",
|
|
446
|
+
placeholder: "<n>",
|
|
447
|
+
describe: `${lookDescriptions.colorTransitionContrast} Default ${defaultLook.colorTransitionContrast}.`,
|
|
448
|
+
parse: finiteNumber("color-transition-contrast")
|
|
449
|
+
},
|
|
450
|
+
grainOpacity: {
|
|
451
|
+
flag: "grain-opacity",
|
|
452
|
+
placeholder: "<0-1>",
|
|
453
|
+
describe: `${lookDescriptions.grainOpacity} Default ${defaultLook.grainOpacity}.`,
|
|
454
|
+
parse: finiteNumber("grain-opacity")
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var cliOnlyFlags = [
|
|
458
|
+
{
|
|
459
|
+
flag: "out",
|
|
460
|
+
placeholder: "<path>",
|
|
461
|
+
describe: "Output file, .webp or .png. Default: output/<palette>-<seed>.webp"
|
|
462
|
+
},
|
|
463
|
+
{ flag: "json", placeholder: "", describe: "Print result metadata as JSON." },
|
|
464
|
+
{ flag: "list-palettes", placeholder: "", describe: "Print built-in palette names and exit." },
|
|
465
|
+
{ flag: "help", placeholder: "", describe: "Show this help (-h)." }
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
// src/create.ts
|
|
469
|
+
function createMeshGradients(options) {
|
|
470
|
+
const { palettes, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT } = options;
|
|
471
|
+
validatePalettes(palettes);
|
|
472
|
+
validateDimension("width", width);
|
|
473
|
+
validateDimension("height", height);
|
|
474
|
+
const resolve = (seed, palette) => {
|
|
475
|
+
if (palette === void 0) return pickPalette(seed, palettes);
|
|
476
|
+
if (typeof palette === "string") return findPalette(palette, palettes);
|
|
477
|
+
validatePalette(palette);
|
|
478
|
+
return palette;
|
|
479
|
+
};
|
|
480
|
+
const resolveSize = (size) => {
|
|
481
|
+
const resolved = { width: size.width ?? width, height: size.height ?? height };
|
|
482
|
+
validateDimension("width", resolved.width);
|
|
483
|
+
validateDimension("height", resolved.height);
|
|
484
|
+
return resolved;
|
|
485
|
+
};
|
|
486
|
+
return {
|
|
487
|
+
palettes,
|
|
488
|
+
findPalette: (name) => findPalette(name, palettes),
|
|
489
|
+
pickPalette: (seed) => pickPalette(seed, palettes),
|
|
490
|
+
// async so an invalid palette rejects like every other render error.
|
|
491
|
+
render: async (renderOptions) => renderCanvas({
|
|
492
|
+
...renderOptions,
|
|
493
|
+
...resolveSize(renderOptions),
|
|
494
|
+
palette: resolve(renderOptions.seed, renderOptions.palette)
|
|
495
|
+
}),
|
|
496
|
+
generate: async (generateOptions) => generateImage({
|
|
497
|
+
...generateOptions,
|
|
498
|
+
...resolveSize(generateOptions),
|
|
499
|
+
palette: resolve(generateOptions.seed, generateOptions.palette)
|
|
500
|
+
})
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function validateDimension(label, value) {
|
|
504
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
505
|
+
throw new Error(`${label} must be a positive integer. Got ${value}.`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/index.ts
|
|
510
|
+
var builtIn = createMeshGradients({ palettes: colorPalettes });
|
|
511
|
+
var generateMeshGradient = builtIn.generate;
|
|
512
|
+
var renderMeshGradient = builtIn.render;
|
|
513
|
+
|
|
514
|
+
// src/cli.ts
|
|
515
|
+
var allFlags = [...Object.values(optionFlags), ...Object.values(lookFlags)];
|
|
516
|
+
var helpText = (() => {
|
|
517
|
+
const width = Math.max(
|
|
518
|
+
...[...allFlags, ...cliOnlyFlags].map((f) => `--${f.flag} ${f.placeholder}`.trimEnd().length)
|
|
519
|
+
);
|
|
520
|
+
const line = (f) => ` ${`--${f.flag} ${f.placeholder}`.trimEnd().padEnd(width)} ${f.describe}`;
|
|
521
|
+
return `Usage: fluid-gradient [options]
|
|
522
|
+
|
|
523
|
+
Every flag is optional. With none, writes one WebP with a random seed and a
|
|
524
|
+
palette picked from that seed.
|
|
525
|
+
|
|
526
|
+
Options:
|
|
527
|
+
${allFlags.map(line).join("\n")}
|
|
528
|
+
|
|
529
|
+
Output & info:
|
|
530
|
+
${cliOnlyFlags.map(line).join("\n")}
|
|
531
|
+
`;
|
|
532
|
+
})();
|
|
533
|
+
var parseOptions = {
|
|
534
|
+
...Object.fromEntries(allFlags.map((f) => [f.flag, { type: "string" }])),
|
|
535
|
+
out: { type: "string" },
|
|
536
|
+
json: { type: "boolean" },
|
|
537
|
+
"list-palettes": { type: "boolean" },
|
|
538
|
+
help: { type: "boolean", short: "h" }
|
|
539
|
+
};
|
|
540
|
+
async function runCli(argv) {
|
|
541
|
+
const values = parseArgs({ args: argv, options: parseOptions }).values;
|
|
542
|
+
const raw = (flag) => values[flag];
|
|
543
|
+
if (values.help) {
|
|
544
|
+
console.log(helpText);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (values["list-palettes"]) {
|
|
548
|
+
for (const palette2 of colorPalettes) {
|
|
549
|
+
console.log(`${palette2.name} ${palette2.colors.join(" ")}`);
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const read = (key) => {
|
|
554
|
+
const spec = optionFlags[key];
|
|
555
|
+
const value = raw(spec.flag);
|
|
556
|
+
return value === void 0 ? void 0 : spec.parse(value);
|
|
557
|
+
};
|
|
558
|
+
const look = {};
|
|
559
|
+
for (const [key, spec] of Object.entries(lookFlags)) {
|
|
560
|
+
const value = raw(spec.flag);
|
|
561
|
+
if (value !== void 0) look[key] = spec.parse(value);
|
|
562
|
+
}
|
|
563
|
+
const seed = read("seed") ?? randomBytes(4).toString("hex");
|
|
564
|
+
const paletteName = read("palette");
|
|
565
|
+
const palette = paletteName === void 0 ? pickPalette(seed) : findPalette(paletteName);
|
|
566
|
+
const out = raw("out") ?? join("output", `${palette.name}-${seed}.webp`);
|
|
567
|
+
const extension = extname(out).toLowerCase();
|
|
568
|
+
if (extension !== ".webp" && extension !== ".png") {
|
|
569
|
+
throw new Error(`--out must end in .webp or .png. Got: ${out}`);
|
|
570
|
+
}
|
|
571
|
+
const result = await generateMeshGradient({
|
|
572
|
+
seed,
|
|
573
|
+
palette,
|
|
574
|
+
width: read("width"),
|
|
575
|
+
height: read("height"),
|
|
576
|
+
quality: read("quality"),
|
|
577
|
+
format: extension === ".png" ? "png" : "webp",
|
|
578
|
+
...Object.keys(look).length > 0 ? { look } : {}
|
|
579
|
+
});
|
|
580
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
581
|
+
writeFileSync(out, result.buffer);
|
|
582
|
+
if (values.json) {
|
|
583
|
+
const { buffer, palette: used, ...meta } = result;
|
|
584
|
+
console.log(
|
|
585
|
+
JSON.stringify({ ...meta, palette: used.name, out, bytes: buffer.length })
|
|
586
|
+
);
|
|
587
|
+
} else {
|
|
588
|
+
console.log(`\u2713 ${out} (${(result.buffer.length / 1024).toFixed(2)} KB)`);
|
|
589
|
+
console.log(` seed: ${result.seed}`);
|
|
590
|
+
console.log(` palette: ${result.palette.name} (accent: ${result.accent}, complement: ${result.complement})`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
var invokedPath = process.argv[1];
|
|
594
|
+
if (invokedPath && realpathSync(invokedPath) === fileURLToPath(import.meta.url)) {
|
|
595
|
+
runCli(process.argv.slice(2)).catch((err) => {
|
|
596
|
+
console.error(`Error: ${err.message}`);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
export {
|
|
601
|
+
helpText,
|
|
602
|
+
runCli
|
|
603
|
+
};
|
|
604
|
+
//# sourceMappingURL=cli.js.map
|