git-hash-art 0.12.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.12.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
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Per-phase timing breakdown using _debugTiming instrumentation.
3
+ * This gives exact cost of each pipeline section inside renderHashArt.
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import { createCanvas } from "@napi-rs/canvas";
7
+ import { renderHashArt } from "../lib/render";
8
+ import type { GenerationConfig } from "../types";
9
+
10
+ const HASHES = [
11
+ { label: "46192e59", hash: "46192e59d42f741c761cbea79462a8b3815dd905" },
12
+ { label: "deadbeef", hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" },
13
+ { label: "ff00ff00", hash: "ff00ff00ff00ff00ff00ff00ff00ff00ff00ff0" },
14
+ { label: "77777777", hash: "7777777777777777777777777777777777777777" },
15
+ ];
16
+
17
+ function fmt(ms: number): string {
18
+ return ms < 1 ? `${(ms * 1000).toFixed(0)}µs` : `${ms.toFixed(1)}ms`;
19
+ }
20
+
21
+ describe("Phase breakdown via _debugTiming", () => {
22
+ for (const { label, hash } of HASHES) {
23
+ it(`${label} at 1024×1024`, () => {
24
+ const canvas = createCanvas(1024, 1024);
25
+ const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
26
+ const timing: GenerationConfig["_debugTiming"] = { phases: {}, shapeCount: 0, extraCount: 0 };
27
+ const config: Partial<GenerationConfig> = { width: 1024, height: 1024, _debugTiming: timing };
28
+
29
+ const start = performance.now();
30
+ renderHashArt(ctx, hash, config);
31
+ const total = performance.now() - start;
32
+
33
+ console.log(`\n ═══ ${label} (${total.toFixed(0)}ms total, ${timing!.shapeCount} shapes, ${timing!.extraCount} extras) ═══`);
34
+ const phases = timing!.phases;
35
+ // Sort by cost descending
36
+ const sorted = Object.entries(phases).sort((a, b) => b[1] - a[1]);
37
+ for (const [name, ms] of sorted) {
38
+ const pct = ((ms / total) * 100).toFixed(1);
39
+ console.log(` ${name.padEnd(25)} ${fmt(ms).padStart(10)} (${pct}%)`);
40
+ }
41
+ expect(total).toBeLessThan(5_000); // was 30s, now targeting <5s
42
+ });
43
+ }
44
+ });
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 {