@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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/address.d.ts +26 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +61 -0
- package/dist/address.js.map +1 -0
- package/dist/blo.d.ts +15 -0
- package/dist/blo.d.ts.map +1 -0
- package/dist/blo.js +86 -0
- package/dist/blo.js.map +1 -0
- package/dist/hex.d.ts +4 -0
- package/dist/hex.d.ts.map +1 -0
- package/dist/hex.js +26 -0
- package/dist/hex.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/keccak.d.ts +8 -0
- package/dist/keccak.d.ts.map +1 -0
- package/dist/keccak.js +15 -0
- package/dist/keccak.js.map +1 -0
- package/dist/miner.d.ts +45 -0
- package/dist/miner.d.ts.map +1 -0
- package/dist/miner.js +97 -0
- package/dist/miner.js.map +1 -0
- package/dist/scoring.d.ts +27 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +185 -0
- package/dist/scoring.js.map +1 -0
- package/dist/select.d.ts +47 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +49 -0
- package/dist/select.js.map +1 -0
- package/dist/templates.d.ts +18 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +140 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/address.ts +89 -0
- package/src/blo.ts +100 -0
- package/src/hex.ts +25 -0
- package/src/index.ts +60 -0
- package/src/keccak.ts +17 -0
- package/src/miner.ts +133 -0
- package/src/scoring.ts +209 -0
- package/src/select.ts +77 -0
- package/src/templates.ts +163 -0
- 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
|
+
}
|
package/src/templates.ts
ADDED
|
@@ -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
|
+
}
|