@safe-vanity-blockie/core 0.3.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/address.d.ts +26 -0
  4. package/dist/address.d.ts.map +1 -0
  5. package/dist/address.js +61 -0
  6. package/dist/address.js.map +1 -0
  7. package/dist/blo.d.ts +15 -0
  8. package/dist/blo.d.ts.map +1 -0
  9. package/dist/blo.js +86 -0
  10. package/dist/blo.js.map +1 -0
  11. package/dist/hex.d.ts +4 -0
  12. package/dist/hex.d.ts.map +1 -0
  13. package/dist/hex.js +26 -0
  14. package/dist/hex.js.map +1 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +9 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/keccak.d.ts +8 -0
  20. package/dist/keccak.d.ts.map +1 -0
  21. package/dist/keccak.js +15 -0
  22. package/dist/keccak.js.map +1 -0
  23. package/dist/miner.d.ts +45 -0
  24. package/dist/miner.d.ts.map +1 -0
  25. package/dist/miner.js +97 -0
  26. package/dist/miner.js.map +1 -0
  27. package/dist/scoring.d.ts +27 -0
  28. package/dist/scoring.d.ts.map +1 -0
  29. package/dist/scoring.js +185 -0
  30. package/dist/scoring.js.map +1 -0
  31. package/dist/select.d.ts +47 -0
  32. package/dist/select.d.ts.map +1 -0
  33. package/dist/select.js +49 -0
  34. package/dist/select.js.map +1 -0
  35. package/dist/templates.d.ts +18 -0
  36. package/dist/templates.d.ts.map +1 -0
  37. package/dist/templates.js +140 -0
  38. package/dist/templates.js.map +1 -0
  39. package/dist/types.d.ts +60 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +1 -0
  43. package/package.json +52 -0
  44. package/src/address.ts +89 -0
  45. package/src/blo.ts +100 -0
  46. package/src/hex.ts +25 -0
  47. package/src/index.ts +60 -0
  48. package/src/keccak.ts +17 -0
  49. package/src/miner.ts +133 -0
  50. package/src/scoring.ts +209 -0
  51. package/src/select.ts +77 -0
  52. package/src/templates.ts +163 -0
  53. package/src/types.ts +66 -0
package/src/select.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { Candidate } from './miner.js'
2
+
3
+ /**
4
+ * A score as a percentage of the template's maximum, rounded to the one decimal a result is
5
+ * displayed with. `formatScore` renders exactly this number, and `filterCandidates` compares
6
+ * exactly this number against the match floor, so the figure on a tile and the figure the filter
7
+ * judges it by are the same figure: 89.96% displays as "90.0%" and must not then be dropped by a
8
+ * 90% floor over a difference nothing on screen can show.
9
+ */
10
+ export function scorePercent(score: number, maxScore: number): number {
11
+ if (maxScore <= 0) return 0
12
+ return Number(((score / maxScore) * 100).toFixed(1))
13
+ }
14
+
15
+ export function filterCandidates(
16
+ candidates: Candidate[],
17
+ filters: { twoColor: boolean; minContrast: number; minMatch?: number },
18
+ ): Candidate[] {
19
+ const minMatch = filters.minMatch ?? 0
20
+ return candidates.filter(
21
+ (candidate) =>
22
+ (!filters.twoColor || candidate.twoColor) &&
23
+ candidate.contrast >= filters.minContrast &&
24
+ scorePercent(candidate.score, candidate.maxScore) >= minMatch,
25
+ )
26
+ }
27
+
28
+ export interface SelectReportedResult {
29
+ reported: Candidate[]
30
+ /** How many candidates the filters removed. Zero when the fallback was used. */
31
+ droppedCount: number
32
+ /** True when filtering removed everything and the unfiltered list is being shown instead. */
33
+ usedFallback: boolean
34
+ }
35
+
36
+ /**
37
+ * Retention is score-ranked and blind to twoColor/minContrast/minMatch, which are applied here —
38
+ * so callers must retain far more candidates than they intend to display, or the filters will
39
+ * have nothing left to choose from.
40
+ *
41
+ * `fallbackWhenEmpty` defaults to true, which shows the unfiltered list rather than nothing when
42
+ * the filters match no candidate at all. That suits a caller that can say so alongside the
43
+ * results — the CLI prints a notice when `usedFallback` comes back true. A caller that can render
44
+ * an explicit "nothing matches these filters" state should pass false instead: silently showing
45
+ * everything makes the filter look ignored rather than unsatisfied. With it off, `droppedCount`
46
+ * stops being a special case and always reports what the filters actually removed.
47
+ */
48
+ export function selectReported(
49
+ candidates: Candidate[],
50
+ options: {
51
+ twoColor: boolean
52
+ minContrast: number
53
+ /** Percentage floor, 0-100, judged as `scorePercent`. Omitted or 0 filters nothing. */
54
+ minMatch?: number
55
+ keep: number
56
+ fallbackWhenEmpty?: boolean
57
+ },
58
+ ): SelectReportedResult {
59
+ const filtered = filterCandidates(candidates, {
60
+ twoColor: options.twoColor,
61
+ minContrast: options.minContrast,
62
+ minMatch: options.minMatch,
63
+ })
64
+ const usedFallback =
65
+ (options.fallbackWhenEmpty ?? true) && filtered.length === 0 && candidates.length > 0
66
+ const usable = usedFallback ? candidates : filtered
67
+ const droppedCount = usedFallback ? 0 : candidates.length - filtered.length
68
+ return { reported: usable.slice(0, options.keep), droppedCount, usedFallback }
69
+ }
70
+
71
+ /**
72
+ * A score as a percentage of the template's maximum. One decimal, because the interesting
73
+ * results sit in a narrow band near the top and whole percent would collapse distinct scores.
74
+ */
75
+ export function formatScore(score: number, maxScore: number): string {
76
+ return `${scorePercent(score, maxScore).toFixed(1)}%`
77
+ }
@@ -0,0 +1,163 @@
1
+ import type { FaceRegion, FaceSpec, FixedCell, RegionAlternative } from './types.js'
2
+
3
+ export const MOUTH_BUDGET = 60
4
+ export const MOUTH_STROKE_WEIGHT = 3
5
+ export const MOUTH_BG_WEIGHT = 1
6
+
7
+ /** Rows 0-4, index = row * 4 + col. 1 = the eye pixel (mirrored to column 5), 0 = background. */
8
+ export const BASE_TARGET: readonly (0 | 1)[] = [
9
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
10
+ ]
11
+
12
+ /** 8 = the eye, 5 = cells hugging it (isolation, incl. col 3 to keep the two eyes apart), 3 = plain background. */
13
+ export const BASE_WEIGHTS: readonly number[] = [
14
+ 3, 3, 3, 3, 3, 3, 5, 3, 3, 5, 8, 5, 3, 3, 5, 3, 3, 3, 3, 3,
15
+ ]
16
+
17
+ /** Rows 5-7 of the left half: r5c0..r5c3, r6c0..r6c3, r7c0..r7c3. */
18
+ export const MOUTH_INDICES: readonly number[] = [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
19
+
20
+ export const MOUTHS: readonly RegionAlternative[] = [
21
+ // r5: c0 c1 c2 c3 r6: c0 c1 c2 c3 r7: c0 c1 c2 c3
22
+ { name: 'smile', cells: [0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }, // corners up, dips centre
23
+ { name: 'frown', cells: [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0] }, // corners down
24
+ { name: 'neutral', cells: [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0] }, // straight line
25
+ { name: 'open', cells: [0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1] }, // rounded "o" / surprised
26
+ { name: 'small', cells: [0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0] }, // little mouth
27
+ ]
28
+
29
+ function baseFixedCells(): FixedCell[] {
30
+ return BASE_TARGET.map((value, index) => ({ index, value, weight: BASE_WEIGHTS[index] }))
31
+ }
32
+
33
+ function mouthRegion(alternatives: RegionAlternative[]): FaceRegion {
34
+ return {
35
+ name: 'mouth',
36
+ indices: [...MOUTH_INDICES],
37
+ budget: MOUTH_BUDGET,
38
+ strokeWeight: MOUTH_STROKE_WEIGHT,
39
+ bgWeight: MOUTH_BG_WEIGHT,
40
+ alternatives,
41
+ }
42
+ }
43
+
44
+ /** Builds a face with fixed eyes and the named subset of expressions accepted for the mouth. */
45
+ export function faceWithMouths(name: string, mouthNames: string[]): FaceSpec {
46
+ const alternatives = mouthNames.map((mouthName) => {
47
+ const mouth = MOUTHS.find((candidate) => candidate.name === mouthName)
48
+ if (!mouth) {
49
+ throw new Error(
50
+ `unknown mouth "${mouthName}"; available: ${MOUTHS.map((m) => m.name).join(', ')}`,
51
+ )
52
+ }
53
+ return { name: mouth.name, cells: [...mouth.cells] }
54
+ })
55
+ return { name, fixed: baseFixedCells(), regions: [mouthRegion(alternatives)] }
56
+ }
57
+
58
+ // Built on a null prototype so a lookup by an attacker- or URL-controlled name can never resolve
59
+ // an inherited Object.prototype key (e.g. "constructor", "toString", "__proto__") instead of
60
+ // throwing the intended "unknown template" error.
61
+ export const TEMPLATES: Record<string, FaceSpec> = Object.assign(Object.create(null), {
62
+ faces: faceWithMouths(
63
+ 'faces',
64
+ MOUTHS.map((mouth) => mouth.name),
65
+ ),
66
+ ...Object.fromEntries(
67
+ MOUTHS.map((mouth) => [mouth.name, faceWithMouths(mouth.name, [mouth.name])]),
68
+ ),
69
+ })
70
+
71
+ export function getTemplate(name: string): FaceSpec {
72
+ if (!Object.hasOwn(TEMPLATES, name)) {
73
+ throw new Error(`unknown template "${name}"; available: ${Object.keys(TEMPLATES).join(', ')}`)
74
+ }
75
+ return TEMPLATES[name]
76
+ }
77
+
78
+ /** Guards the `entry as Record<string, unknown>` casts below: a null or primitive entry would
79
+ * otherwise surface as a raw TypeError from the first property read. */
80
+ function requireObject(value: unknown, label: string): Record<string, unknown> {
81
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
82
+ throw new Error(`${label} must be an object, got ${JSON.stringify(value)}`)
83
+ }
84
+ return value as Record<string, unknown>
85
+ }
86
+
87
+ function requirePositiveInteger(value: unknown, label: string): number {
88
+ if (!Number.isInteger(value) || (value as number) <= 0) {
89
+ throw new Error(`${label} must be a positive integer, got ${JSON.stringify(value)}`)
90
+ }
91
+ return value as number
92
+ }
93
+
94
+ /** Validates untrusted JSON (a `--target file.json`, or a future web designer export). */
95
+ export function parseFaceSpec(input: unknown): FaceSpec {
96
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
97
+ throw new Error('FaceSpec must be an object')
98
+ }
99
+ const raw = input as Record<string, unknown>
100
+ const name = typeof raw.name === 'string' && raw.name ? raw.name : 'custom'
101
+
102
+ const fixedInput = Array.isArray(raw.fixed) ? raw.fixed : []
103
+ const fixed: FixedCell[] = fixedInput.map((entry, i) => {
104
+ const cell = requireObject(entry, `fixed[${i}]`)
105
+ const index = cell.index
106
+ if (!Number.isInteger(index) || (index as number) < 0 || (index as number) > 31) {
107
+ throw new Error(`fixed[${i}].index must be an integer between 0 and 31`)
108
+ }
109
+ if (cell.value !== 0 && cell.value !== 1) {
110
+ throw new Error(`fixed[${i}].value must be 0 or 1`)
111
+ }
112
+ return {
113
+ index: index as number,
114
+ value: cell.value as 0 | 1,
115
+ weight: requirePositiveInteger(cell.weight, `fixed[${i}].weight`),
116
+ }
117
+ })
118
+
119
+ const regionsInput = Array.isArray(raw.regions) ? raw.regions : []
120
+ const regions: FaceRegion[] = regionsInput.map((entry, r) => {
121
+ const label = `regions[${r}]`
122
+ const region = requireObject(entry, label)
123
+ if (!Array.isArray(region.indices) || region.indices.length === 0) {
124
+ throw new Error(`${label}.indices must be a non-empty array`)
125
+ }
126
+ for (const index of region.indices) {
127
+ if (!Number.isInteger(index) || index < 0 || index > 31) {
128
+ throw new Error(`${label}.indices must all be integers between 0 and 31`)
129
+ }
130
+ }
131
+ if (!Array.isArray(region.alternatives) || region.alternatives.length === 0) {
132
+ throw new Error(`${label}.alternatives must contain at least one alternative`)
133
+ }
134
+ const alternatives: RegionAlternative[] = region.alternatives.map((altEntry, a) => {
135
+ const alternative = requireObject(altEntry, `${label}.alternatives[${a}]`)
136
+ if (!Array.isArray(alternative.cells)) {
137
+ throw new Error(`${label}.alternatives[${a}].cells must be an array`)
138
+ }
139
+ for (const cell of alternative.cells) {
140
+ if (cell !== 0 && cell !== 1) {
141
+ throw new Error(`${label}.alternatives[${a}].cells value must be 0 or 1`)
142
+ }
143
+ }
144
+ return {
145
+ name: typeof alternative.name === 'string' ? alternative.name : `alt${a}`,
146
+ cells: alternative.cells as (0 | 1)[],
147
+ }
148
+ })
149
+ return {
150
+ name: typeof region.name === 'string' ? region.name : `region${r}`,
151
+ indices: region.indices as number[],
152
+ budget: requirePositiveInteger(region.budget, `${label}.budget`),
153
+ strokeWeight: requirePositiveInteger(region.strokeWeight, `${label}.strokeWeight`),
154
+ bgWeight: requirePositiveInteger(region.bgWeight, `${label}.bgWeight`),
155
+ alternatives,
156
+ }
157
+ })
158
+
159
+ if (fixed.length === 0 && regions.length === 0) {
160
+ throw new Error('FaceSpec must define at least one fixed cell or region')
161
+ }
162
+ return { name, fixed, regions }
163
+ }
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ /** Hue 0-360, saturation 0-100, lightness 0-100 — the ranges blo emits. */
2
+ export type Hsl = readonly [number, number, number]
3
+
4
+ /** blo's palette, in the order blo returns it: background, color, spot. */
5
+ export type Palette = readonly [Hsl, Hsl, Hsl]
6
+
7
+ export interface BloImage {
8
+ /** 32 values in {0,1,2} for the left half of the 8x8 grid; column c mirrors to 7-c. */
9
+ readonly data: Uint8Array
10
+ readonly colors: Palette
11
+ }
12
+
13
+ /** A cell whose target value is pinned. `index` is 0..31 in the left half of the grid. */
14
+ export interface FixedCell {
15
+ index: number
16
+ value: 0 | 1
17
+ weight: number
18
+ }
19
+
20
+ /** One accepted shape for a region, e.g. a single mouth expression. */
21
+ export interface RegionAlternative {
22
+ name: string
23
+ /** Aligned 1:1 with the region's `indices`. */
24
+ cells: (0 | 1)[]
25
+ }
26
+
27
+ /**
28
+ * A group of cells scored as `max` over a list of alternatives. Every alternative is normalised
29
+ * to the same `budget`, so no shape wins just for having more stroke pixels.
30
+ */
31
+ export interface FaceRegion {
32
+ name: string
33
+ indices: number[]
34
+ budget: number
35
+ /** Relative weight of a cell that should be foreground. */
36
+ strokeWeight: number
37
+ /** Relative weight of a cell that should stay background. */
38
+ bgWeight: number
39
+ alternatives: RegionAlternative[]
40
+ }
41
+
42
+ export interface FaceSpec {
43
+ name: string
44
+ fixed: FixedCell[]
45
+ regions: FaceRegion[]
46
+ }
47
+
48
+ /** Flattened, integer-only form of a FaceSpec. Built once; read millions of times. */
49
+ export interface CompiledFace {
50
+ readonly name: string
51
+ readonly maxScore: number
52
+ readonly nFixed: number
53
+ readonly fixedIndex: Uint8Array
54
+ readonly fixedTarget: Uint8Array
55
+ readonly fixedWeight: Int32Array
56
+ readonly nRegions: number
57
+ readonly regionIndexStart: Int32Array
58
+ readonly regionLength: Int32Array
59
+ readonly regionAltCount: Int32Array
60
+ readonly regionCellStart: Int32Array
61
+ readonly regionIndex: Uint8Array
62
+ readonly regionCells: Uint8Array
63
+ readonly regionWeights: Int32Array
64
+ readonly regionNames: readonly string[]
65
+ readonly regionAltNames: readonly (readonly string[])[]
66
+ }