git-hash-art 0.12.0 → 0.13.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.13.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -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/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
 
@@ -530,13 +539,16 @@ export function renderHashArt(
530
539
  const cx = width / 2;
531
540
  const cy = height / 2;
532
541
 
542
+ _mark("0_setup");
543
+
533
544
  // ── 1. Background ──────────────────────────────────────────────
534
545
  const bgRadius = Math.hypot(cx, cy);
535
546
  drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
536
547
 
537
548
  // Gradient mesh overlay — 3-4 color control points for richer backgrounds
549
+ // Use source-over instead of soft-light for cheaper compositing
538
550
  const meshPoints = 3 + Math.floor(rng() * 2);
539
- ctx.globalCompositeOperation = "soft-light";
551
+ ctx.globalAlpha = 1;
540
552
  for (let i = 0; i < meshPoints; i++) {
541
553
  const mx = rng() * width;
542
554
  const my = rng() * height;
@@ -545,104 +557,114 @@ export function renderHashArt(
545
557
  const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
546
558
  grad.addColorStop(0, hexWithAlpha(mColor, 0.08 + rng() * 0.06));
547
559
  grad.addColorStop(1, "rgba(0,0,0,0)");
548
- ctx.globalAlpha = 1;
549
560
  ctx.fillStyle = grad;
550
- ctx.fillRect(0, 0, width, height);
561
+ // Clip to gradient bounding box — avoids blending transparent pixels
562
+ const gx = Math.max(0, mx - mRadius);
563
+ const gy = Math.max(0, my - mRadius);
564
+ const gw = Math.min(width, mx + mRadius) - gx;
565
+ const gh = Math.min(height, my + mRadius) - gy;
566
+ ctx.fillRect(gx, gy, gw, gh);
551
567
  }
552
- ctx.globalCompositeOperation = "source-over";
553
568
 
554
569
  // Compute average background luminance for contrast enforcement
555
570
  const bgLum = (luminance(bgStart) + luminance(bgEnd)) / 2;
556
571
 
557
572
  // ── 1b. Layered background — archetype-coherent shapes ─────────
573
+ // Use source-over with pre-multiplied alpha instead of soft-light
574
+ // for much cheaper compositing (soft-light requires per-pixel blend)
558
575
  const bgShapeCount = 3 + Math.floor(rng() * 4);
559
- ctx.globalCompositeOperation = "soft-light";
560
576
  for (let i = 0; i < bgShapeCount; i++) {
561
577
  const bx = rng() * width;
562
578
  const by = rng() * height;
563
579
  const bSize = (width * 0.3 + rng() * width * 0.5);
564
580
  const bColor = pickHierarchyColor(colorHierarchy, rng);
565
- ctx.globalAlpha = 0.03 + rng() * 0.05;
581
+ ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light
566
582
  ctx.fillStyle = hexWithAlpha(bColor, 0.15);
567
583
  ctx.beginPath();
568
584
  // Use archetype-appropriate background shapes
569
585
  if (archetype.name === "geometric-precision" || archetype.name === "op-art") {
570
- // Rectangular shapes for geometric archetypes
571
586
  ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
572
587
  } else {
573
588
  ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
574
589
  }
575
590
  ctx.fill();
576
591
  }
577
- // Subtle concentric rings from center
592
+ // Subtle concentric rings from center — batched into single stroke
578
593
  const ringCount = 2 + Math.floor(rng() * 3);
579
594
  ctx.globalAlpha = 0.02 + rng() * 0.03;
580
595
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
581
596
  ctx.lineWidth = 1 * scaleFactor;
597
+ ctx.beginPath();
582
598
  for (let i = 1; i <= ringCount; i++) {
583
599
  const r = (Math.min(width, height) * 0.15) * i;
584
- ctx.beginPath();
600
+ ctx.moveTo(cx + r, cy);
585
601
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
586
- ctx.stroke();
587
602
  }
588
- ctx.globalCompositeOperation = "source-over";
603
+ ctx.stroke();
589
604
 
590
605
  // ── 1c. Background pattern layer — subtle textured paper ───────
591
606
  const bgPatternRoll = rng();
592
607
  if (bgPatternRoll < 0.6) {
593
608
  ctx.save();
594
- ctx.globalCompositeOperation = "soft-light";
595
609
  const patternOpacity = 0.02 + rng() * 0.04;
596
610
  const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
597
611
 
598
612
  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;
613
+ // Dot grid — use fillRect instead of arcs (much cheaper, no path building)
614
+ const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015));
615
+ const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16));
602
616
  ctx.globalAlpha = patternOpacity;
603
617
  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);
618
+ let dotCount = 0;
619
+ for (let px = 0; px < width && dotCount < 2000; px += dotSpacing) {
620
+ for (let py = 0; py < height && dotCount < 2000; py += dotSpacing) {
621
+ ctx.fillRect(px, py, dotDiam, dotDiam);
622
+ dotCount++;
609
623
  }
610
624
  }
611
- ctx.fill();
612
625
  } 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));
626
+ // Diagonal lines — batched into a single path, capped at 300 lines
627
+ const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02));
615
628
  ctx.globalAlpha = patternOpacity;
616
629
  ctx.strokeStyle = patternColor;
617
630
  ctx.lineWidth = 0.5 * scaleFactor;
618
631
  const diag = Math.hypot(width, height);
619
632
  ctx.beginPath();
620
- for (let d = -diag; d < diag; d += lineSpacing) {
633
+ let lineCount = 0;
634
+ for (let d = -diag; d < diag && lineCount < 300; d += lineSpacing) {
621
635
  ctx.moveTo(d, 0);
622
636
  ctx.lineTo(d + height, height);
637
+ lineCount++;
623
638
  }
624
639
  ctx.stroke();
625
640
  } 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));
641
+ // Tessellation — hexagonal grid, capped at 500 hexagons
642
+ const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02));
628
643
  const tessH = tessSize * Math.sqrt(3);
629
644
  ctx.globalAlpha = patternOpacity * 0.7;
630
645
  ctx.strokeStyle = patternColor;
631
646
  ctx.lineWidth = 0.4 * scaleFactor;
647
+ // Pre-compute hex vertex offsets (avoid trig per vertex)
648
+ const hexVx: number[] = [];
649
+ const hexVy: number[] = [];
650
+ for (let s = 0; s < 6; s++) {
651
+ const angle = (Math.PI / 3) * s - Math.PI / 6;
652
+ hexVx.push(Math.cos(angle) * tessSize * 0.5);
653
+ hexVy.push(Math.sin(angle) * tessSize * 0.5);
654
+ }
632
655
  ctx.beginPath();
633
- for (let row = 0; row * tessH < height + tessH; row++) {
656
+ let hexCount = 0;
657
+ for (let row = 0; row * tessH < height + tessH && hexCount < 500; row++) {
634
658
  const offsetX = (row % 2) * tessSize * 0.75;
635
- for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
659
+ for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++) {
636
660
  const hx = col * tessSize * 1.5 + offsetX;
637
661
  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);
662
+ ctx.moveTo(hx + hexVx[0], hy + hexVy[0]);
663
+ for (let s = 1; s < 6; s++) {
664
+ ctx.lineTo(hx + hexVx[s], hy + hexVy[s]);
644
665
  }
645
666
  ctx.closePath();
667
+ hexCount++;
646
668
  }
647
669
  }
648
670
  ctx.stroke();
@@ -651,6 +673,8 @@ export function renderHashArt(
651
673
  }
652
674
  ctx.globalCompositeOperation = "source-over";
653
675
 
676
+ _mark("1_background");
677
+
654
678
  // ── 2. Composition mode — archetype-aware selection ──────────────
655
679
  const compositionMode: CompositionMode = rng() < 0.7
656
680
  ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
@@ -776,6 +800,8 @@ export function renderHashArt(
776
800
  }
777
801
  ctx.globalAlpha = 1;
778
802
 
803
+ _mark("2_3_composition_focal");
804
+
779
805
  // ── 4. Flow field — simplex noise for organic variation ─────────
780
806
  // Create a seeded simplex noise field (unique per hash)
781
807
  const noiseFieldRng = createRng(seedFromHash(gitHash, 333));
@@ -855,6 +881,8 @@ export function renderHashArt(
855
881
  }
856
882
 
857
883
 
884
+ _mark("4_flowfield_hero");
885
+
858
886
  // ── 5. Shape layers ────────────────────────────────────────────
859
887
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
860
888
 
@@ -1337,6 +1365,8 @@ export function renderHashArt(
1337
1365
 
1338
1366
  // Reset blend mode for post-processing passes
1339
1367
  ctx.globalCompositeOperation = "source-over";
1368
+ if (_dt) { _dt.shapeCount = shapePositions.length; _dt.extraCount = extrasSpent; }
1369
+ _mark("5_shape_layers");
1340
1370
 
1341
1371
  // ── 5g. Layered masking / cutout portals ───────────────────────
1342
1372
  // ~18% of images get 1-3 portal windows that paint over foreground
@@ -1405,6 +1435,8 @@ export function renderHashArt(
1405
1435
  }
1406
1436
 
1407
1437
 
1438
+ _mark("5g_portals");
1439
+
1408
1440
  // ── 6. Flow-line pass — variable color, branching, pressure ────
1409
1441
  // Optimized: collect all segments into width-quantized buckets, then
1410
1442
  // render each bucket as a single batched path. This reduces
@@ -1546,6 +1578,8 @@ export function renderHashArt(
1546
1578
  }
1547
1579
  }
1548
1580
 
1581
+ _mark("6_flow_lines");
1582
+
1549
1583
  // ── 6b. Motion/energy lines — short directional bursts ─────────
1550
1584
  // Optimized: collect all burst segments, then batch by quantized alpha
1551
1585
  const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
@@ -1605,6 +1639,8 @@ export function renderHashArt(
1605
1639
  }
1606
1640
  }
1607
1641
 
1642
+ _mark("6b_energy_lines");
1643
+
1608
1644
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
1609
1645
  if (symmetryMode !== "none") {
1610
1646
  const canvas = ctx.canvas;
@@ -1627,66 +1663,28 @@ export function renderHashArt(
1627
1663
  }
1628
1664
 
1629
1665
 
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.
1666
+ _mark("6c_symmetry");
1667
+
1668
+ // ── 7. Noise texture overlay ─────────────────────────────────────
1669
+ // With density capped at 2500 dots, direct fillRect calls are far cheaper
1670
+ // than the getImageData/putImageData round-trip which copies the entire
1671
+ // pixel buffer (4 × width × height bytes) twice.
1633
1672
  const noiseRng = createRng(seedFromHash(gitHash, 777));
1634
1673
  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
1674
  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
- }
1675
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
1676
+ for (let i = 0; i < noiseDensity; i++) {
1677
+ const nx = noiseRng() * width;
1678
+ const ny = noiseRng() * height;
1679
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1680
+ const alpha = 0.01 + noiseRng() * 0.03;
1681
+ ctx.globalAlpha = alpha;
1682
+ ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`;
1683
+ ctx.fillRect(nx, ny, pixelScale, pixelScale);
1688
1684
  }
1689
1685
 
1686
+ _mark("7_noise_texture");
1687
+
1690
1688
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
1691
1689
  ctx.globalAlpha = 1;
1692
1690
  const vignetteStrength = 0.25 + rng() * 0.2;
@@ -1702,6 +1700,8 @@ export function renderHashArt(
1702
1700
  ctx.fillStyle = vigGrad;
1703
1701
  ctx.fillRect(0, 0, width, height);
1704
1702
 
1703
+ _mark("8_vignette");
1704
+
1705
1705
  // ── 9. Organic connecting curves — proximity-aware ───────────────
1706
1706
  // Optimized: batch all curves into alpha-quantized groups to reduce
1707
1707
  // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
@@ -1770,6 +1770,8 @@ export function renderHashArt(
1770
1770
  }
1771
1771
  }
1772
1772
 
1773
+ _mark("9_connecting_curves");
1774
+
1773
1775
  // ── 10. Post-processing ────────────────────────────────────────
1774
1776
 
1775
1777
  // 10a. Color grading — unified tone across the whole image
@@ -1827,6 +1829,8 @@ export function renderHashArt(
1827
1829
  ctx.globalCompositeOperation = "source-over";
1828
1830
  }
1829
1831
 
1832
+ _mark("10_post_processing");
1833
+
1830
1834
  // ── 10e. Generative borders — archetype-driven decorative frames ──
1831
1835
  {
1832
1836
  ctx.save();
@@ -1973,6 +1977,8 @@ export function renderHashArt(
1973
1977
  ctx.restore();
1974
1978
  }
1975
1979
 
1980
+ _mark("10e_borders");
1981
+
1976
1982
  // ── 11. Signature mark — placed in the least-dense corner ──────
1977
1983
  {
1978
1984
  const sigRng = createRng(seedFromHash(gitHash, 42));
@@ -2036,5 +2042,6 @@ export function renderHashArt(
2036
2042
  }
2037
2043
 
2038
2044
  ctx.globalAlpha = 1;
2045
+ _mark("11_signature");
2039
2046
 
2040
2047
  }
package/src/types.ts CHANGED
@@ -20,6 +20,8 @@ export interface GenerationConfig {
20
20
  opacityReduction: number;
21
21
  /** Base shapes per layer — defaults to gridSize² × 1.5 when 0 */
22
22
  shapesPerLayer: number;
23
+ /** Internal: collect per-phase timing data when set (not part of public API) */
24
+ _debugTiming?: { phases: Record<string, number>; shapeCount: number; extraCount: number };
23
25
  }
24
26
 
25
27
  export const DEFAULT_CONFIG: GenerationConfig = {