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/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
- 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);
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.globalCompositeOperation = "soft-light";
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
- ctx.fillRect(0, 0, width, height);
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.beginPath();
625
+ ctx.moveTo(cx + r, cy);
585
626
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
586
- ctx.stroke();
587
627
  }
588
- ctx.globalCompositeOperation = "source-over";
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 — batched into a single path
600
- const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
601
- const dotR = dotSpacing * 0.08;
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
- ctx.beginPath();
605
- for (let px = 0; px < width; px += dotSpacing) {
606
- for (let py = 0; py < height; py += dotSpacing) {
607
- ctx.moveTo(px + dotR, py);
608
- ctx.arc(px, py, dotR, 0, Math.PI * 2);
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(6, Math.min(width, height) * (0.02 + rng() * 0.02));
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
- for (let d = -diag; d < diag; d += lineSpacing) {
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, batched into a single path
627
- const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
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
- for (let row = 0; row * tessH < height + tessH; row++) {
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
- for (let s = 0; s < 6; s++) {
639
- const angle = (Math.PI / 3) * s - Math.PI / 6;
640
- const vx = hx + Math.cos(angle) * tessSize * 0.5;
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. Layered masking / cutout portals ───────────────────────
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
- // ── 7. Noise texture overlay — batched via ImageData ─────────────
1631
- // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
1632
- // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
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
- try {
1639
- const imageData = ctx.getImageData(0, 0, width, height);
1640
- const data = imageData.data;
1641
- const pixelScale = Math.max(1, Math.round(scaleFactor));
1642
- if (pixelScale === 1) {
1643
- // Fast path no inner loop, direct pixel write
1644
- // Pre-compute alpha blend as integer math (avoid float multiply per channel)
1645
- for (let i = 0; i < noiseDensity; i++) {
1646
- const nx = Math.floor(noiseRng() * width);
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 = {