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/src/lib/render.ts
CHANGED
|
@@ -479,6 +479,15 @@ export function renderHashArt(
|
|
|
479
479
|
config: Partial<GenerationConfig> = {},
|
|
480
480
|
): void {
|
|
481
481
|
const finalConfig: GenerationConfig = { ...DEFAULT_CONFIG, ...config };
|
|
482
|
+
const _dt = finalConfig._debugTiming;
|
|
483
|
+
const _t = _dt ? () => performance.now() : undefined;
|
|
484
|
+
let _p = _t ? _t() : 0;
|
|
485
|
+
function _mark(name: string) {
|
|
486
|
+
if (!_dt || !_t) return;
|
|
487
|
+
const now = _t();
|
|
488
|
+
_dt.phases[name] = (now - _p);
|
|
489
|
+
_p = now;
|
|
490
|
+
}
|
|
482
491
|
|
|
483
492
|
const rng = createRng(seedFromHash(gitHash));
|
|
484
493
|
|
|
@@ -511,7 +520,32 @@ export function renderHashArt(
|
|
|
511
520
|
const colorHierarchy = buildColorHierarchy(colors, rng);
|
|
512
521
|
|
|
513
522
|
// ── 0c. Shape palette — curated shapes that work well together ──
|
|
514
|
-
|
|
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);
|
|
515
549
|
const shapePalette = buildShapePalette(rng, shapeNames, archetype.name);
|
|
516
550
|
|
|
517
551
|
// ── 0d. Color grading — unified tone for the whole image ───────
|
|
@@ -530,13 +564,16 @@ export function renderHashArt(
|
|
|
530
564
|
const cx = width / 2;
|
|
531
565
|
const cy = height / 2;
|
|
532
566
|
|
|
567
|
+
_mark("0_setup");
|
|
568
|
+
|
|
533
569
|
// ── 1. Background ──────────────────────────────────────────────
|
|
534
570
|
const bgRadius = Math.hypot(cx, cy);
|
|
535
571
|
drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
|
|
536
572
|
|
|
537
573
|
// Gradient mesh overlay — 3-4 color control points for richer backgrounds
|
|
574
|
+
// Use source-over instead of soft-light for cheaper compositing
|
|
538
575
|
const meshPoints = 3 + Math.floor(rng() * 2);
|
|
539
|
-
ctx.
|
|
576
|
+
ctx.globalAlpha = 1;
|
|
540
577
|
for (let i = 0; i < meshPoints; i++) {
|
|
541
578
|
const mx = rng() * width;
|
|
542
579
|
const my = rng() * height;
|
|
@@ -545,104 +582,114 @@ export function renderHashArt(
|
|
|
545
582
|
const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
|
|
546
583
|
grad.addColorStop(0, hexWithAlpha(mColor, 0.08 + rng() * 0.06));
|
|
547
584
|
grad.addColorStop(1, "rgba(0,0,0,0)");
|
|
548
|
-
ctx.globalAlpha = 1;
|
|
549
585
|
ctx.fillStyle = grad;
|
|
550
|
-
|
|
586
|
+
// Clip to gradient bounding box — avoids blending transparent pixels
|
|
587
|
+
const gx = Math.max(0, mx - mRadius);
|
|
588
|
+
const gy = Math.max(0, my - mRadius);
|
|
589
|
+
const gw = Math.min(width, mx + mRadius) - gx;
|
|
590
|
+
const gh = Math.min(height, my + mRadius) - gy;
|
|
591
|
+
ctx.fillRect(gx, gy, gw, gh);
|
|
551
592
|
}
|
|
552
|
-
ctx.globalCompositeOperation = "source-over";
|
|
553
593
|
|
|
554
594
|
// Compute average background luminance for contrast enforcement
|
|
555
595
|
const bgLum = (luminance(bgStart) + luminance(bgEnd)) / 2;
|
|
556
596
|
|
|
557
597
|
// ── 1b. Layered background — archetype-coherent shapes ─────────
|
|
598
|
+
// Use source-over with pre-multiplied alpha instead of soft-light
|
|
599
|
+
// for much cheaper compositing (soft-light requires per-pixel blend)
|
|
558
600
|
const bgShapeCount = 3 + Math.floor(rng() * 4);
|
|
559
|
-
ctx.globalCompositeOperation = "soft-light";
|
|
560
601
|
for (let i = 0; i < bgShapeCount; i++) {
|
|
561
602
|
const bx = rng() * width;
|
|
562
603
|
const by = rng() * height;
|
|
563
604
|
const bSize = (width * 0.3 + rng() * width * 0.5);
|
|
564
605
|
const bColor = pickHierarchyColor(colorHierarchy, rng);
|
|
565
|
-
ctx.globalAlpha = 0.03 + rng() * 0.05;
|
|
606
|
+
ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light
|
|
566
607
|
ctx.fillStyle = hexWithAlpha(bColor, 0.15);
|
|
567
608
|
ctx.beginPath();
|
|
568
609
|
// Use archetype-appropriate background shapes
|
|
569
610
|
if (archetype.name === "geometric-precision" || archetype.name === "op-art") {
|
|
570
|
-
// Rectangular shapes for geometric archetypes
|
|
571
611
|
ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
|
|
572
612
|
} else {
|
|
573
613
|
ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
|
|
574
614
|
}
|
|
575
615
|
ctx.fill();
|
|
576
616
|
}
|
|
577
|
-
// Subtle concentric rings from center
|
|
617
|
+
// Subtle concentric rings from center — batched into single stroke
|
|
578
618
|
const ringCount = 2 + Math.floor(rng() * 3);
|
|
579
619
|
ctx.globalAlpha = 0.02 + rng() * 0.03;
|
|
580
620
|
ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
|
|
581
621
|
ctx.lineWidth = 1 * scaleFactor;
|
|
622
|
+
ctx.beginPath();
|
|
582
623
|
for (let i = 1; i <= ringCount; i++) {
|
|
583
624
|
const r = (Math.min(width, height) * 0.15) * i;
|
|
584
|
-
ctx.
|
|
625
|
+
ctx.moveTo(cx + r, cy);
|
|
585
626
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
586
|
-
ctx.stroke();
|
|
587
627
|
}
|
|
588
|
-
ctx.
|
|
628
|
+
ctx.stroke();
|
|
589
629
|
|
|
590
630
|
// ── 1c. Background pattern layer — subtle textured paper ───────
|
|
591
631
|
const bgPatternRoll = rng();
|
|
592
632
|
if (bgPatternRoll < 0.6) {
|
|
593
633
|
ctx.save();
|
|
594
|
-
ctx.globalCompositeOperation = "soft-light";
|
|
595
634
|
const patternOpacity = 0.02 + rng() * 0.04;
|
|
596
635
|
const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
|
|
597
636
|
|
|
598
637
|
if (bgPatternRoll < 0.2) {
|
|
599
|
-
// Dot grid —
|
|
600
|
-
const dotSpacing = Math.max(
|
|
601
|
-
const
|
|
638
|
+
// Dot grid — use fillRect instead of arcs (much cheaper, no path building)
|
|
639
|
+
const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015));
|
|
640
|
+
const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16));
|
|
602
641
|
ctx.globalAlpha = patternOpacity;
|
|
603
642
|
ctx.fillStyle = patternColor;
|
|
604
|
-
|
|
605
|
-
for (let px = 0; px < width; px += dotSpacing) {
|
|
606
|
-
for (let py = 0; py < height; py += dotSpacing) {
|
|
607
|
-
ctx.
|
|
608
|
-
|
|
643
|
+
let dotCount = 0;
|
|
644
|
+
for (let px = 0; px < width && dotCount < 2000; px += dotSpacing) {
|
|
645
|
+
for (let py = 0; py < height && dotCount < 2000; py += dotSpacing) {
|
|
646
|
+
ctx.fillRect(px, py, dotDiam, dotDiam);
|
|
647
|
+
dotCount++;
|
|
609
648
|
}
|
|
610
649
|
}
|
|
611
|
-
ctx.fill();
|
|
612
650
|
} else if (bgPatternRoll < 0.4) {
|
|
613
|
-
// Diagonal lines — batched into a single path
|
|
614
|
-
const lineSpacing = Math.max(
|
|
651
|
+
// Diagonal lines — batched into a single path, capped at 300 lines
|
|
652
|
+
const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02));
|
|
615
653
|
ctx.globalAlpha = patternOpacity;
|
|
616
654
|
ctx.strokeStyle = patternColor;
|
|
617
655
|
ctx.lineWidth = 0.5 * scaleFactor;
|
|
618
656
|
const diag = Math.hypot(width, height);
|
|
619
657
|
ctx.beginPath();
|
|
620
|
-
|
|
658
|
+
let lineCount = 0;
|
|
659
|
+
for (let d = -diag; d < diag && lineCount < 300; d += lineSpacing) {
|
|
621
660
|
ctx.moveTo(d, 0);
|
|
622
661
|
ctx.lineTo(d + height, height);
|
|
662
|
+
lineCount++;
|
|
623
663
|
}
|
|
624
664
|
ctx.stroke();
|
|
625
665
|
} else {
|
|
626
|
-
// Tessellation — hexagonal grid,
|
|
627
|
-
const tessSize = Math.max(
|
|
666
|
+
// Tessellation — hexagonal grid, capped at 500 hexagons
|
|
667
|
+
const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02));
|
|
628
668
|
const tessH = tessSize * Math.sqrt(3);
|
|
629
669
|
ctx.globalAlpha = patternOpacity * 0.7;
|
|
630
670
|
ctx.strokeStyle = patternColor;
|
|
631
671
|
ctx.lineWidth = 0.4 * scaleFactor;
|
|
672
|
+
// Pre-compute hex vertex offsets (avoid trig per vertex)
|
|
673
|
+
const hexVx: number[] = [];
|
|
674
|
+
const hexVy: number[] = [];
|
|
675
|
+
for (let s = 0; s < 6; s++) {
|
|
676
|
+
const angle = (Math.PI / 3) * s - Math.PI / 6;
|
|
677
|
+
hexVx.push(Math.cos(angle) * tessSize * 0.5);
|
|
678
|
+
hexVy.push(Math.sin(angle) * tessSize * 0.5);
|
|
679
|
+
}
|
|
632
680
|
ctx.beginPath();
|
|
633
|
-
|
|
681
|
+
let hexCount = 0;
|
|
682
|
+
for (let row = 0; row * tessH < height + tessH && hexCount < 500; row++) {
|
|
634
683
|
const offsetX = (row % 2) * tessSize * 0.75;
|
|
635
|
-
for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
|
|
684
|
+
for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++) {
|
|
636
685
|
const hx = col * tessSize * 1.5 + offsetX;
|
|
637
686
|
const hy = row * tessH;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const vy = hy + Math.sin(angle) * tessSize * 0.5;
|
|
642
|
-
if (s === 0) ctx.moveTo(vx, vy);
|
|
643
|
-
else ctx.lineTo(vx, vy);
|
|
687
|
+
ctx.moveTo(hx + hexVx[0], hy + hexVy[0]);
|
|
688
|
+
for (let s = 1; s < 6; s++) {
|
|
689
|
+
ctx.lineTo(hx + hexVx[s], hy + hexVy[s]);
|
|
644
690
|
}
|
|
645
691
|
ctx.closePath();
|
|
692
|
+
hexCount++;
|
|
646
693
|
}
|
|
647
694
|
}
|
|
648
695
|
ctx.stroke();
|
|
@@ -651,6 +698,8 @@ export function renderHashArt(
|
|
|
651
698
|
}
|
|
652
699
|
ctx.globalCompositeOperation = "source-over";
|
|
653
700
|
|
|
701
|
+
_mark("1_background");
|
|
702
|
+
|
|
654
703
|
// ── 2. Composition mode — archetype-aware selection ──────────────
|
|
655
704
|
const compositionMode: CompositionMode = rng() < 0.7
|
|
656
705
|
? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
|
|
@@ -776,6 +825,8 @@ export function renderHashArt(
|
|
|
776
825
|
}
|
|
777
826
|
ctx.globalAlpha = 1;
|
|
778
827
|
|
|
828
|
+
_mark("2_3_composition_focal");
|
|
829
|
+
|
|
779
830
|
// ── 4. Flow field — simplex noise for organic variation ─────────
|
|
780
831
|
// Create a seeded simplex noise field (unique per hash)
|
|
781
832
|
const noiseFieldRng = createRng(seedFromHash(gitHash, 333));
|
|
@@ -847,6 +898,7 @@ export function renderHashArt(
|
|
|
847
898
|
rng,
|
|
848
899
|
lightAngle,
|
|
849
900
|
scaleFactor,
|
|
901
|
+
activeShapes,
|
|
850
902
|
});
|
|
851
903
|
|
|
852
904
|
heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
|
|
@@ -855,6 +907,8 @@ export function renderHashArt(
|
|
|
855
907
|
}
|
|
856
908
|
|
|
857
909
|
|
|
910
|
+
_mark("4_flowfield_hero");
|
|
911
|
+
|
|
858
912
|
// ── 5. Shape layers ────────────────────────────────────────────
|
|
859
913
|
const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
|
|
860
914
|
|
|
@@ -1090,6 +1144,7 @@ export function renderHashArt(
|
|
|
1090
1144
|
rng,
|
|
1091
1145
|
lightAngle,
|
|
1092
1146
|
scaleFactor,
|
|
1147
|
+
activeShapes,
|
|
1093
1148
|
};
|
|
1094
1149
|
|
|
1095
1150
|
if (shouldMirror) {
|
|
@@ -1124,6 +1179,7 @@ export function renderHashArt(
|
|
|
1124
1179
|
proportionType: "GOLDEN_RATIO",
|
|
1125
1180
|
renderStyle: "fill-only",
|
|
1126
1181
|
rng,
|
|
1182
|
+
activeShapes,
|
|
1127
1183
|
});
|
|
1128
1184
|
}
|
|
1129
1185
|
extrasSpent += glazePasses;
|
|
@@ -1158,6 +1214,7 @@ export function renderHashArt(
|
|
|
1158
1214
|
proportionType: "GOLDEN_RATIO",
|
|
1159
1215
|
renderStyle: finalRenderStyle,
|
|
1160
1216
|
rng,
|
|
1217
|
+
activeShapes,
|
|
1161
1218
|
});
|
|
1162
1219
|
shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
|
|
1163
1220
|
spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
|
|
@@ -1209,6 +1266,7 @@ export function renderHashArt(
|
|
|
1209
1266
|
proportionType: "GOLDEN_RATIO",
|
|
1210
1267
|
renderStyle: innerStyle,
|
|
1211
1268
|
rng,
|
|
1269
|
+
activeShapes,
|
|
1212
1270
|
},
|
|
1213
1271
|
);
|
|
1214
1272
|
extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1;
|
|
@@ -1269,6 +1327,7 @@ export function renderHashArt(
|
|
|
1269
1327
|
proportionType: "GOLDEN_RATIO",
|
|
1270
1328
|
renderStyle: memberStyle,
|
|
1271
1329
|
rng,
|
|
1330
|
+
activeShapes,
|
|
1272
1331
|
});
|
|
1273
1332
|
shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
1274
1333
|
spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
@@ -1320,6 +1379,7 @@ export function renderHashArt(
|
|
|
1320
1379
|
proportionType: "GOLDEN_RATIO",
|
|
1321
1380
|
renderStyle: finalRenderStyle,
|
|
1322
1381
|
rng,
|
|
1382
|
+
activeShapes,
|
|
1323
1383
|
});
|
|
1324
1384
|
shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
1325
1385
|
spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
@@ -1337,73 +1397,12 @@ export function renderHashArt(
|
|
|
1337
1397
|
|
|
1338
1398
|
// Reset blend mode for post-processing passes
|
|
1339
1399
|
ctx.globalCompositeOperation = "source-over";
|
|
1400
|
+
if (_dt) { _dt.shapeCount = shapePositions.length; _dt.extraCount = extrasSpent; }
|
|
1401
|
+
_mark("5_shape_layers");
|
|
1340
1402
|
|
|
1341
|
-
// ── 5g.
|
|
1342
|
-
// ~18% of images get 1-3 portal windows that paint over foreground
|
|
1343
|
-
// with a tinted background wash, creating a "peek through" effect.
|
|
1344
|
-
if (rng() < 0.18 && shapePositions.length > 3) {
|
|
1345
|
-
const portalCount = 1 + Math.floor(rng() * 2);
|
|
1346
|
-
for (let p = 0; p < portalCount; p++) {
|
|
1347
|
-
// Pick a position biased toward placed shapes
|
|
1348
|
-
const sourceShape = shapePositions[Math.floor(rng() * shapePositions.length)];
|
|
1349
|
-
const portalX = sourceShape.x + (rng() - 0.5) * sourceShape.size * 0.5;
|
|
1350
|
-
const portalY = sourceShape.y + (rng() - 0.5) * sourceShape.size * 0.5;
|
|
1351
|
-
const portalSize = adjustedMaxSize * (0.15 + rng() * 0.25);
|
|
1352
|
-
|
|
1353
|
-
// Pick a portal shape from the palette
|
|
1354
|
-
const portalShape = pickShapeFromPalette(shapePalette, rng, portalSize / adjustedMaxSize);
|
|
1355
|
-
const portalRotation = rng() * 360;
|
|
1356
|
-
const portalAlpha = 0.6 + rng() * 0.35;
|
|
1357
|
-
|
|
1358
|
-
ctx.save();
|
|
1359
|
-
ctx.translate(portalX, portalY);
|
|
1360
|
-
ctx.rotate((portalRotation * Math.PI) / 180);
|
|
1361
|
-
|
|
1362
|
-
// Step 1: Clip to the portal shape and fill with background wash
|
|
1363
|
-
ctx.beginPath();
|
|
1364
|
-
shapes[portalShape]?.(ctx, portalSize);
|
|
1365
|
-
ctx.clip();
|
|
1366
|
-
|
|
1367
|
-
// Fill the clipped region with a radial gradient from background colors
|
|
1368
|
-
const portalColor = jitterColorHSL(bgStart, rng, 15, 0.1);
|
|
1369
|
-
const portalGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, portalSize);
|
|
1370
|
-
portalGrad.addColorStop(0, portalColor);
|
|
1371
|
-
portalGrad.addColorStop(1, bgEnd);
|
|
1372
|
-
ctx.globalAlpha = portalAlpha;
|
|
1373
|
-
ctx.fillStyle = portalGrad;
|
|
1374
|
-
ctx.fillRect(-portalSize, -portalSize, portalSize * 2, portalSize * 2);
|
|
1375
|
-
|
|
1376
|
-
// Optional: subtle inner texture — a few tiny dots inside the portal
|
|
1377
|
-
if (rng() < 0.5) {
|
|
1378
|
-
const dotCount = 3 + Math.floor(rng() * 5);
|
|
1379
|
-
ctx.globalAlpha = portalAlpha * 0.3;
|
|
1380
|
-
ctx.fillStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.2);
|
|
1381
|
-
for (let d = 0; d < dotCount; d++) {
|
|
1382
|
-
const dx = (rng() - 0.5) * portalSize * 1.4;
|
|
1383
|
-
const dy = (rng() - 0.5) * portalSize * 1.4;
|
|
1384
|
-
const dr = (1 + rng() * 3) * scaleFactor;
|
|
1385
|
-
ctx.beginPath();
|
|
1386
|
-
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
|
|
1387
|
-
ctx.fill();
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
ctx.restore();
|
|
1392
|
-
|
|
1393
|
-
// Step 2: Draw a border ring around the portal (outside the clip)
|
|
1394
|
-
ctx.save();
|
|
1395
|
-
ctx.translate(portalX, portalY);
|
|
1396
|
-
ctx.rotate((portalRotation * Math.PI) / 180);
|
|
1397
|
-
ctx.globalAlpha = 0.15 + rng() * 0.2;
|
|
1398
|
-
ctx.strokeStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.5);
|
|
1399
|
-
ctx.lineWidth = (1.5 + rng() * 2.5) * scaleFactor;
|
|
1400
|
-
ctx.beginPath();
|
|
1401
|
-
shapes[portalShape]?.(ctx, portalSize * 1.06);
|
|
1402
|
-
ctx.stroke();
|
|
1403
|
-
ctx.restore();
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1403
|
+
// ── 5g. (Portal/cutout feature removed — replaced by custom shapes API) ──
|
|
1406
1404
|
|
|
1405
|
+
_mark("5g_portals");
|
|
1407
1406
|
|
|
1408
1407
|
// ── 6. Flow-line pass — variable color, branching, pressure ────
|
|
1409
1408
|
// Optimized: collect all segments into width-quantized buckets, then
|
|
@@ -1546,6 +1545,8 @@ export function renderHashArt(
|
|
|
1546
1545
|
}
|
|
1547
1546
|
}
|
|
1548
1547
|
|
|
1548
|
+
_mark("6_flow_lines");
|
|
1549
|
+
|
|
1549
1550
|
// ── 6b. Motion/energy lines — short directional bursts ─────────
|
|
1550
1551
|
// Optimized: collect all burst segments, then batch by quantized alpha
|
|
1551
1552
|
const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
|
|
@@ -1605,6 +1606,8 @@ export function renderHashArt(
|
|
|
1605
1606
|
}
|
|
1606
1607
|
}
|
|
1607
1608
|
|
|
1609
|
+
_mark("6b_energy_lines");
|
|
1610
|
+
|
|
1608
1611
|
// ── 6c. Apply symmetry mirroring ─────────────────────────────────
|
|
1609
1612
|
if (symmetryMode !== "none") {
|
|
1610
1613
|
const canvas = ctx.canvas;
|
|
@@ -1627,66 +1630,28 @@ export function renderHashArt(
|
|
|
1627
1630
|
}
|
|
1628
1631
|
|
|
1629
1632
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
//
|
|
1633
|
+
_mark("6c_symmetry");
|
|
1634
|
+
|
|
1635
|
+
// ── 7. Noise texture overlay ─────────────────────────────────────
|
|
1636
|
+
// With density capped at 2500 dots, direct fillRect calls are far cheaper
|
|
1637
|
+
// than the getImageData/putImageData round-trip which copies the entire
|
|
1638
|
+
// pixel buffer (4 × width × height bytes) twice.
|
|
1633
1639
|
const noiseRng = createRng(seedFromHash(gitHash, 777));
|
|
1634
1640
|
const rawNoiseDensity = Math.floor((width * height) / 800);
|
|
1635
|
-
// Cap at 2500 dots — beyond this the visual effect is indistinguishable
|
|
1636
|
-
// but getImageData/putImageData cost scales with canvas size
|
|
1637
1641
|
const noiseDensity = Math.min(rawNoiseDensity, 2500);
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
const
|
|
1641
|
-
const
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
const ny = Math.floor(noiseRng() * height);
|
|
1648
|
-
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1649
|
-
// srcA in range [0.01, 0.04] — multiply by 256 for fixed-point
|
|
1650
|
-
const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
|
|
1651
|
-
const invA256 = 256 - srcA256;
|
|
1652
|
-
const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
|
|
1653
|
-
const idx = (ny * width + nx) << 2;
|
|
1654
|
-
data[idx] = (data[idx] * invA256 + bSrc) >> 8;
|
|
1655
|
-
data[idx + 1] = (data[idx + 1] * invA256 + bSrc) >> 8;
|
|
1656
|
-
data[idx + 2] = (data[idx + 2] * invA256 + bSrc) >> 8;
|
|
1657
|
-
}
|
|
1658
|
-
} else {
|
|
1659
|
-
for (let i = 0; i < noiseDensity; i++) {
|
|
1660
|
-
const nx = Math.floor(noiseRng() * width);
|
|
1661
|
-
const ny = Math.floor(noiseRng() * height);
|
|
1662
|
-
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1663
|
-
const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
|
|
1664
|
-
const invA256 = 256 - srcA256;
|
|
1665
|
-
const bSrc = brightness * srcA256;
|
|
1666
|
-
for (let dy = 0; dy < pixelScale && ny + dy < height; dy++) {
|
|
1667
|
-
for (let dx = 0; dx < pixelScale && nx + dx < width; dx++) {
|
|
1668
|
-
const idx = ((ny + dy) * width + (nx + dx)) << 2;
|
|
1669
|
-
data[idx] = (data[idx] * invA256 + bSrc) >> 8;
|
|
1670
|
-
data[idx + 1] = (data[idx + 1] * invA256 + bSrc) >> 8;
|
|
1671
|
-
data[idx + 2] = (data[idx + 2] * invA256 + bSrc) >> 8;
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
ctx.putImageData(imageData, 0, 0);
|
|
1677
|
-
} catch {
|
|
1678
|
-
// Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
|
|
1679
|
-
for (let i = 0; i < noiseDensity; i++) {
|
|
1680
|
-
const nx = noiseRng() * width;
|
|
1681
|
-
const ny = noiseRng() * height;
|
|
1682
|
-
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1683
|
-
const alpha = 0.01 + noiseRng() * 0.03;
|
|
1684
|
-
ctx.globalAlpha = alpha;
|
|
1685
|
-
ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
|
|
1686
|
-
ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
|
|
1687
|
-
}
|
|
1642
|
+
const pixelScale = Math.max(1, Math.round(scaleFactor));
|
|
1643
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
1644
|
+
const nx = noiseRng() * width;
|
|
1645
|
+
const ny = noiseRng() * height;
|
|
1646
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1647
|
+
const alpha = 0.01 + noiseRng() * 0.03;
|
|
1648
|
+
ctx.globalAlpha = alpha;
|
|
1649
|
+
ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`;
|
|
1650
|
+
ctx.fillRect(nx, ny, pixelScale, pixelScale);
|
|
1688
1651
|
}
|
|
1689
1652
|
|
|
1653
|
+
_mark("7_noise_texture");
|
|
1654
|
+
|
|
1690
1655
|
// ── 8. Vignette — darken edges to draw the eye inward ───────────
|
|
1691
1656
|
ctx.globalAlpha = 1;
|
|
1692
1657
|
const vignetteStrength = 0.25 + rng() * 0.2;
|
|
@@ -1702,6 +1667,8 @@ export function renderHashArt(
|
|
|
1702
1667
|
ctx.fillStyle = vigGrad;
|
|
1703
1668
|
ctx.fillRect(0, 0, width, height);
|
|
1704
1669
|
|
|
1670
|
+
_mark("8_vignette");
|
|
1671
|
+
|
|
1705
1672
|
// ── 9. Organic connecting curves — proximity-aware ───────────────
|
|
1706
1673
|
// Optimized: batch all curves into alpha-quantized groups to reduce
|
|
1707
1674
|
// beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
|
|
@@ -1770,6 +1737,8 @@ export function renderHashArt(
|
|
|
1770
1737
|
}
|
|
1771
1738
|
}
|
|
1772
1739
|
|
|
1740
|
+
_mark("9_connecting_curves");
|
|
1741
|
+
|
|
1773
1742
|
// ── 10. Post-processing ────────────────────────────────────────
|
|
1774
1743
|
|
|
1775
1744
|
// 10a. Color grading — unified tone across the whole image
|
|
@@ -1827,6 +1796,8 @@ export function renderHashArt(
|
|
|
1827
1796
|
ctx.globalCompositeOperation = "source-over";
|
|
1828
1797
|
}
|
|
1829
1798
|
|
|
1799
|
+
_mark("10_post_processing");
|
|
1800
|
+
|
|
1830
1801
|
// ── 10e. Generative borders — archetype-driven decorative frames ──
|
|
1831
1802
|
{
|
|
1832
1803
|
ctx.save();
|
|
@@ -1973,6 +1944,8 @@ export function renderHashArt(
|
|
|
1973
1944
|
ctx.restore();
|
|
1974
1945
|
}
|
|
1975
1946
|
|
|
1947
|
+
_mark("10e_borders");
|
|
1948
|
+
|
|
1976
1949
|
// ── 11. Signature mark — placed in the least-dense corner ──────
|
|
1977
1950
|
{
|
|
1978
1951
|
const sigRng = createRng(seedFromHash(gitHash, 42));
|
|
@@ -2036,5 +2009,10 @@ export function renderHashArt(
|
|
|
2036
2009
|
}
|
|
2037
2010
|
|
|
2038
2011
|
ctx.globalAlpha = 1;
|
|
2012
|
+
_mark("11_signature");
|
|
2039
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
|
+
}
|
|
2040
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,15 @@ 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>;
|
|
73
|
+
/** Internal: collect per-phase timing data when set (not part of public API) */
|
|
74
|
+
_debugTiming?: { phases: Record<string, number>; shapeCount: number; extraCount: number };
|
|
23
75
|
}
|
|
24
76
|
|
|
25
77
|
export const DEFAULT_CONFIG: GenerationConfig = {
|