@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/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 smoothStep = (edge) => edge * edge * (3 - 2 * edge);
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 = createNoise2D(random);
298
- const verticalWarpNoise = createNoise2D(random);
299
- const colorFieldNoise = createNoise2D(random);
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 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
- );
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
- out: { type: "string" },
536
- json: { type: "boolean" },
537
- "list-palettes": { type: "boolean" },
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 palette = paletteName === void 0 ? pickPalette(seed) : findPalette(paletteName);
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
  }