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/CHANGELOG.md +16 -0
- package/dist/browser.js +167 -154
- package/dist/browser.js.map +1 -1
- package/dist/main.js +167 -154
- package/dist/main.js.map +1 -1
- package/dist/module.js +167 -154
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/custom-shapes.test.ts +182 -0
- package/src/__tests__/phase-breakdown.test.ts +44 -0
- package/src/browser.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/canvas/draw.ts +8 -4
- package/src/lib/canvas/shapes/affinity.ts +33 -0
- package/src/lib/render.ts +133 -155
- package/src/types.ts +52 -0
package/package.json
CHANGED
|
@@ -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";
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|