git-hash-art 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-hash-art",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createCanvas } from "@napi-rs/canvas";
3
+ import * as render from "../lib/render";
4
+ import * as affinity from "../lib/canvas/shapes/affinity";
5
+ import type { CustomDrawFunction } from "../types";
6
+
7
+ describe("Custom shapes", () => {
8
+ let ctx: CanvasRenderingContext2D;
9
+
10
+ beforeEach(() => {
11
+ const canvas = createCanvas(256, 256);
12
+ ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
13
+ });
14
+
15
+ it("should render with custom shapes without crashing", () => {
16
+ const drawSpy = vi.fn<CustomDrawFunction>((ctx, size, rng) => {
17
+ const r = size / 2;
18
+ ctx.beginPath();
19
+ ctx.arc(0, 0, r, 0, Math.PI * 2);
20
+ ctx.closePath();
21
+ });
22
+
23
+ expect(() => {
24
+ render.renderHashArt(ctx, "abcdef1234567890", {
25
+ width: 256,
26
+ height: 256,
27
+ customShapes: {
28
+ myCircle: { draw: drawSpy },
29
+ },
30
+ });
31
+ }).not.toThrow();
32
+ });
33
+
34
+ it("should pass deterministic RNG to custom draw function", () => {
35
+ const rngValues: number[] = [];
36
+ const makeDraw = (collector: number[]): CustomDrawFunction => (ctx, size, rng) => {
37
+ collector.push(rng());
38
+ ctx.beginPath();
39
+ ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
40
+ ctx.closePath();
41
+ };
42
+
43
+ render.renderHashArt(ctx, "deadbeefdeadbeef", {
44
+ width: 256,
45
+ height: 256,
46
+ customShapes: {
47
+ onlyShape: {
48
+ draw: makeDraw(rngValues),
49
+ profile: { tier: 1, heroCandidate: true, affinities: ["onlyShape"] },
50
+ },
51
+ },
52
+ });
53
+
54
+ if (rngValues.length > 0) {
55
+ const rngValues2: number[] = [];
56
+ const canvas2 = createCanvas(256, 256);
57
+ const ctx2 = canvas2.getContext("2d") as unknown as CanvasRenderingContext2D;
58
+
59
+ render.renderHashArt(ctx2, "deadbeefdeadbeef", {
60
+ width: 256,
61
+ height: 256,
62
+ customShapes: {
63
+ onlyShape: {
64
+ draw: makeDraw(rngValues2),
65
+ profile: { tier: 1, heroCandidate: true, affinities: ["onlyShape"] },
66
+ },
67
+ },
68
+ });
69
+
70
+ expect(rngValues).toEqual(rngValues2);
71
+ }
72
+ });
73
+
74
+ it("should not crash with empty customShapes", () => {
75
+ expect(() => {
76
+ render.renderHashArt(ctx, "1111111111111111", {
77
+ width: 256,
78
+ height: 256,
79
+ customShapes: {},
80
+ });
81
+ }).not.toThrow();
82
+ });
83
+
84
+ it("should not crash with undefined customShapes", () => {
85
+ expect(() => {
86
+ render.renderHashArt(ctx, "2222222222222222", {
87
+ width: 256,
88
+ height: 256,
89
+ });
90
+ }).not.toThrow();
91
+ });
92
+
93
+ it("should clean up custom profiles after render", () => {
94
+ render.renderHashArt(ctx, "3333333333333333", {
95
+ width: 256,
96
+ height: 256,
97
+ customShapes: {
98
+ tempShape: {
99
+ draw: (ctx, size) => {
100
+ ctx.beginPath();
101
+ ctx.rect(-size / 2, -size / 2, size, size);
102
+ ctx.closePath();
103
+ },
104
+ },
105
+ },
106
+ });
107
+
108
+ expect(affinity.SHAPE_PROFILES["tempShape"]).toBeUndefined();
109
+ });
110
+
111
+ it("should apply custom profile overrides", () => {
112
+ affinity.registerCustomProfile("testShape", {
113
+ tier: 1,
114
+ minSizeFraction: 0.1,
115
+ maxSizeFraction: 0.5,
116
+ affinities: ["circle", "triangle"],
117
+ heroCandidate: true,
118
+ bestStyles: ["stroke-only"],
119
+ });
120
+
121
+ const profile = affinity.SHAPE_PROFILES["testShape"];
122
+ expect(profile).toBeDefined();
123
+ expect(profile.tier).toBe(1);
124
+ expect(profile.minSizeFraction).toBe(0.1);
125
+ expect(profile.maxSizeFraction).toBe(0.5);
126
+ expect(profile.heroCandidate).toBe(true);
127
+ expect(profile.bestStyles).toEqual(["stroke-only"]);
128
+ expect(profile.category).toBe("procedural");
129
+
130
+ affinity.unregisterCustomProfile("testShape");
131
+ expect(affinity.SHAPE_PROFILES["testShape"]).toBeUndefined();
132
+ });
133
+
134
+ it("should use sensible defaults for missing profile fields", () => {
135
+ affinity.registerCustomProfile("defaultsShape", {});
136
+
137
+ const profile = affinity.SHAPE_PROFILES["defaultsShape"];
138
+ expect(profile.tier).toBe(2);
139
+ expect(profile.minSizeFraction).toBe(0.05);
140
+ expect(profile.maxSizeFraction).toBe(1.0);
141
+ expect(profile.affinities).toEqual(["circle", "square"]);
142
+ expect(profile.heroCandidate).toBe(false);
143
+ expect(profile.bestStyles).toEqual(["fill-and-stroke", "watercolor"]);
144
+
145
+ affinity.unregisterCustomProfile("defaultsShape");
146
+ });
147
+
148
+ it("should render with multiple custom shapes", () => {
149
+ const diamond: CustomDrawFunction = (ctx, size, rng) => {
150
+ const half = size / 2;
151
+ ctx.beginPath();
152
+ ctx.moveTo(0, -half);
153
+ ctx.lineTo(half * rng(), 0);
154
+ ctx.lineTo(0, half);
155
+ ctx.lineTo(-half * rng(), 0);
156
+ ctx.closePath();
157
+ };
158
+
159
+ const cross: CustomDrawFunction = (ctx, size, rng) => {
160
+ const arm = size * 0.15;
161
+ const half = size / 2 * (0.8 + rng() * 0.2);
162
+ ctx.beginPath();
163
+ ctx.rect(-arm, -half, arm * 2, size);
164
+ ctx.rect(-half, -arm, size, arm * 2);
165
+ ctx.closePath();
166
+ };
167
+
168
+ expect(() => {
169
+ render.renderHashArt(ctx, "cafebabe12345678", {
170
+ width: 256,
171
+ height: 256,
172
+ customShapes: {
173
+ customDiamond: { draw: diamond },
174
+ customCross: {
175
+ draw: cross,
176
+ profile: { tier: 1, heroCandidate: true },
177
+ },
178
+ },
179
+ });
180
+ }).not.toThrow();
181
+ });
182
+ });
package/src/browser.ts CHANGED
@@ -96,5 +96,5 @@ function generateDataURL(
96
96
 
97
97
  export { renderToCanvas, generateImageBlob, generateDataURL, renderHashArt };
98
98
  export { PRESETS } from "./lib/constants";
99
- export type { GenerationConfig } from "./types";
99
+ export type { GenerationConfig, CustomShapeDefinition, CustomDrawFunction } from "./types";
100
100
  export { DEFAULT_CONFIG } from "./types";
package/src/index.ts CHANGED
@@ -66,5 +66,5 @@ function saveImageToFile(
66
66
 
67
67
  export { generateImageFromHash, saveImageToFile, renderHashArt };
68
68
  export { PRESETS } from "./lib/constants";
69
- export type { GenerationConfig } from "./types";
69
+ export type { GenerationConfig, CustomShapeDefinition, CustomDrawFunction } from "./types";
70
70
  export { DEFAULT_CONFIG } from "./types";
@@ -134,6 +134,8 @@ interface EnhanceShapeConfig extends DrawShapeConfig {
134
134
  lightAngle?: number;
135
135
  /** Scale factor for resolution-independent sizing. */
136
136
  scaleFactor?: number;
137
+ /** Optional combined shapes registry (includes custom shapes). Falls back to built-in shapes. */
138
+ activeShapes?: Record<string, (ctx: CanvasRenderingContext2D, size: number, config?: any) => void>;
137
139
  }
138
140
 
139
141
  export function drawShape(
@@ -141,9 +143,9 @@ export function drawShape(
141
143
  shape: string,
142
144
  x: number,
143
145
  y: number,
144
- config: DrawShapeConfig,
146
+ config: DrawShapeConfig & { activeShapes?: Record<string, (ctx: CanvasRenderingContext2D, size: number, config?: any) => void> },
145
147
  ) {
146
- const { fillColor, strokeColor, strokeWidth, size, rotation } = config;
148
+ const { fillColor, strokeColor, strokeWidth, size, rotation, activeShapes } = config;
147
149
  ctx.save();
148
150
  ctx.translate(x, y);
149
151
  ctx.rotate((rotation * Math.PI) / 180);
@@ -151,7 +153,8 @@ export function drawShape(
151
153
  ctx.strokeStyle = strokeColor;
152
154
  ctx.lineWidth = strokeWidth;
153
155
 
154
- const drawFunction = shapes[shape];
156
+ const registry = activeShapes ?? shapes;
157
+ const drawFunction = registry[shape];
155
158
  if (drawFunction) {
156
159
  drawFunction(ctx, size);
157
160
  ctx.fill();
@@ -746,7 +749,8 @@ export function enhanceShapeGeneration(
746
749
  ctx.strokeStyle = strokeColor;
747
750
  ctx.lineWidth = strokeWidth;
748
751
 
749
- const drawFunction = shapes[shape];
752
+ const registry = config.activeShapes ?? shapes;
753
+ const drawFunction = registry[shape];
750
754
  if (drawFunction) {
751
755
  drawFunction(ctx, size, { rng });
752
756
  applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
@@ -420,6 +420,39 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
420
420
  },
421
421
  };
422
422
 
423
+ /**
424
+ * Register a custom shape profile into SHAPE_PROFILES.
425
+ * Merges user-provided partial profile with sensible defaults.
426
+ */
427
+ export function registerCustomProfile(
428
+ name: string,
429
+ partial?: {
430
+ tier?: 1 | 2 | 3;
431
+ minSizeFraction?: number;
432
+ maxSizeFraction?: number;
433
+ affinities?: string[];
434
+ heroCandidate?: boolean;
435
+ bestStyles?: string[];
436
+ },
437
+ ): void {
438
+ SHAPE_PROFILES[name] = {
439
+ tier: partial?.tier ?? 2,
440
+ minSizeFraction: partial?.minSizeFraction ?? 0.05,
441
+ maxSizeFraction: partial?.maxSizeFraction ?? 1.0,
442
+ affinities: partial?.affinities ?? ["circle", "square"],
443
+ category: "procedural",
444
+ heroCandidate: partial?.heroCandidate ?? false,
445
+ bestStyles: partial?.bestStyles ?? ["fill-and-stroke", "watercolor"],
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Remove a custom shape profile from SHAPE_PROFILES.
451
+ */
452
+ export function unregisterCustomProfile(name: string): void {
453
+ delete SHAPE_PROFILES[name];
454
+ }
455
+
423
456
  // ── Shape palette: curated sets of shapes that work well together ────
424
457
 
425
458
  export interface ShapePalette {
package/src/lib/render.ts CHANGED
@@ -520,7 +520,32 @@ export function renderHashArt(
520
520
  const colorHierarchy = buildColorHierarchy(colors, rng);
521
521
 
522
522
  // ── 0c. Shape palette — curated shapes that work well together ──
523
- const shapeNames = Object.keys(shapes);
523
+ // Merge custom shapes into a combined registry
524
+ const customShapeNames: string[] = [];
525
+ type DrawFunction = (ctx: CanvasRenderingContext2D, size: number, config?: any) => void;
526
+ let activeShapes: Record<string, DrawFunction> | undefined;
527
+ if (finalConfig.customShapes && Object.keys(finalConfig.customShapes).length > 0) {
528
+ activeShapes = { ...shapes };
529
+ for (const [name, def] of Object.entries(finalConfig.customShapes)) {
530
+ // Wrap CustomDrawFunction (ctx, size, rng) into DrawFunction (ctx, size, config?)
531
+ const customDraw = def.draw;
532
+ activeShapes[name] = (ctx, size, config?) => {
533
+ customDraw(ctx, size, config?.rng ?? Math.random);
534
+ };
535
+ // Register profile for affinity system (inlined to avoid ESM interop issues)
536
+ SHAPE_PROFILES[name] = {
537
+ tier: def.profile?.tier ?? 2,
538
+ minSizeFraction: def.profile?.minSizeFraction ?? 0.05,
539
+ maxSizeFraction: def.profile?.maxSizeFraction ?? 1.0,
540
+ affinities: def.profile?.affinities ?? ["circle", "square"],
541
+ category: "procedural",
542
+ heroCandidate: def.profile?.heroCandidate ?? false,
543
+ bestStyles: def.profile?.bestStyles ?? ["fill-and-stroke", "watercolor"],
544
+ };
545
+ customShapeNames.push(name);
546
+ }
547
+ }
548
+ const shapeNames = Object.keys(activeShapes ?? shapes);
524
549
  const shapePalette = buildShapePalette(rng, shapeNames, archetype.name);
525
550
 
526
551
  // ── 0d. Color grading — unified tone for the whole image ───────
@@ -873,6 +898,7 @@ export function renderHashArt(
873
898
  rng,
874
899
  lightAngle,
875
900
  scaleFactor,
901
+ activeShapes,
876
902
  });
877
903
 
878
904
  heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
@@ -1118,6 +1144,7 @@ export function renderHashArt(
1118
1144
  rng,
1119
1145
  lightAngle,
1120
1146
  scaleFactor,
1147
+ activeShapes,
1121
1148
  };
1122
1149
 
1123
1150
  if (shouldMirror) {
@@ -1152,6 +1179,7 @@ export function renderHashArt(
1152
1179
  proportionType: "GOLDEN_RATIO",
1153
1180
  renderStyle: "fill-only",
1154
1181
  rng,
1182
+ activeShapes,
1155
1183
  });
1156
1184
  }
1157
1185
  extrasSpent += glazePasses;
@@ -1186,6 +1214,7 @@ export function renderHashArt(
1186
1214
  proportionType: "GOLDEN_RATIO",
1187
1215
  renderStyle: finalRenderStyle,
1188
1216
  rng,
1217
+ activeShapes,
1189
1218
  });
1190
1219
  shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1191
1220
  spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
@@ -1237,6 +1266,7 @@ export function renderHashArt(
1237
1266
  proportionType: "GOLDEN_RATIO",
1238
1267
  renderStyle: innerStyle,
1239
1268
  rng,
1269
+ activeShapes,
1240
1270
  },
1241
1271
  );
1242
1272
  extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1;
@@ -1297,6 +1327,7 @@ export function renderHashArt(
1297
1327
  proportionType: "GOLDEN_RATIO",
1298
1328
  renderStyle: memberStyle,
1299
1329
  rng,
1330
+ activeShapes,
1300
1331
  });
1301
1332
  shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1302
1333
  spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
@@ -1348,6 +1379,7 @@ export function renderHashArt(
1348
1379
  proportionType: "GOLDEN_RATIO",
1349
1380
  renderStyle: finalRenderStyle,
1350
1381
  rng,
1382
+ activeShapes,
1351
1383
  });
1352
1384
  shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1353
1385
  spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
@@ -1368,72 +1400,7 @@ export function renderHashArt(
1368
1400
  if (_dt) { _dt.shapeCount = shapePositions.length; _dt.extraCount = extrasSpent; }
1369
1401
  _mark("5_shape_layers");
1370
1402
 
1371
- // ── 5g. Layered masking / cutout portals ───────────────────────
1372
- // ~18% of images get 1-3 portal windows that paint over foreground
1373
- // with a tinted background wash, creating a "peek through" effect.
1374
- if (rng() < 0.18 && shapePositions.length > 3) {
1375
- const portalCount = 1 + Math.floor(rng() * 2);
1376
- for (let p = 0; p < portalCount; p++) {
1377
- // Pick a position biased toward placed shapes
1378
- const sourceShape = shapePositions[Math.floor(rng() * shapePositions.length)];
1379
- const portalX = sourceShape.x + (rng() - 0.5) * sourceShape.size * 0.5;
1380
- const portalY = sourceShape.y + (rng() - 0.5) * sourceShape.size * 0.5;
1381
- const portalSize = adjustedMaxSize * (0.15 + rng() * 0.25);
1382
-
1383
- // Pick a portal shape from the palette
1384
- const portalShape = pickShapeFromPalette(shapePalette, rng, portalSize / adjustedMaxSize);
1385
- const portalRotation = rng() * 360;
1386
- const portalAlpha = 0.6 + rng() * 0.35;
1387
-
1388
- ctx.save();
1389
- ctx.translate(portalX, portalY);
1390
- ctx.rotate((portalRotation * Math.PI) / 180);
1391
-
1392
- // Step 1: Clip to the portal shape and fill with background wash
1393
- ctx.beginPath();
1394
- shapes[portalShape]?.(ctx, portalSize);
1395
- ctx.clip();
1396
-
1397
- // Fill the clipped region with a radial gradient from background colors
1398
- const portalColor = jitterColorHSL(bgStart, rng, 15, 0.1);
1399
- const portalGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, portalSize);
1400
- portalGrad.addColorStop(0, portalColor);
1401
- portalGrad.addColorStop(1, bgEnd);
1402
- ctx.globalAlpha = portalAlpha;
1403
- ctx.fillStyle = portalGrad;
1404
- ctx.fillRect(-portalSize, -portalSize, portalSize * 2, portalSize * 2);
1405
-
1406
- // Optional: subtle inner texture — a few tiny dots inside the portal
1407
- if (rng() < 0.5) {
1408
- const dotCount = 3 + Math.floor(rng() * 5);
1409
- ctx.globalAlpha = portalAlpha * 0.3;
1410
- ctx.fillStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.2);
1411
- for (let d = 0; d < dotCount; d++) {
1412
- const dx = (rng() - 0.5) * portalSize * 1.4;
1413
- const dy = (rng() - 0.5) * portalSize * 1.4;
1414
- const dr = (1 + rng() * 3) * scaleFactor;
1415
- ctx.beginPath();
1416
- ctx.arc(dx, dy, dr, 0, Math.PI * 2);
1417
- ctx.fill();
1418
- }
1419
- }
1420
-
1421
- ctx.restore();
1422
-
1423
- // Step 2: Draw a border ring around the portal (outside the clip)
1424
- ctx.save();
1425
- ctx.translate(portalX, portalY);
1426
- ctx.rotate((portalRotation * Math.PI) / 180);
1427
- ctx.globalAlpha = 0.15 + rng() * 0.2;
1428
- ctx.strokeStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.5);
1429
- ctx.lineWidth = (1.5 + rng() * 2.5) * scaleFactor;
1430
- ctx.beginPath();
1431
- shapes[portalShape]?.(ctx, portalSize * 1.06);
1432
- ctx.stroke();
1433
- ctx.restore();
1434
- }
1435
- }
1436
-
1403
+ // ── 5g. (Portal/cutout feature removed replaced by custom shapes API) ──
1437
1404
 
1438
1405
  _mark("5g_portals");
1439
1406
 
@@ -2044,4 +2011,8 @@ export function renderHashArt(
2044
2011
  ctx.globalAlpha = 1;
2045
2012
  _mark("11_signature");
2046
2013
 
2014
+ // Clean up custom shape profiles to avoid leaking into subsequent renders
2015
+ for (const name of customShapeNames) {
2016
+ delete SHAPE_PROFILES[name];
2017
+ }
2047
2018
  }
package/src/types.ts CHANGED
@@ -1,3 +1,46 @@
1
+ /**
2
+ * Draw function signature for custom shapes.
3
+ * The function should build a canvas path (moveTo/lineTo/arc/etc.)
4
+ * centered at the origin. The pipeline handles translate, rotate,
5
+ * fill, and stroke — your function just defines the geometry.
6
+ *
7
+ * @param ctx - Canvas 2D rendering context (already translated to shape center)
8
+ * @param size - Bounding size in pixels
9
+ * @param rng - Deterministic RNG seeded from the git hash — use this instead of Math.random()
10
+ */
11
+ export type CustomDrawFunction = (
12
+ ctx: CanvasRenderingContext2D,
13
+ size: number,
14
+ rng: () => number,
15
+ ) => void;
16
+
17
+ /**
18
+ * Definition for a user-provided custom shape.
19
+ */
20
+ export interface CustomShapeDefinition {
21
+ /** The draw function that builds the shape path */
22
+ draw: CustomDrawFunction;
23
+ /**
24
+ * Optional shape profile for the affinity system.
25
+ * Controls how the shape is selected and composed with others.
26
+ * Sensible defaults are applied for any omitted fields.
27
+ */
28
+ profile?: {
29
+ /** Visual quality tier: 1 = always good, 2 = usually good, 3 = situational (default: 2) */
30
+ tier?: 1 | 2 | 3;
31
+ /** Minimum size as fraction of maxShapeSize (default: 0.05) */
32
+ minSizeFraction?: number;
33
+ /** Maximum size as fraction of maxShapeSize (default: 1.0) */
34
+ maxSizeFraction?: number;
35
+ /** Names of shapes this composes well with (default: ["circle", "square"]) */
36
+ affinities?: string[];
37
+ /** Whether this shape works as a hero/focal element (default: false) */
38
+ heroCandidate?: boolean;
39
+ /** Best render styles (default: ["fill-and-stroke", "watercolor"]) */
40
+ bestStyles?: string[];
41
+ };
42
+ }
43
+
1
44
  /**
2
45
  * Configuration options for image generation.
3
46
  */
@@ -20,6 +63,13 @@ export interface GenerationConfig {
20
63
  opacityReduction: number;
21
64
  /** Base shapes per layer — defaults to gridSize² × 1.5 when 0 */
22
65
  shapesPerLayer: number;
66
+ /**
67
+ * Custom shapes to include in the generation.
68
+ * Keys are shape names, values are CustomShapeDefinition objects.
69
+ * Custom shapes are merged with built-in shapes and participate
70
+ * in palette selection, affinity matching, and all render styles.
71
+ */
72
+ customShapes?: Record<string, CustomShapeDefinition>;
23
73
  /** Internal: collect per-phase timing data when set (not part of public API) */
24
74
  _debugTiming?: { phases: Record<string, number>; shapeCount: number; extraCount: number };
25
75
  }