@dooph-software/fluid-gradients 1.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -12
- package/dist/{chunk-KYCZHKFE.js → chunk-OGG3CQZ4.js} +32 -2
- package/dist/{chunk-KYCZHKFE.js.map → chunk-OGG3CQZ4.js.map} +1 -1
- package/dist/{chunk-CRRF2Q64.cjs → chunk-VF6TCELO.cjs} +32 -2
- package/dist/chunk-VF6TCELO.cjs.map +1 -0
- package/dist/cli.js +394 -43
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +280 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.js +273 -18
- package/dist/index.js.map +1 -1
- package/dist/palettes.cjs +2 -2
- package/dist/palettes.cjs.map +1 -1
- package/dist/palettes.d.cts +72 -0
- package/dist/palettes.d.ts +72 -0
- package/dist/palettes.js +1 -1
- package/package.json +3 -2
- package/skills/using-fluid-gradients/SKILL.md +3 -2
- package/dist/chunk-CRRF2Q64.cjs.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { randomBytes } from "crypto";
|
|
4
|
+
import { randomBytes, randomInt } from "crypto";
|
|
5
5
|
import { mkdirSync, realpathSync, writeFileSync } from "fs";
|
|
6
6
|
import { dirname, extname, join } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
@@ -10,8 +10,257 @@ import { parseArgs } from "util";
|
|
|
10
10
|
// src/render.ts
|
|
11
11
|
import { createCanvas, ImageData } from "@napi-rs/canvas";
|
|
12
12
|
import sharp from "sharp";
|
|
13
|
+
import { createNoise2D as createNoise2D2 } from "simplex-noise";
|
|
14
|
+
|
|
15
|
+
// src/sweep.ts
|
|
13
16
|
import { createNoise2D } from "simplex-noise";
|
|
14
17
|
|
|
18
|
+
// src/random.ts
|
|
19
|
+
function hashStringToInt(seed) {
|
|
20
|
+
let hash = 2166136261 >>> 0;
|
|
21
|
+
for (let i = 0; i < seed.length; i++) {
|
|
22
|
+
hash ^= seed.charCodeAt(i);
|
|
23
|
+
hash = Math.imul(hash, 16777619);
|
|
24
|
+
}
|
|
25
|
+
return hash >>> 0;
|
|
26
|
+
}
|
|
27
|
+
function createSeededRandom(seedInt) {
|
|
28
|
+
let state = seedInt;
|
|
29
|
+
return function nextRandom() {
|
|
30
|
+
state |= 0;
|
|
31
|
+
state = state + 1831565813 | 0;
|
|
32
|
+
let mixed = Math.imul(state ^ state >>> 15, 1 | state);
|
|
33
|
+
mixed = mixed + Math.imul(mixed ^ mixed >>> 7, 61 | mixed) ^ mixed;
|
|
34
|
+
return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/sweep.ts
|
|
39
|
+
var tuning = {
|
|
40
|
+
workHeight: 400,
|
|
41
|
+
velocityFrequency: 0.4,
|
|
42
|
+
/** Velocity grid cells per image height. */
|
|
43
|
+
velocityResolution: 80,
|
|
44
|
+
/** Travel at sweep 1, measured at drift speed. */
|
|
45
|
+
advectDistance: 1.6,
|
|
46
|
+
advectSteps: 32,
|
|
47
|
+
/** Drift speed relative to the current's RMS speed. */
|
|
48
|
+
drift: 1,
|
|
49
|
+
/** Eddy speed cap, relative to the drift. Two fields mixed at most √2 ×
|
|
50
|
+
* this, which must stay below 1 or eddies can close. */
|
|
51
|
+
eddyCap: 0.65,
|
|
52
|
+
/** Radians the current turns between its two fields over the trace. */
|
|
53
|
+
timeSpin: 1.2,
|
|
54
|
+
defocus: 8e-3,
|
|
55
|
+
smearLength: 0.5,
|
|
56
|
+
smearSteps: 40,
|
|
57
|
+
/** Fraction of the exposure at full weight before the window tapers. */
|
|
58
|
+
smearFlat: 0.6,
|
|
59
|
+
groundSoftness: 0.35,
|
|
60
|
+
groundFieldWeight: 1,
|
|
61
|
+
groundNoiseWeight: 0.6,
|
|
62
|
+
groundBlur: 0.02,
|
|
63
|
+
groundMaxCover: 0.9,
|
|
64
|
+
groundFrequency: 0.9
|
|
65
|
+
};
|
|
66
|
+
var smoothStep = (edge) => edge * edge * (3 - 2 * edge);
|
|
67
|
+
var clamp01 = (value) => Math.max(0, Math.min(1, value));
|
|
68
|
+
var fractal = (noise, x, y) => 0.5 * noise(x, y) + 0.25 * noise(x * 2, y * 2);
|
|
69
|
+
var Grid = class {
|
|
70
|
+
constructor(width, height) {
|
|
71
|
+
this.width = width;
|
|
72
|
+
this.height = height;
|
|
73
|
+
this.values = new Float32Array(width * height);
|
|
74
|
+
}
|
|
75
|
+
width;
|
|
76
|
+
height;
|
|
77
|
+
values;
|
|
78
|
+
sample(x, y) {
|
|
79
|
+
const gx = Math.max(0, Math.min(this.width - 1.0001, x));
|
|
80
|
+
const gy = Math.max(0, Math.min(this.height - 1.0001, y));
|
|
81
|
+
const x0 = Math.floor(gx);
|
|
82
|
+
const y0 = Math.floor(gy);
|
|
83
|
+
const fx = gx - x0;
|
|
84
|
+
const fy = gy - y0;
|
|
85
|
+
const v = this.values;
|
|
86
|
+
const i = y0 * this.width + x0;
|
|
87
|
+
const top = v[i] + (v[i + 1] - v[i]) * fx;
|
|
88
|
+
const bottom = v[i + this.width] + (v[i + this.width + 1] - v[i + this.width]) * fx;
|
|
89
|
+
return top + (bottom - top) * fy;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
function applySweep(input) {
|
|
93
|
+
const { seed, width, height, pixels, rampPositionAt, rgbStops, sampleRamp, sweep, ground } = input;
|
|
94
|
+
const t = tuning;
|
|
95
|
+
const random = createSeededRandom(hashStringToInt(`${seed}::sweep`));
|
|
96
|
+
const currentNoiseA = createNoise2D(random);
|
|
97
|
+
const currentNoiseB = createNoise2D(random);
|
|
98
|
+
const coverageNoise = createNoise2D(random);
|
|
99
|
+
const coverageWarpNoise = createNoise2D(random);
|
|
100
|
+
const driftAngle = random() * Math.PI * 2;
|
|
101
|
+
const scale = Math.min(1, t.workHeight / height);
|
|
102
|
+
const workW = Math.max(2, Math.round(width * scale));
|
|
103
|
+
const workH = Math.max(2, Math.round(height * scale));
|
|
104
|
+
const aspect = width / height;
|
|
105
|
+
const driftX = Math.cos(driftAngle) * t.drift;
|
|
106
|
+
const driftY = Math.sin(driftAngle) * t.drift;
|
|
107
|
+
const margin = 2 * t.advectDistance * sweep + 0.1;
|
|
108
|
+
const cell = 1 / t.velocityResolution;
|
|
109
|
+
const velW = Math.ceil((aspect + 2 * margin) / cell) + 2;
|
|
110
|
+
const velH = Math.ceil((1 + 2 * margin) / cell) + 2;
|
|
111
|
+
const currents = [currentNoiseA, currentNoiseB].map((noise) => {
|
|
112
|
+
const vx = new Grid(velW, velH);
|
|
113
|
+
const vy = new Grid(velW, velH);
|
|
114
|
+
const f = t.velocityFrequency;
|
|
115
|
+
const e = 2e-3;
|
|
116
|
+
let total = 0;
|
|
117
|
+
for (let gy = 0; gy < velH; gy++) {
|
|
118
|
+
for (let gx = 0; gx < velW; gx++) {
|
|
119
|
+
const u = (gx * cell - margin) * f;
|
|
120
|
+
const v = (gy * cell - margin) * f;
|
|
121
|
+
const i = gy * velW + gx;
|
|
122
|
+
vx.values[i] = (fractal(noise, u, v + e) - fractal(noise, u, v - e)) / (2 * e);
|
|
123
|
+
vy.values[i] = -(fractal(noise, u + e, v) - fractal(noise, u - e, v)) / (2 * e);
|
|
124
|
+
total += vx.values[i] ** 2 + vy.values[i] ** 2;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const rms = Math.sqrt(total / vx.values.length) || 1;
|
|
128
|
+
const cap = t.eddyCap * t.drift;
|
|
129
|
+
for (let i = 0; i < vx.values.length; i++) {
|
|
130
|
+
const speed = Math.hypot(vx.values[i], vy.values[i]) / rms + 1e-9;
|
|
131
|
+
const limited = cap * Math.tanh(speed / cap) / speed / rms;
|
|
132
|
+
vx.values[i] *= limited;
|
|
133
|
+
vy.values[i] *= limited;
|
|
134
|
+
}
|
|
135
|
+
return { vx, vy };
|
|
136
|
+
});
|
|
137
|
+
const velocity = (u, v, phase, out) => {
|
|
138
|
+
const gx = (u + margin) / cell;
|
|
139
|
+
const gy = (v + margin) / cell;
|
|
140
|
+
const a = Math.cos(phase);
|
|
141
|
+
const b = Math.sin(phase);
|
|
142
|
+
out[0] = a * currents[0].vx.sample(gx, gy) + b * currents[1].vx.sample(gx, gy) + driftX;
|
|
143
|
+
out[1] = a * currents[0].vy.sample(gx, gy) + b * currents[1].vy.sample(gx, gy) + driftY;
|
|
144
|
+
};
|
|
145
|
+
const flowX = new Grid(workW, workH);
|
|
146
|
+
const flowY = new Grid(workW, workH);
|
|
147
|
+
const fieldSource = new Grid(workW, workH);
|
|
148
|
+
const sourceU = new Float32Array(workW * workH);
|
|
149
|
+
const sourceV = new Float32Array(workW * workH);
|
|
150
|
+
const steps = t.advectSteps;
|
|
151
|
+
const dt = sweep * t.advectDistance / t.drift / steps;
|
|
152
|
+
const k1 = [0, 0];
|
|
153
|
+
const k2 = [0, 0];
|
|
154
|
+
for (let y = 0; y < workH; y++) {
|
|
155
|
+
for (let x = 0; x < workW; x++) {
|
|
156
|
+
let u = x / workH;
|
|
157
|
+
let v = y / workH;
|
|
158
|
+
const i = y * workW + x;
|
|
159
|
+
velocity(u, v, 0, k1);
|
|
160
|
+
flowX.values[i] = k1[0];
|
|
161
|
+
flowY.values[i] = k1[1];
|
|
162
|
+
for (let s = 0; s < steps; s++) {
|
|
163
|
+
const phase = t.timeSpin * s / steps;
|
|
164
|
+
velocity(u, v, phase, k1);
|
|
165
|
+
velocity(u - k1[0] * dt / 2, v - k1[1] * dt / 2, phase, k2);
|
|
166
|
+
u -= k2[0] * dt;
|
|
167
|
+
v -= k2[1] * dt;
|
|
168
|
+
}
|
|
169
|
+
sourceU[i] = u;
|
|
170
|
+
sourceV[i] = v;
|
|
171
|
+
fieldSource.values[i] = rampPositionAt(u, v);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
boxBlur(fieldSource.values, workW, workH, Math.max(1, Math.round(t.defocus * sweep * workH)), 3);
|
|
175
|
+
const coverageSource = new Grid(workW, workH);
|
|
176
|
+
if (ground > 0) {
|
|
177
|
+
for (let y = 0; y < workH; y++) {
|
|
178
|
+
for (let x = 0; x < workW; x++) {
|
|
179
|
+
const i = y * workW + x;
|
|
180
|
+
const su = sourceU[i] * t.groundFrequency;
|
|
181
|
+
const sv = sourceV[i] * t.groundFrequency;
|
|
182
|
+
const warpX = fractal(coverageWarpNoise, su, sv);
|
|
183
|
+
const warpY = fractal(coverageWarpNoise, su + 5.2, sv + 1.3);
|
|
184
|
+
coverageSource.values[i] = t.groundFieldWeight * fieldSource.values[i] + t.groundNoiseWeight * fractal(coverageNoise, su + warpX, sv + warpY);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const sorted = Float32Array.from(coverageSource.values).sort();
|
|
188
|
+
const threshold = sorted[Math.min(sorted.length - 1, Math.floor(ground * t.groundMaxCover * sorted.length))];
|
|
189
|
+
for (let i = 0; i < coverageSource.values.length; i++) coverageSource.values[i] -= threshold;
|
|
190
|
+
boxBlur(coverageSource.values, workW, workH, Math.max(1, Math.round(t.groundBlur * workH)), 3);
|
|
191
|
+
}
|
|
192
|
+
const smearedField = new Grid(workW, workH);
|
|
193
|
+
const smearedCoverage = new Grid(workW, workH);
|
|
194
|
+
const smearSteps = t.smearSteps;
|
|
195
|
+
const step = sweep * t.smearLength * workH / 2 / Math.hypot(1, t.drift) / smearSteps;
|
|
196
|
+
const weights = new Float32Array(smearSteps + 1);
|
|
197
|
+
for (let s = 0; s <= smearSteps; s++) {
|
|
198
|
+
const along = s / (smearSteps + 1);
|
|
199
|
+
weights[s] = along <= t.smearFlat ? 1 : 0.5 + 0.5 * Math.cos(Math.PI * (along - t.smearFlat) / (1 - t.smearFlat));
|
|
200
|
+
}
|
|
201
|
+
for (let y = 0; y < workH; y++) {
|
|
202
|
+
for (let x = 0; x < workW; x++) {
|
|
203
|
+
let fieldTotal = weights[0] * fieldSource.sample(x, y);
|
|
204
|
+
let coverageTotal = ground > 0 ? weights[0] * coverageSource.sample(x, y) : 0;
|
|
205
|
+
let weightTotal = weights[0];
|
|
206
|
+
for (let direction = -1; direction <= 1; direction += 2) {
|
|
207
|
+
let px = x;
|
|
208
|
+
let py = y;
|
|
209
|
+
for (let s = 1; s <= smearSteps; s++) {
|
|
210
|
+
const mx = px + direction * flowX.sample(px, py) * step / 2;
|
|
211
|
+
const my = py + direction * flowY.sample(px, py) * step / 2;
|
|
212
|
+
px += direction * flowX.sample(mx, my) * step;
|
|
213
|
+
py += direction * flowY.sample(mx, my) * step;
|
|
214
|
+
if (px < 0 || py < 0 || px > workW - 1 || py > workH - 1) break;
|
|
215
|
+
const w = weights[s];
|
|
216
|
+
fieldTotal += w * fieldSource.sample(px, py);
|
|
217
|
+
if (ground > 0) coverageTotal += w * coverageSource.sample(px, py);
|
|
218
|
+
weightTotal += w;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
smearedField.values[y * workW + x] = fieldTotal / weightTotal;
|
|
222
|
+
smearedCoverage.values[y * workW + x] = coverageTotal / weightTotal;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const groundColor = rgbStops[0];
|
|
226
|
+
for (let py = 0; py < height; py++) {
|
|
227
|
+
for (let px = 0; px < width; px++) {
|
|
228
|
+
const sx = (px + 0.5) * scale - 0.5;
|
|
229
|
+
const sy = (py + 0.5) * scale - 0.5;
|
|
230
|
+
let [r, g, b] = sampleRamp(smearedField.sample(sx, sy));
|
|
231
|
+
if (ground > 0) {
|
|
232
|
+
const m = smoothStep(clamp01(smearedCoverage.sample(sx, sy) / t.groundSoftness + 0.5));
|
|
233
|
+
r = groundColor[0] + (r - groundColor[0]) * m;
|
|
234
|
+
g = groundColor[1] + (g - groundColor[1]) * m;
|
|
235
|
+
b = groundColor[2] + (b - groundColor[2]) * m;
|
|
236
|
+
}
|
|
237
|
+
const o = (py * width + px) * 4;
|
|
238
|
+
pixels[o] = r;
|
|
239
|
+
pixels[o + 1] = g;
|
|
240
|
+
pixels[o + 2] = b;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function boxBlur(values, width, height, radius, passes) {
|
|
245
|
+
const scratch = new Float32Array(values.length);
|
|
246
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
247
|
+
for (let y = 0; y < height; y++) {
|
|
248
|
+
for (let x = 0; x < width; x++) {
|
|
249
|
+
let total = 0;
|
|
250
|
+
for (let k = -radius; k <= radius; k++) total += values[y * width + Math.max(0, Math.min(width - 1, x + k))];
|
|
251
|
+
scratch[y * width + x] = total / (2 * radius + 1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (let y = 0; y < height; y++) {
|
|
255
|
+
for (let x = 0; x < width; x++) {
|
|
256
|
+
let total = 0;
|
|
257
|
+
for (let k = -radius; k <= radius; k++) total += scratch[Math.max(0, Math.min(height - 1, y + k)) * width + x];
|
|
258
|
+
values[y * width + x] = total / (2 * radius + 1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
15
264
|
// src/color.ts
|
|
16
265
|
var ACHROMATIC_CHROMA = 0.01;
|
|
17
266
|
var HUE_TRUST_CHROMA = 0.03;
|
|
@@ -92,26 +341,6 @@ function paletteComplementColor(accent, ramp) {
|
|
|
92
341
|
return Math.hypot(meanA, meanB) < ACHROMATIC_CHROMA / 2 ? oppositeGray(L) : vividOpposite(meanA, meanB);
|
|
93
342
|
}
|
|
94
343
|
|
|
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
344
|
// src/palettes.ts
|
|
116
345
|
var MIN_PALETTE_COLORS = 3;
|
|
117
346
|
var MAX_PALETTE_COLORS = 5;
|
|
@@ -190,13 +419,43 @@ var colorPalettes = [
|
|
|
190
419
|
{ name: "onyx-snow", colors: ["#ffffff", "#b0b0b0", "#4a4a4a", "#000000"] },
|
|
191
420
|
{ name: "midnight-ember", colors: ["#f0a468", "#c23f1f", "#2a1410", "#0a0a0c"] },
|
|
192
421
|
{ name: "slate-harbor", colors: ["#aeb7bd", "#6f7b84", "#3c454c", "#1a1f24"] },
|
|
422
|
+
{ name: "ember-mono", colors: ["#ff9a6b", "#cf5604", "#632400", "#180501"] },
|
|
423
|
+
// ── Monochromatic: one hue, lightness stepping down (OKLCH-spaced) ──
|
|
424
|
+
{ name: "tangerine-mono", colors: ["#ffeee4", "#feb079", "#dc7100", "#824103"] },
|
|
425
|
+
{ name: "electric-blue-mono", colors: ["#ebf2fe", "#a4c6fc", "#5d91e7", "#2a5397"] },
|
|
426
|
+
{ name: "magenta-mono", colors: ["#feecf5", "#edaed1", "#cd6ca6", "#823564"] },
|
|
427
|
+
{ name: "emerald-mono", colors: ["#e2f8ea", "#95d5af", "#2cab70", "#03663e"] },
|
|
428
|
+
{ name: "violet-mono", colors: ["#f2f0fe", "#c8b7ff", "#9e71fd", "#5f38a7"] },
|
|
429
|
+
{ name: "crimson-mono", colors: ["#ffedec", "#feaaa9", "#f44f5d", "#9e122b"] },
|
|
430
|
+
{ name: "aqua-mono", colors: ["#dff7f8", "#8dd2d6", "#39a4a9", "#026266"] },
|
|
431
|
+
{ name: "burgundy-mono", colors: ["#fbe0e4", "#dc9ea8", "#a7415a", "#560c24"] },
|
|
432
|
+
{ name: "indigo-mono", colors: ["#eaeefe", "#a7b2f7", "#5a5ce2", "#261b81"] },
|
|
433
|
+
{ name: "lime-mono", colors: ["#edfcd7", "#c2e87d", "#9fc93e", "#638119"] },
|
|
434
|
+
// ── Monochromatic earth tones: same idea, chroma held up so they stay
|
|
435
|
+
// clay / ochre / moss rather than collapsing to greige ──
|
|
436
|
+
{ name: "terracotta-mono", colors: ["#fdeae4", "#f6b19b", "#c5694c", "#73311b"] },
|
|
437
|
+
{ name: "ochre-mono", colors: ["#f9edd5", "#dec082", "#a87f09", "#5b4404"] },
|
|
438
|
+
{ name: "olive-mono", colors: ["#edf2d7", "#c2cb91", "#848e38", "#474d04"] },
|
|
439
|
+
{ name: "fern-mono", colors: ["#e3f5df", "#a9d1a0", "#5f9752", "#2a5320"] },
|
|
440
|
+
{ name: "verdigris-mono", colors: ["#d6f7f1", "#96d2c9", "#38988d", "#07534c"] },
|
|
441
|
+
{ name: "umber-mono", colors: ["#feeada", "#e1bc9d", "#ab7a4f", "#623f1e"] },
|
|
442
|
+
{ name: "adobe-mono", colors: ["#fee9e8", "#eeb3b0", "#bb6d6b", "#6c3434"] },
|
|
193
443
|
// ── Neutral / mono ──
|
|
194
444
|
{ name: "ivory-ink", colors: ["#f7f4ef", "#d8d2c8", "#8a8378", "#20201d"] },
|
|
195
445
|
{ name: "paper-graphite", colors: ["#f4f4f5", "#cfcfd4", "#83838f", "#1c1c22"] },
|
|
196
446
|
{ name: "bone-coffee", colors: ["#f5efe6", "#d9c9b3", "#9c8468", "#3a2c1e"] },
|
|
197
447
|
{ name: "porcelain-greige", colors: ["#f6f3ef", "#ddd4c9", "#b0a596", "#6a5f52"] },
|
|
198
448
|
{ name: "battleship-steel", colors: ["#e6e9e8", "#b9c0be", "#7d8683", "#3f4745"] },
|
|
199
|
-
{ name: "gunmetal-fog", colors: ["#eceef1", "#c2c7cd", "#7b838d", "#343a42"] }
|
|
449
|
+
{ name: "gunmetal-fog", colors: ["#eceef1", "#c2c7cd", "#7b838d", "#343a42"] },
|
|
450
|
+
{ name: "silver-steel", colors: ["#e2e5e8", "#c4c9cf", "#9aa2ac", "#646f7d"] },
|
|
451
|
+
// ── Airy: a near-white base with soft color splashes, in the style of
|
|
452
|
+
// blurred editorial gradients (light stops first, then the splash hues) ──
|
|
453
|
+
{ name: "petal-bloom", colors: ["#e4eeff", "#bcd2ff", "#dfa6e9", "#fa787b", "#fba96a"] },
|
|
454
|
+
{ name: "tulip-blur", colors: ["#e6f1f6", "#a8d4e8", "#f1c4d0", "#ee9a6e", "#f4b81c"] },
|
|
455
|
+
{ name: "silk-rose", colors: ["#fcedf4", "#f2cfdd", "#f0c1b8", "#d68ba5", "#b07a9f"] },
|
|
456
|
+
// ── Flags: sampled from heraldic flag art ──
|
|
457
|
+
{ name: "sultans-eagle", colors: ["#f2da38", "#e3c42a", "#c9a21d", "#a1353a", "#6a1c26"] },
|
|
458
|
+
{ name: "zhuge-nu", colors: ["#f0f3ee", "#dfc652", "#4fa79b", "#1f8283", "#0f5252"] }
|
|
200
459
|
];
|
|
201
460
|
function paletteAccent(palette) {
|
|
202
461
|
const colors = palette.colors;
|
|
@@ -259,14 +518,18 @@ var lookDescriptions = {
|
|
|
259
518
|
domainWarpStrength: "How much the color field swirls.",
|
|
260
519
|
fractalOctaveCount: "More octaves add finer detail.",
|
|
261
520
|
colorTransitionContrast: "Sharpness of the edges between colors.",
|
|
262
|
-
grainOpacity: "Strength of the film-grain overlay."
|
|
521
|
+
grainOpacity: "Strength of the film-grain overlay.",
|
|
522
|
+
sweep: "Drifts the color into soft, curving wisps, like dye in water or folded silk, 0\u20131. 0 is the plain fluid.",
|
|
523
|
+
ground: "Roughly the share of the image, 0\u20131, left as a pale ground in the palette's lightest color."
|
|
263
524
|
};
|
|
264
525
|
var defaultLook = {
|
|
265
526
|
baseNoiseFrequency: 0.3,
|
|
266
527
|
domainWarpStrength: 1,
|
|
267
528
|
fractalOctaveCount: 2,
|
|
268
529
|
colorTransitionContrast: 1.75,
|
|
269
|
-
grainOpacity: 0.09
|
|
530
|
+
grainOpacity: 0.09,
|
|
531
|
+
sweep: 0,
|
|
532
|
+
ground: 0
|
|
270
533
|
};
|
|
271
534
|
var DEFAULT_WIDTH = 1200;
|
|
272
535
|
var DEFAULT_HEIGHT = 1500;
|
|
@@ -288,15 +551,20 @@ function sampleColorRamp(rgbStops, position) {
|
|
|
288
551
|
lowB + (highB - lowB) * blend
|
|
289
552
|
];
|
|
290
553
|
}
|
|
291
|
-
var
|
|
554
|
+
var smoothStep2 = (edge) => edge * edge * (3 - 2 * edge);
|
|
292
555
|
async function renderCanvas(options) {
|
|
293
556
|
const { seed, palette, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT } = options;
|
|
294
557
|
const look = { ...defaultLook, ...options.look };
|
|
558
|
+
for (const knob of ["sweep", "ground"]) {
|
|
559
|
+
if (!(look[knob] >= 0 && look[knob] <= 1)) {
|
|
560
|
+
throw new RangeError(`look.${knob} must be between 0 and 1. Got: ${look[knob]}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
295
563
|
const random = createSeededRandom(hashStringToInt(seed));
|
|
296
564
|
const rgbStops = palette.colors.map(hexToRgb2);
|
|
297
|
-
const horizontalWarpNoise =
|
|
298
|
-
const verticalWarpNoise =
|
|
299
|
-
const colorFieldNoise =
|
|
565
|
+
const horizontalWarpNoise = createNoise2D2(random);
|
|
566
|
+
const verticalWarpNoise = createNoise2D2(random);
|
|
567
|
+
const colorFieldNoise = createNoise2D2(random);
|
|
300
568
|
const sampleFractalNoise = (noise, sampleX, sampleY) => {
|
|
301
569
|
let total = 0;
|
|
302
570
|
let amplitude = 0.5;
|
|
@@ -313,21 +581,24 @@ async function renderCanvas(options) {
|
|
|
313
581
|
const image = context.createImageData(width, height);
|
|
314
582
|
const pixels = image.data;
|
|
315
583
|
const aspectRatio = width / height;
|
|
584
|
+
const rampPositionAt = (sampleX, sampleY) => {
|
|
585
|
+
const horizontalWarpOffset = sampleFractalNoise(horizontalWarpNoise, sampleX, sampleY);
|
|
586
|
+
const verticalWarpOffset = sampleFractalNoise(verticalWarpNoise, sampleX + 5.2, sampleY + 1.3);
|
|
587
|
+
const rawFieldValue = sampleFractalNoise(
|
|
588
|
+
colorFieldNoise,
|
|
589
|
+
sampleX + look.domainWarpStrength * horizontalWarpOffset,
|
|
590
|
+
sampleY + look.domainWarpStrength * verticalWarpOffset
|
|
591
|
+
);
|
|
592
|
+
const normalizedValue = (rawFieldValue + 1) / 2;
|
|
593
|
+
return smoothStep2(
|
|
594
|
+
Math.max(0, Math.min(1, (normalizedValue - 0.5) * look.colorTransitionContrast + 0.5))
|
|
595
|
+
);
|
|
596
|
+
};
|
|
316
597
|
for (let pixelY = 0; pixelY < height; pixelY++) {
|
|
317
598
|
for (let pixelX = 0; pixelX < width; pixelX++) {
|
|
318
599
|
const sampleX = pixelX / width * look.baseNoiseFrequency * aspectRatio;
|
|
319
600
|
const sampleY = pixelY / height * look.baseNoiseFrequency;
|
|
320
|
-
const
|
|
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
|
-
);
|
|
601
|
+
const contrastedValue = rampPositionAt(sampleX, sampleY);
|
|
331
602
|
const [red, green, blue] = sampleColorRamp(rgbStops, contrastedValue);
|
|
332
603
|
const pixelIndex = (pixelY * width + pixelX) * 4;
|
|
333
604
|
pixels[pixelIndex] = red;
|
|
@@ -336,6 +607,20 @@ async function renderCanvas(options) {
|
|
|
336
607
|
pixels[pixelIndex + 3] = 255;
|
|
337
608
|
}
|
|
338
609
|
}
|
|
610
|
+
if (look.sweep > 0 || look.ground > 0) {
|
|
611
|
+
applySweep({
|
|
612
|
+
seed,
|
|
613
|
+
width,
|
|
614
|
+
height,
|
|
615
|
+
pixels,
|
|
616
|
+
// Height units: the fluid's x runs 0…aspect, y runs 0…1.
|
|
617
|
+
rampPositionAt: (u, v) => rampPositionAt(u * look.baseNoiseFrequency, v * look.baseNoiseFrequency),
|
|
618
|
+
rgbStops,
|
|
619
|
+
sampleRamp: (position) => sampleColorRamp(rgbStops, position),
|
|
620
|
+
sweep: look.sweep,
|
|
621
|
+
ground: look.ground
|
|
622
|
+
});
|
|
623
|
+
}
|
|
339
624
|
context.putImageData(image, 0, 0);
|
|
340
625
|
const grainPixels = new Uint8ClampedArray(width * height * 4);
|
|
341
626
|
for (let i = 0; i < width * height; i++) {
|
|
@@ -390,6 +675,13 @@ var finiteNumber = (flag) => (raw) => {
|
|
|
390
675
|
if (!Number.isFinite(value)) throw new Error(`--${flag} must be a number. Got: ${raw}`);
|
|
391
676
|
return value;
|
|
392
677
|
};
|
|
678
|
+
var unitInterval = (flag) => (raw) => {
|
|
679
|
+
const value = Number(raw);
|
|
680
|
+
if (raw.trim() === "" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
681
|
+
throw new Error(`--${flag} must be a number from 0 to 1. Got: ${raw}`);
|
|
682
|
+
}
|
|
683
|
+
return value;
|
|
684
|
+
};
|
|
393
685
|
var optionFlags = {
|
|
394
686
|
seed: {
|
|
395
687
|
flag: "seed",
|
|
@@ -452,6 +744,18 @@ var lookFlags = {
|
|
|
452
744
|
placeholder: "<0-1>",
|
|
453
745
|
describe: `${lookDescriptions.grainOpacity} Default ${defaultLook.grainOpacity}.`,
|
|
454
746
|
parse: finiteNumber("grain-opacity")
|
|
747
|
+
},
|
|
748
|
+
sweep: {
|
|
749
|
+
flag: "sweep",
|
|
750
|
+
placeholder: "<0-1>",
|
|
751
|
+
describe: `${lookDescriptions.sweep} Default ${defaultLook.sweep}.`,
|
|
752
|
+
parse: unitInterval("sweep")
|
|
753
|
+
},
|
|
754
|
+
ground: {
|
|
755
|
+
flag: "ground",
|
|
756
|
+
placeholder: "<0-1>",
|
|
757
|
+
describe: `${lookDescriptions.ground} Default ${defaultLook.ground}.`,
|
|
758
|
+
parse: unitInterval("ground")
|
|
455
759
|
}
|
|
456
760
|
};
|
|
457
761
|
var cliOnlyFlags = [
|
|
@@ -460,6 +764,21 @@ var cliOnlyFlags = [
|
|
|
460
764
|
placeholder: "<path>",
|
|
461
765
|
describe: "Output file, .webp or .png. Default: output/<palette>-<seed>.webp"
|
|
462
766
|
},
|
|
767
|
+
{
|
|
768
|
+
flag: "random-palette",
|
|
769
|
+
placeholder: "",
|
|
770
|
+
describe: "Pick the palette at random instead of deriving it from --seed. The name is printed."
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
flag: "chaos",
|
|
774
|
+
placeholder: "",
|
|
775
|
+
describe: "Ignore the palettes: use 3\u20135 random colors (the same --seed gives the same colors)."
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
flag: "colors",
|
|
779
|
+
placeholder: "<hex,...>",
|
|
780
|
+
describe: 'Use your own 3\u20135 colors, light \u2192 dark, comma-separated: "#fdf6ee,#f2c4ce,#3f6fd8". The # is optional.'
|
|
781
|
+
},
|
|
463
782
|
{ flag: "json", placeholder: "", describe: "Print result metadata as JSON." },
|
|
464
783
|
{ flag: "list-palettes", placeholder: "", describe: "Print built-in palette names and exit." },
|
|
465
784
|
{ flag: "help", placeholder: "", describe: "Show this help (-h)." }
|
|
@@ -532,11 +851,33 @@ ${cliOnlyFlags.map(line).join("\n")}
|
|
|
532
851
|
})();
|
|
533
852
|
var parseOptions = {
|
|
534
853
|
...Object.fromEntries(allFlags.map((f) => [f.flag, { type: "string" }])),
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
854
|
+
// A CLI-only flag with a placeholder takes a value; one without is a switch.
|
|
855
|
+
...Object.fromEntries(
|
|
856
|
+
cliOnlyFlags.map((f) => [f.flag, { type: f.placeholder ? "string" : "boolean" }])
|
|
857
|
+
),
|
|
538
858
|
help: { type: "boolean", short: "h" }
|
|
539
859
|
};
|
|
860
|
+
function chaosPalette(seed) {
|
|
861
|
+
const random = createSeededRandom(hashStringToInt(`${seed}::chaos`));
|
|
862
|
+
const count = 3 + Math.floor(random() * 3);
|
|
863
|
+
const colors = Array.from({ length: count }, () => {
|
|
864
|
+
const channels = [random(), random(), random()].map(
|
|
865
|
+
(c) => Math.floor(c * 256).toString(16).padStart(2, "0")
|
|
866
|
+
);
|
|
867
|
+
return `#${channels.join("")}`;
|
|
868
|
+
});
|
|
869
|
+
return { name: "chaos", colors };
|
|
870
|
+
}
|
|
871
|
+
function customPalette(raw) {
|
|
872
|
+
const colors = raw.split(",").map((c) => c.trim()).filter((c) => c.length > 0).map((c) => (c.startsWith("#") ? c : `#${c}`).toLowerCase());
|
|
873
|
+
const palette = { name: "custom", colors };
|
|
874
|
+
try {
|
|
875
|
+
validatePalette(palette);
|
|
876
|
+
} catch (err) {
|
|
877
|
+
throw new Error(err.message.replace('Palette "custom"', "--colors"));
|
|
878
|
+
}
|
|
879
|
+
return palette;
|
|
880
|
+
}
|
|
540
881
|
async function runCli(argv) {
|
|
541
882
|
const values = parseArgs({ args: argv, options: parseOptions }).values;
|
|
542
883
|
const raw = (flag) => values[flag];
|
|
@@ -562,7 +903,14 @@ async function runCli(argv) {
|
|
|
562
903
|
}
|
|
563
904
|
const seed = read("seed") ?? randomBytes(4).toString("hex");
|
|
564
905
|
const paletteName = read("palette");
|
|
565
|
-
const
|
|
906
|
+
const customColors = raw("colors");
|
|
907
|
+
const sources = ["palette", "random-palette", "chaos", "colors"].filter(
|
|
908
|
+
(flag) => values[flag] !== void 0 && values[flag] !== false
|
|
909
|
+
);
|
|
910
|
+
if (sources.length > 1) {
|
|
911
|
+
throw new Error(`${sources.map((f) => `--${f}`).join(" and ")} cannot be combined.`);
|
|
912
|
+
}
|
|
913
|
+
const palette = customColors !== void 0 ? customPalette(customColors) : values.chaos ? chaosPalette(seed) : values["random-palette"] ? colorPalettes[randomInt(colorPalettes.length)] : paletteName === void 0 ? pickPalette(seed) : findPalette(paletteName);
|
|
566
914
|
const out = raw("out") ?? join("output", `${palette.name}-${seed}.webp`);
|
|
567
915
|
const extension = extname(out).toLowerCase();
|
|
568
916
|
if (extension !== ".webp" && extension !== ".png") {
|
|
@@ -587,6 +935,9 @@ async function runCli(argv) {
|
|
|
587
935
|
} else {
|
|
588
936
|
console.log(`\u2713 ${out} (${(result.buffer.length / 1024).toFixed(2)} KB)`);
|
|
589
937
|
console.log(` seed: ${result.seed}`);
|
|
938
|
+
if (values.chaos || customColors !== void 0) {
|
|
939
|
+
console.log(` colors: ${result.palette.colors.join(" ")}`);
|
|
940
|
+
}
|
|
590
941
|
console.log(` palette: ${result.palette.name} (accent: ${result.accent}, complement: ${result.complement})`);
|
|
591
942
|
}
|
|
592
943
|
}
|