git-hash-art 0.6.0 → 0.8.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/ALGORITHM.md +30 -3
- package/CHANGELOG.md +16 -0
- package/bin/generateExamples.js +6 -14
- package/dist/browser.js +1289 -125
- package/dist/browser.js.map +1 -1
- package/dist/main.js +1289 -125
- package/dist/main.js.map +1 -1
- package/dist/module.js +1289 -125
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +51 -0
- package/src/lib/canvas/colors.ts +155 -2
- package/src/lib/canvas/draw.ts +42 -9
- package/src/lib/canvas/shapes/affinity.ts +479 -0
- package/src/lib/canvas/shapes/index.ts +2 -0
- package/src/lib/canvas/shapes/procedural.ts +209 -0
- package/src/lib/render.ts +282 -136
package/package.json
CHANGED
package/src/lib/archetypes.ts
CHANGED
|
@@ -219,6 +219,57 @@ const ARCHETYPES: Archetype[] = [
|
|
|
219
219
|
sizePower: 2.5,
|
|
220
220
|
invertForeground: false,
|
|
221
221
|
},
|
|
222
|
+
{
|
|
223
|
+
name: "watercolor-wash",
|
|
224
|
+
gridSize: 3,
|
|
225
|
+
layers: 3,
|
|
226
|
+
baseOpacity: 0.25,
|
|
227
|
+
opacityReduction: 0.03,
|
|
228
|
+
minShapeSize: 200,
|
|
229
|
+
maxShapeSize: 700,
|
|
230
|
+
backgroundStyle: "radial-light",
|
|
231
|
+
paletteMode: "harmonious",
|
|
232
|
+
preferredStyles: ["watercolor", "fill-only", "incomplete"],
|
|
233
|
+
flowLineMultiplier: 0.5,
|
|
234
|
+
heroShape: false,
|
|
235
|
+
glowMultiplier: 0.3,
|
|
236
|
+
sizePower: 0.6,
|
|
237
|
+
invertForeground: false,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "op-art",
|
|
241
|
+
gridSize: 8,
|
|
242
|
+
layers: 2,
|
|
243
|
+
baseOpacity: 0.95,
|
|
244
|
+
opacityReduction: 0.05,
|
|
245
|
+
minShapeSize: 20,
|
|
246
|
+
maxShapeSize: 200,
|
|
247
|
+
backgroundStyle: "solid-light",
|
|
248
|
+
paletteMode: "high-contrast",
|
|
249
|
+
preferredStyles: ["fill-and-stroke", "stroke-only", "dashed"],
|
|
250
|
+
flowLineMultiplier: 0,
|
|
251
|
+
heroShape: false,
|
|
252
|
+
glowMultiplier: 0,
|
|
253
|
+
sizePower: 0.4,
|
|
254
|
+
invertForeground: false,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "collage",
|
|
258
|
+
gridSize: 4,
|
|
259
|
+
layers: 3,
|
|
260
|
+
baseOpacity: 0.9,
|
|
261
|
+
opacityReduction: 0.08,
|
|
262
|
+
minShapeSize: 80,
|
|
263
|
+
maxShapeSize: 500,
|
|
264
|
+
backgroundStyle: "solid-light",
|
|
265
|
+
paletteMode: "duotone",
|
|
266
|
+
preferredStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
|
|
267
|
+
flowLineMultiplier: 0,
|
|
268
|
+
heroShape: true,
|
|
269
|
+
glowMultiplier: 0,
|
|
270
|
+
sizePower: 0.7,
|
|
271
|
+
invertForeground: false,
|
|
272
|
+
},
|
|
222
273
|
{
|
|
223
274
|
name: "classic",
|
|
224
275
|
gridSize: 5,
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -325,9 +325,87 @@ export function hexWithAlpha(hex: string, alpha: number): string {
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
328
|
+
* Color hierarchy — assigns dominant/secondary/accent roles to a palette.
|
|
329
|
+
* Dominant gets ~60% of usage, secondary ~25%, accent ~15%.
|
|
330
330
|
*/
|
|
331
|
+
export interface ColorHierarchy {
|
|
332
|
+
dominant: string;
|
|
333
|
+
secondary: string;
|
|
334
|
+
accent: string;
|
|
335
|
+
all: string[];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function buildColorHierarchy(colors: string[], rng: () => number): ColorHierarchy {
|
|
339
|
+
if (colors.length < 3) {
|
|
340
|
+
return {
|
|
341
|
+
dominant: colors[0] || "#888888",
|
|
342
|
+
secondary: colors[1] || colors[0] || "#888888",
|
|
343
|
+
accent: colors[colors.length - 1] || "#888888",
|
|
344
|
+
all: colors,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// Pick dominant as the color closest to the palette's average hue
|
|
348
|
+
const hsls = colors.map((c) => hexToHsl(c));
|
|
349
|
+
const avgHue = hsls.reduce((s, h) => s + h[0], 0) / hsls.length;
|
|
350
|
+
let dominantIdx = 0;
|
|
351
|
+
let minDist = 360;
|
|
352
|
+
for (let i = 0; i < hsls.length; i++) {
|
|
353
|
+
const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
|
|
354
|
+
if (d < minDist) { minDist = d; dominantIdx = i; }
|
|
355
|
+
}
|
|
356
|
+
// Accent is the color most distant from dominant in hue
|
|
357
|
+
let accentIdx = 0;
|
|
358
|
+
let maxDist = 0;
|
|
359
|
+
for (let i = 0; i < hsls.length; i++) {
|
|
360
|
+
if (i === dominantIdx) continue;
|
|
361
|
+
const d = Math.min(Math.abs(hsls[i][0] - hsls[dominantIdx][0]), 360 - Math.abs(hsls[i][0] - hsls[dominantIdx][0]));
|
|
362
|
+
if (d > maxDist) { maxDist = d; accentIdx = i; }
|
|
363
|
+
}
|
|
364
|
+
// Secondary is the remaining color with highest saturation
|
|
365
|
+
let secondaryIdx = 0;
|
|
366
|
+
let maxSat = -1;
|
|
367
|
+
for (let i = 0; i < hsls.length; i++) {
|
|
368
|
+
if (i === dominantIdx || i === accentIdx) continue;
|
|
369
|
+
if (hsls[i][1] > maxSat) { maxSat = hsls[i][1]; secondaryIdx = i; }
|
|
370
|
+
}
|
|
371
|
+
if (secondaryIdx === dominantIdx) secondaryIdx = accentIdx === 0 ? 1 : 0;
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
dominant: colors[dominantIdx],
|
|
375
|
+
secondary: colors[secondaryIdx],
|
|
376
|
+
accent: colors[accentIdx],
|
|
377
|
+
all: colors,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Pick a color from the hierarchy with weighted probability.
|
|
383
|
+
* ~60% dominant, ~25% secondary, ~15% accent.
|
|
384
|
+
*/
|
|
385
|
+
export function pickHierarchyColor(hierarchy: ColorHierarchy, rng: () => number): string {
|
|
386
|
+
const roll = rng();
|
|
387
|
+
if (roll < 0.60) return hierarchy.dominant;
|
|
388
|
+
if (roll < 0.85) return hierarchy.secondary;
|
|
389
|
+
return hierarchy.accent;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* HSL-space color jitter — preserves vibrancy better than RGB jitter.
|
|
394
|
+
* Applies small hue wobble + saturation/lightness variation.
|
|
395
|
+
*/
|
|
396
|
+
export function jitterColorHSL(
|
|
397
|
+
hex: string,
|
|
398
|
+
rng: () => number,
|
|
399
|
+
hueAmount = 8,
|
|
400
|
+
slAmount = 0.06,
|
|
401
|
+
): string {
|
|
402
|
+
const [h, s, l] = hexToHsl(hex);
|
|
403
|
+
const newH = (h + (rng() - 0.5) * hueAmount * 2 + 360) % 360;
|
|
404
|
+
const newS = Math.max(0, Math.min(1, s + (rng() - 0.5) * slAmount * 2));
|
|
405
|
+
const newL = Math.max(0, Math.min(1, l + (rng() - 0.5) * slAmount * 2));
|
|
406
|
+
return hslToHex(newH, newS, newL);
|
|
407
|
+
}
|
|
408
|
+
|
|
331
409
|
export function jitterColor(
|
|
332
410
|
hex: string,
|
|
333
411
|
rng: () => number,
|
|
@@ -357,3 +435,78 @@ export function shiftTemperature(hex: string, target: "warm" | "cool", amount: n
|
|
|
357
435
|
const [h, s, l] = hexToHsl(hex);
|
|
358
436
|
return hslToHex(shiftHueToward(h, target, amount), s, l);
|
|
359
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Compute relative luminance of a hex color (0 = black, 1 = white).
|
|
441
|
+
* Uses the sRGB luminance formula from WCAG.
|
|
442
|
+
*/
|
|
443
|
+
export function luminance(hex: string): number {
|
|
444
|
+
const [r, g, b] = hexToRgb(hex).map((c) => {
|
|
445
|
+
const s = c / 255;
|
|
446
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
447
|
+
});
|
|
448
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Enforce minimum contrast between a foreground color and a background
|
|
453
|
+
* luminance. On light backgrounds, darkens/saturates the foreground.
|
|
454
|
+
* On dark backgrounds, lightens/saturates the foreground.
|
|
455
|
+
*
|
|
456
|
+
* `bgLuminance` is 0-1 (pre-computed from the background color).
|
|
457
|
+
* `minContrast` is the minimum luminance difference to enforce (default 0.15).
|
|
458
|
+
*/
|
|
459
|
+
export function enforceContrast(
|
|
460
|
+
fgHex: string,
|
|
461
|
+
bgLuminance: number,
|
|
462
|
+
minContrast = 0.15,
|
|
463
|
+
): string {
|
|
464
|
+
const fgLum = luminance(fgHex);
|
|
465
|
+
const diff = Math.abs(fgLum - bgLuminance);
|
|
466
|
+
|
|
467
|
+
if (diff >= minContrast) return fgHex;
|
|
468
|
+
|
|
469
|
+
const [h, s, l] = hexToHsl(fgHex);
|
|
470
|
+
|
|
471
|
+
if (bgLuminance > 0.5) {
|
|
472
|
+
// Light background — darken and boost saturation
|
|
473
|
+
const targetL = Math.max(0.08, l - (minContrast - diff) * 1.5);
|
|
474
|
+
const targetS = Math.min(1, s + 0.2);
|
|
475
|
+
return hslToHex(h, targetS, targetL);
|
|
476
|
+
} else {
|
|
477
|
+
// Dark background — lighten and boost saturation
|
|
478
|
+
const targetL = Math.min(0.92, l + (minContrast - diff) * 1.5);
|
|
479
|
+
const targetS = Math.min(1, s + 0.15);
|
|
480
|
+
return hslToHex(h, targetS, targetL);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Apply a unified color grade to a hex color — shifts the entire image
|
|
486
|
+
* toward a cohesive tone. This is the "Instagram filter" effect.
|
|
487
|
+
*/
|
|
488
|
+
export function applyColorGrade(
|
|
489
|
+
hex: string,
|
|
490
|
+
gradeHue: number,
|
|
491
|
+
intensity: number,
|
|
492
|
+
): string {
|
|
493
|
+
const [h, s, l] = hexToHsl(hex);
|
|
494
|
+
// Blend hue toward the grade hue
|
|
495
|
+
const hueDiff = ((gradeHue - h + 540) % 360) - 180;
|
|
496
|
+
const newH = (h + hueDiff * intensity * 0.3 + 360) % 360;
|
|
497
|
+
// Slightly unify saturation
|
|
498
|
+
const newS = Math.max(0, Math.min(1, s + (0.5 - s) * intensity * 0.15));
|
|
499
|
+
return hslToHex(newH, newS, l);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Compute a deterministic color grade from the hash.
|
|
504
|
+
* Returns a hue (0-360) and intensity (0.15-0.4).
|
|
505
|
+
*/
|
|
506
|
+
export function pickColorGrade(rng: () => number): { hue: number; intensity: number } {
|
|
507
|
+
// Warm golden, cool blue, rosy, teal, amber
|
|
508
|
+
const GRADE_HUES = [40, 220, 340, 175, 30];
|
|
509
|
+
const hue = GRADE_HUES[Math.floor(rng() * GRADE_HUES.length)] + (rng() - 0.5) * 20;
|
|
510
|
+
const intensity = 0.15 + rng() * 0.25;
|
|
511
|
+
return { hue: (hue + 360) % 360, intensity };
|
|
512
|
+
}
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -151,23 +151,56 @@ function applyRenderStyle(
|
|
|
151
151
|
break;
|
|
152
152
|
|
|
153
153
|
case "watercolor": {
|
|
154
|
-
//
|
|
155
|
-
const passes =
|
|
154
|
+
// Improved watercolor: edge darkening + radial bleed + layered washes
|
|
155
|
+
const passes = 4 + (rng ? Math.floor(rng() * 2) : 0);
|
|
156
156
|
const savedAlpha = ctx.globalAlpha;
|
|
157
|
-
|
|
157
|
+
|
|
158
|
+
// Pass 1: Base wash — large, soft fill at low opacity
|
|
159
|
+
ctx.globalAlpha = savedAlpha * 0.15;
|
|
160
|
+
ctx.save();
|
|
161
|
+
const baseScale = 1.08 + (rng ? rng() * 0.04 : 0);
|
|
162
|
+
ctx.scale(baseScale, baseScale);
|
|
163
|
+
ctx.fill();
|
|
164
|
+
ctx.restore();
|
|
165
|
+
|
|
166
|
+
// Pass 2: Multiple offset washes with radial displacement
|
|
167
|
+
ctx.globalAlpha = savedAlpha * (0.25 / passes * 2);
|
|
158
168
|
for (let p = 0; p < passes; p++) {
|
|
159
|
-
|
|
160
|
-
const
|
|
169
|
+
// Radial outward displacement (not uniform) for organic bleed
|
|
170
|
+
const angle = rng ? rng() * Math.PI * 2 : p * Math.PI / 2;
|
|
171
|
+
const dist = rng ? rng() * size * 0.05 : size * 0.02;
|
|
172
|
+
const jx = Math.cos(angle) * dist;
|
|
173
|
+
const jy = Math.sin(angle) * dist;
|
|
161
174
|
ctx.save();
|
|
162
175
|
ctx.translate(jx, jy);
|
|
163
176
|
ctx.fill();
|
|
164
177
|
ctx.restore();
|
|
165
178
|
}
|
|
179
|
+
|
|
180
|
+
// Pass 3: Edge darkening — draw a slightly smaller shape with lighter fill
|
|
181
|
+
// to simulate pigment pooling at boundaries
|
|
182
|
+
ctx.globalAlpha = savedAlpha * 0.35;
|
|
183
|
+
ctx.save();
|
|
184
|
+
const innerScale = 0.85 + (rng ? rng() * 0.08 : 0);
|
|
185
|
+
ctx.scale(innerScale, innerScale);
|
|
186
|
+
// Lighten the fill for the inner area
|
|
187
|
+
const origFill = ctx.fillStyle;
|
|
188
|
+
if (typeof fillColor === "string") {
|
|
189
|
+
ctx.fillStyle = fillColor.replace(/[\d.]+\)$/, (m) => {
|
|
190
|
+
const v = parseFloat(m);
|
|
191
|
+
return Math.min(1, v * 1.4).toFixed(2) + ")";
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
ctx.fill();
|
|
195
|
+
ctx.fillStyle = origFill;
|
|
196
|
+
ctx.restore();
|
|
197
|
+
|
|
166
198
|
ctx.globalAlpha = savedAlpha;
|
|
167
|
-
//
|
|
168
|
-
ctx.globalAlpha *= 0.
|
|
199
|
+
// Soft stroke on top — thinner than normal for delicacy
|
|
200
|
+
ctx.globalAlpha *= 0.25;
|
|
201
|
+
ctx.lineWidth = strokeWidth * 0.6;
|
|
169
202
|
ctx.stroke();
|
|
170
|
-
ctx.globalAlpha /= 0.
|
|
203
|
+
ctx.globalAlpha /= 0.25;
|
|
171
204
|
break;
|
|
172
205
|
}
|
|
173
206
|
|
|
@@ -304,7 +337,7 @@ export function enhanceShapeGeneration(
|
|
|
304
337
|
|
|
305
338
|
const drawFunction = shapes[shape];
|
|
306
339
|
if (drawFunction) {
|
|
307
|
-
drawFunction(ctx, size);
|
|
340
|
+
drawFunction(ctx, size, { rng });
|
|
308
341
|
applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
|
|
309
342
|
}
|
|
310
343
|
|