@brickflow/cli 0.0.5 → 0.0.7

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.
@@ -0,0 +1,928 @@
1
+ import crypto from 'crypto'
2
+ import fs from 'fs'
3
+ import { globSync } from 'glob'
4
+ import looksSame from 'looks-same'
5
+ import path from 'path'
6
+ import sharp from 'sharp'
7
+ import { optimize } from 'svgo'
8
+
9
+ const args = process.argv.slice(3)
10
+
11
+ if (args.includes('--help') || args.includes('-h')) {
12
+ printHelp()
13
+ process.exit(0)
14
+ }
15
+
16
+ const options = parseArgs(args)
17
+
18
+ if (!options.path) {
19
+ printHelp()
20
+ process.exit(1)
21
+ }
22
+
23
+ const iconsDir = path.resolve(process.cwd(), options.path)
24
+ const scopeName = options.name || path.basename(iconsDir)
25
+
26
+ if (!fs.existsSync(iconsDir)) {
27
+ throw new Error(`Icon directory not found: ${iconsDir}`)
28
+ }
29
+
30
+ if (!fs.statSync(iconsDir).isDirectory()) {
31
+ throw new Error(`Icon path must be a directory: ${iconsDir}`)
32
+ }
33
+
34
+ const allowedDuplicateIconPairs = new Set([
35
+ 'bounty::affiliate-block-percent::clock-filled',
36
+ 'bounty::cs2-icon-mobile::statistics-stat-players_total',
37
+ ])
38
+
39
+ const D_HASH_SIZE = 32
40
+ const STRICT_HASH_SIZE = 128
41
+ const PHASH_IMAGE_SIZE = 32
42
+ const PHASH_MATRIX_SIZE = 8
43
+ const ALPHA_THRESHOLD = 8
44
+ const SIMPLE_SIMILARITY_THRESHOLD = 90
45
+ const SIMPLE_IOU_THRESHOLD_PERCENT = 70
46
+ const MAX_FILL_RATIO_DELTA_PERCENT = 10
47
+ const STEP2_DIRECT_SIMILARITY_MIN = 88
48
+ const STEP2_DIRECT_IOU_MIN = 92
49
+ const STEP2_DIRECT_FILL_DELTA_MAX = 8
50
+ const LOOKS_SAME_CONCURRENCY = 8
51
+ const LOOKS_SAME_OPTIONS = {
52
+ ignoreAntialiasing: false,
53
+ tolerance: 65,
54
+ }
55
+
56
+ function addEdgeByIndexes(graph, entries, firstIndex, secondIndex) {
57
+ addUndirectedEdge(graph, entries[firstIndex].filePath, entries[secondIndex].filePath)
58
+ }
59
+
60
+ function addUndirectedEdge(graph, firstNode, secondNode) {
61
+ graph.get(firstNode)?.add(secondNode)
62
+ graph.get(secondNode)?.add(firstNode)
63
+ }
64
+
65
+ function buildDuplicateGroups(graph, projectDir) {
66
+ const visited = new Set()
67
+ const groups = []
68
+
69
+ for (const filePath of [...graph.keys()].sort((first, second) => first.localeCompare(second))) {
70
+ if (visited.has(filePath)) {
71
+ continue
72
+ }
73
+
74
+ const neighbors = graph.get(filePath)
75
+
76
+ if (!neighbors || neighbors.size === 0) {
77
+ continue
78
+ }
79
+
80
+ const queue = [filePath]
81
+ const component = []
82
+ visited.add(filePath)
83
+
84
+ while (queue.length > 0) {
85
+ const currentFilePath = queue.shift()
86
+
87
+ if (!currentFilePath) {
88
+ continue
89
+ }
90
+
91
+ component.push(currentFilePath)
92
+
93
+ for (const neighborPath of graph.get(currentFilePath) ?? []) {
94
+ if (visited.has(neighborPath)) {
95
+ continue
96
+ }
97
+
98
+ visited.add(neighborPath)
99
+ queue.push(neighborPath)
100
+ }
101
+ }
102
+
103
+ if (component.length > 1) {
104
+ const iconNames = component
105
+ .map((iconPath) => path.relative(projectDir, iconPath).replace(/\.svg$/u, ''))
106
+ .sort((first, second) => first.localeCompare(second))
107
+
108
+ groups.push({
109
+ duplicateIcons: iconNames.slice(1),
110
+ iconName: iconNames[0],
111
+ })
112
+ }
113
+ }
114
+
115
+ return groups.sort((first, second) => first.iconName.localeCompare(second.iconName))
116
+ }
117
+
118
+ function buildExactHashMap(entries) {
119
+ const dHashToIndexes = new Map()
120
+
121
+ entries.forEach((entry, index) => {
122
+ if (!dHashToIndexes.has(entry.dHash)) {
123
+ dHashToIndexes.set(entry.dHash, [])
124
+ }
125
+
126
+ dHashToIndexes.get(entry.dHash).push(index)
127
+ })
128
+
129
+ const exactGroups = []
130
+
131
+ for (const indexes of dHashToIndexes.values()) {
132
+ if (indexes.length < 2) {
133
+ continue
134
+ }
135
+
136
+ const strictHashToIndexes = new Map()
137
+
138
+ for (const index of indexes) {
139
+ const strictHash = entries[index].strictHash
140
+
141
+ if (!strictHashToIndexes.has(strictHash)) {
142
+ strictHashToIndexes.set(strictHash, [])
143
+ }
144
+
145
+ strictHashToIndexes.get(strictHash).push(index)
146
+ }
147
+
148
+ for (const strictIndexes of strictHashToIndexes.values()) {
149
+ if (strictIndexes.length > 1) {
150
+ exactGroups.push(strictIndexes)
151
+ }
152
+ }
153
+ }
154
+
155
+ return exactGroups
156
+ }
157
+
158
+ function buildSimpleCandidatePairs(entries, exactPairKeys, progressTracker) {
159
+ const totalPairs = (entries.length * (entries.length - 1)) / 2
160
+
161
+ if (totalPairs === 0) {
162
+ return {
163
+ candidatePairs: [],
164
+ directPairs: [],
165
+ totalPairs,
166
+ }
167
+ }
168
+
169
+ const candidatePairs = []
170
+ const directPairs = []
171
+
172
+ for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
173
+ for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
174
+ progressTracker?.tick()
175
+
176
+ const pairKey = pairKeyFromIndexes(leftIndex, rightIndex)
177
+
178
+ if (exactPairKeys.has(pairKey)) {
179
+ continue
180
+ }
181
+
182
+ const leftEntry = entries[leftIndex]
183
+ const rightEntry = entries[rightIndex]
184
+ const hammingDistance = calculateHammingDistance(leftEntry.shapeHash, rightEntry.shapeHash)
185
+ const similarityPercent = Math.round((1 - hammingDistance / leftEntry.shapeHash.length) * 100)
186
+
187
+ if (similarityPercent < SIMPLE_SIMILARITY_THRESHOLD) {
188
+ continue
189
+ }
190
+
191
+ const iouPercent = calculateIoU(leftEntry.normalizedMask, rightEntry.normalizedMask)
192
+
193
+ if (iouPercent < SIMPLE_IOU_THRESHOLD_PERCENT) {
194
+ continue
195
+ }
196
+
197
+ const fillRatioDeltaPercent = Math.abs(leftEntry.fillRatio - rightEntry.fillRatio) * 100
198
+
199
+ if (fillRatioDeltaPercent > MAX_FILL_RATIO_DELTA_PERCENT) {
200
+ continue
201
+ }
202
+
203
+ const shouldSendDirectToResult =
204
+ similarityPercent >= STEP2_DIRECT_SIMILARITY_MIN &&
205
+ iouPercent >= STEP2_DIRECT_IOU_MIN &&
206
+ fillRatioDeltaPercent <= STEP2_DIRECT_FILL_DELTA_MAX &&
207
+ leftEntry.componentsCount === rightEntry.componentsCount
208
+
209
+ if (shouldSendDirectToResult) {
210
+ directPairs.push([leftIndex, rightIndex])
211
+ } else {
212
+ candidatePairs.push([leftIndex, rightIndex])
213
+ }
214
+ }
215
+ }
216
+
217
+ return {
218
+ candidatePairs,
219
+ directPairs,
220
+ totalPairs,
221
+ }
222
+ }
223
+
224
+ function calculateHammingDistance(firstBits, secondBits) {
225
+ let distance = 0
226
+
227
+ for (let bitIndex = 0; bitIndex < firstBits.length; bitIndex += 1) {
228
+ if (firstBits[bitIndex] !== secondBits[bitIndex]) {
229
+ distance += 1
230
+ }
231
+ }
232
+
233
+ return distance
234
+ }
235
+
236
+ function calculateIoU(firstMask, secondMask) {
237
+ let intersection = 0
238
+ let union = 0
239
+
240
+ for (let index = 0; index < firstMask.length; index += 1) {
241
+ const firstFilled = firstMask[index] === 1
242
+ const secondFilled = secondMask[index] === 1
243
+
244
+ if (firstFilled && secondFilled) {
245
+ intersection += 1
246
+ }
247
+
248
+ if (firstFilled || secondFilled) {
249
+ union += 1
250
+ }
251
+ }
252
+
253
+ if (union === 0) {
254
+ return 0
255
+ }
256
+
257
+ return (intersection / union) * 100
258
+ }
259
+
260
+ function countConnectedComponents(mask, size) {
261
+ const totalPixels = size * size
262
+ const visited = new Uint8Array(totalPixels)
263
+ const queue = []
264
+ const directions = [
265
+ [1, 0],
266
+ [-1, 0],
267
+ [0, 1],
268
+ [0, -1],
269
+ ]
270
+
271
+ let componentsCount = 0
272
+
273
+ for (let pixelIndex = 0; pixelIndex < totalPixels; pixelIndex += 1) {
274
+ if (visited[pixelIndex] || mask[pixelIndex] !== 1) {
275
+ continue
276
+ }
277
+
278
+ componentsCount += 1
279
+ visited[pixelIndex] = 1
280
+ queue.push(pixelIndex)
281
+
282
+ while (queue.length > 0) {
283
+ const currentPixelIndex = queue.pop()
284
+ const x = currentPixelIndex % size
285
+ const y = Math.floor(currentPixelIndex / size)
286
+
287
+ for (const [deltaX, deltaY] of directions) {
288
+ const nextX = x + deltaX
289
+ const nextY = y + deltaY
290
+
291
+ if (nextX < 0 || nextY < 0 || nextX >= size || nextY >= size) {
292
+ continue
293
+ }
294
+
295
+ const nextPixelIndex = nextY * size + nextX
296
+
297
+ if (visited[nextPixelIndex] || mask[nextPixelIndex] !== 1) {
298
+ continue
299
+ }
300
+
301
+ visited[nextPixelIndex] = 1
302
+ queue.push(nextPixelIndex)
303
+ }
304
+ }
305
+ }
306
+
307
+ return componentsCount
308
+ }
309
+
310
+ function createDHashFromRawResult(rawResult) {
311
+ const width = D_HASH_SIZE + 1
312
+ const height = D_HASH_SIZE
313
+ const { data: rgbaPixels, info } = rawResult
314
+ const channels = info.channels
315
+
316
+ let bitString = ''
317
+
318
+ for (let y = 0; y < height; y += 1) {
319
+ const rowStart = y * width
320
+
321
+ for (let x = 0; x < width - 1; x += 1) {
322
+ const leftAlphaIndex = (rowStart + x) * channels + 3
323
+ const rightAlphaIndex = (rowStart + x + 1) * channels + 3
324
+ const left = rgbaPixels[leftAlphaIndex]
325
+ const right = rgbaPixels[rightAlphaIndex]
326
+ bitString += left > right ? '1' : '0'
327
+ }
328
+ }
329
+
330
+ return bitString
331
+ }
332
+
333
+ async function createIconArtifacts(normalizedSvg, svgPath) {
334
+ try {
335
+ const [dHashRaw, strictHashRaw, shapeRaw, looksSamePng] = await Promise.all([
336
+ renderForDHash(normalizedSvg),
337
+ renderForStrictHash(normalizedSvg),
338
+ renderForShape(normalizedSvg),
339
+ renderForLooksSameNormalized(normalizedSvg),
340
+ ])
341
+
342
+ const dHash = createDHashFromRawResult(dHashRaw)
343
+ const strictHash = createStrictHashFromRawResult(strictHashRaw)
344
+ const { componentsCount, fillRatio, normalizedMask } = createNormalizedMaskFromRawShape(shapeRaw)
345
+ const shapeHash = createPHash(normalizedMask)
346
+
347
+ return {
348
+ componentsCount,
349
+ dHash,
350
+ fillRatio,
351
+ looksSamePng,
352
+ normalizedMask,
353
+ shapeHash,
354
+ strictHash,
355
+ }
356
+ } catch (error) {
357
+ throw new Error(`Failed to process SVG: ${svgPath}\n${error.message}`, { cause: error })
358
+ }
359
+ }
360
+
361
+ function createNormalizedMaskFromRawShape(rawResult) {
362
+ const { data: rgbaPixels, info } = rawResult
363
+ const channels = info.channels
364
+ const size = PHASH_IMAGE_SIZE
365
+ const alphaMask = new Uint8Array(size * size)
366
+
367
+ for (let pixelIndex = 0; pixelIndex < alphaMask.length; pixelIndex += 1) {
368
+ alphaMask[pixelIndex] = rgbaPixels[pixelIndex * channels + 3] > ALPHA_THRESHOLD ? 1 : 0
369
+ }
370
+
371
+ let minX = size
372
+ let minY = size
373
+ let maxX = -1
374
+ let maxY = -1
375
+ let filledPixels = 0
376
+
377
+ for (let y = 0; y < size; y += 1) {
378
+ for (let x = 0; x < size; x += 1) {
379
+ const value = alphaMask[y * size + x]
380
+
381
+ if (value === 0) {
382
+ continue
383
+ }
384
+
385
+ filledPixels += 1
386
+ minX = Math.min(minX, x)
387
+ minY = Math.min(minY, y)
388
+ maxX = Math.max(maxX, x)
389
+ maxY = Math.max(maxY, y)
390
+ }
391
+ }
392
+
393
+ if (maxX === -1 || maxY === -1) {
394
+ return {
395
+ componentsCount: 0,
396
+ fillRatio: 0,
397
+ normalizedMask: alphaMask,
398
+ }
399
+ }
400
+
401
+ const boxWidth = maxX - minX + 1
402
+ const boxHeight = maxY - minY + 1
403
+ const normalizedMask = new Uint8Array(size * size)
404
+
405
+ for (let y = 0; y < size; y += 1) {
406
+ const sourceY = minY + Math.min(boxHeight - 1, Math.floor((y / size) * boxHeight))
407
+
408
+ for (let x = 0; x < size; x += 1) {
409
+ const sourceX = minX + Math.min(boxWidth - 1, Math.floor((x / size) * boxWidth))
410
+ normalizedMask[y * size + x] = alphaMask[sourceY * size + sourceX]
411
+ }
412
+ }
413
+
414
+ return {
415
+ componentsCount: countConnectedComponents(normalizedMask, size),
416
+ fillRatio: filledPixels / (size * size),
417
+ normalizedMask,
418
+ }
419
+ }
420
+
421
+ function createPHash(mask) {
422
+ const matrix = []
423
+
424
+ for (let rowIndex = 0; rowIndex < PHASH_IMAGE_SIZE; rowIndex += 1) {
425
+ const row = new Float64Array(PHASH_IMAGE_SIZE)
426
+
427
+ for (let columnIndex = 0; columnIndex < PHASH_IMAGE_SIZE; columnIndex += 1) {
428
+ row[columnIndex] = mask[rowIndex * PHASH_IMAGE_SIZE + columnIndex]
429
+ }
430
+
431
+ matrix.push(row)
432
+ }
433
+
434
+ const dct = dct2d(matrix, PHASH_IMAGE_SIZE)
435
+ const lowFrequencyValues = []
436
+
437
+ for (let rowIndex = 0; rowIndex < PHASH_MATRIX_SIZE; rowIndex += 1) {
438
+ for (let columnIndex = 0; columnIndex < PHASH_MATRIX_SIZE; columnIndex += 1) {
439
+ if (rowIndex === 0 && columnIndex === 0) {
440
+ continue
441
+ }
442
+
443
+ lowFrequencyValues.push(dct[rowIndex][columnIndex])
444
+ }
445
+ }
446
+
447
+ const sortedValues = [...lowFrequencyValues].sort((first, second) => first - second)
448
+ const median = sortedValues[Math.floor(sortedValues.length / 2)]
449
+ const bits = new Uint8Array(PHASH_MATRIX_SIZE * PHASH_MATRIX_SIZE)
450
+ let bitIndex = 0
451
+
452
+ for (let rowIndex = 0; rowIndex < PHASH_MATRIX_SIZE; rowIndex += 1) {
453
+ for (let columnIndex = 0; columnIndex < PHASH_MATRIX_SIZE; columnIndex += 1) {
454
+ if (rowIndex === 0 && columnIndex === 0) {
455
+ bits[bitIndex] = 0
456
+ } else {
457
+ bits[bitIndex] = dct[rowIndex][columnIndex] > median ? 1 : 0
458
+ }
459
+
460
+ bitIndex += 1
461
+ }
462
+ }
463
+
464
+ return bits
465
+ }
466
+
467
+ function createProgressTracker(label, total) {
468
+ let done = 0
469
+ const safeTotal = Math.max(total, 1)
470
+ let currentTotal = safeTotal
471
+ const barWidth = 18
472
+ let lastRenderedPercent = -1
473
+
474
+ const render = (force = false) => {
475
+ const percent = Math.round((done / currentTotal) * 100)
476
+
477
+ if (!force && percent === lastRenderedPercent) {
478
+ return
479
+ }
480
+
481
+ const filledCells = Math.round((percent * barWidth) / 100)
482
+ const bar = `${'#'.repeat(filledCells)}${'.'.repeat(barWidth - filledCells)}`
483
+
484
+ process.stdout.write(`\r ${label}: [${bar}] ${String(percent).padStart(3)}% ${done}/${currentTotal}`)
485
+
486
+ lastRenderedPercent = percent
487
+
488
+ if (done >= currentTotal) {
489
+ process.stdout.write('\n')
490
+ }
491
+ }
492
+
493
+ render(true)
494
+
495
+ const tick = (step = 1) => {
496
+ done = Math.min(done + step, currentTotal)
497
+ render(done >= currentTotal)
498
+ }
499
+
500
+ return {
501
+ setLabel(nextLabel) {
502
+ label = nextLabel
503
+ render(true)
504
+ },
505
+ setTotal(nextTotal) {
506
+ currentTotal = Math.max(nextTotal, 1)
507
+ done = Math.min(done, currentTotal)
508
+ render(true)
509
+ },
510
+ tick,
511
+ }
512
+ }
513
+
514
+ function createStrictHashFromRawResult(rawResult) {
515
+ const { data: rgbaPixels, info } = rawResult
516
+ const channels = info.channels
517
+ const alphaPixels = Buffer.alloc(STRICT_HASH_SIZE * STRICT_HASH_SIZE)
518
+
519
+ for (let pixelIndex = 0; pixelIndex < alphaPixels.length; pixelIndex += 1) {
520
+ alphaPixels[pixelIndex] = rgbaPixels[pixelIndex * channels + 3]
521
+ }
522
+
523
+ return crypto.createHash('sha256').update(alphaPixels).digest('hex')
524
+ }
525
+
526
+ function dct1d(values) {
527
+ const length = values.length
528
+ const output = new Float64Array(length)
529
+ const factor = Math.PI / (2 * length)
530
+
531
+ for (let frequency = 0; frequency < length; frequency += 1) {
532
+ let sum = 0
533
+
534
+ for (let index = 0; index < length; index += 1) {
535
+ sum += values[index] * Math.cos((2 * index + 1) * frequency * factor)
536
+ }
537
+
538
+ output[frequency] = sum * (frequency === 0 ? Math.sqrt(1 / length) : Math.sqrt(2 / length))
539
+ }
540
+
541
+ return output
542
+ }
543
+
544
+ function dct2d(matrix, size) {
545
+ const rowTransformed = Array.from({ length: size }, () => new Float64Array(size))
546
+
547
+ for (let rowIndex = 0; rowIndex < size; rowIndex += 1) {
548
+ rowTransformed[rowIndex] = dct1d(matrix[rowIndex])
549
+ }
550
+
551
+ const output = Array.from({ length: size }, () => new Float64Array(size))
552
+
553
+ for (let columnIndex = 0; columnIndex < size; columnIndex += 1) {
554
+ const column = new Float64Array(size)
555
+
556
+ for (let rowIndex = 0; rowIndex < size; rowIndex += 1) {
557
+ column[rowIndex] = rowTransformed[rowIndex][columnIndex]
558
+ }
559
+
560
+ const transformedColumn = dct1d(column)
561
+
562
+ for (let rowIndex = 0; rowIndex < size; rowIndex += 1) {
563
+ output[rowIndex][columnIndex] = transformedColumn[rowIndex]
564
+ }
565
+ }
566
+
567
+ return output
568
+ }
569
+
570
+ function duplicateIconPairKey(scope, firstIconName, secondIconName) {
571
+ const [leftIconName, rightIconName] = [firstIconName, secondIconName].sort((first, second) =>
572
+ first.localeCompare(second),
573
+ )
574
+
575
+ return `${scope}::${leftIconName}::${rightIconName}`
576
+ }
577
+
578
+ function formatDuration(milliseconds) {
579
+ if (milliseconds < 1000) {
580
+ return `${milliseconds}ms`
581
+ }
582
+
583
+ return `${(milliseconds / 1000).toFixed(2)}s`
584
+ }
585
+
586
+ function isAllowedDuplicateIconPair(scope, firstIconName, secondIconName) {
587
+ return allowedDuplicateIconPairs.has(duplicateIconPairKey(scope, firstIconName, secondIconName))
588
+ }
589
+
590
+ function normalizeSvg(svg, svgPath) {
591
+ const optimized = optimize(svg, {
592
+ multipass: true,
593
+ path: svgPath,
594
+ plugins: ['preset-default', 'sortAttrs'],
595
+ })
596
+
597
+ return optimized.data.trim()
598
+ }
599
+
600
+ function pairKeyFromIndexes(firstIndex, secondIndex) {
601
+ return firstIndex < secondIndex ? `${firstIndex}:${secondIndex}` : `${secondIndex}:${firstIndex}`
602
+ }
603
+
604
+ function parseArgs(rawArgs) {
605
+ const parsedOptions = {
606
+ name: null,
607
+ path: null,
608
+ }
609
+ const positional = []
610
+
611
+ for (let index = 0; index < rawArgs.length; index += 1) {
612
+ const value = rawArgs[index]
613
+
614
+ if (value === '--path') {
615
+ parsedOptions.path = rawArgs[index + 1] ?? null
616
+ index += 1
617
+ continue
618
+ }
619
+
620
+ if (value === '--name') {
621
+ parsedOptions.name = rawArgs[index + 1] ?? null
622
+ index += 1
623
+ continue
624
+ }
625
+
626
+ positional.push(value)
627
+ }
628
+
629
+ if (!parsedOptions.path) {
630
+ parsedOptions.path = positional[0] ?? null
631
+ }
632
+
633
+ if (!parsedOptions.name) {
634
+ parsedOptions.name = positional[1] ?? null
635
+ }
636
+
637
+ return parsedOptions
638
+ }
639
+
640
+ function printHelp() {
641
+ console.log(`brick icon-check <path> [name]
642
+
643
+ Usage:
644
+ brick icon-check ./path/to/svg-icons
645
+ brick icon-check ./path/to/svg-icons bounty
646
+ brick icon-check --path ./path/to/svg-icons --name bounty
647
+
648
+ Notes:
649
+ path: directory with source .svg icons
650
+ name: label for logs and allowed duplicate pairs; default is the directory name`)
651
+ }
652
+
653
+ function renderForDHash(svgContent) {
654
+ const width = D_HASH_SIZE + 1
655
+ const height = D_HASH_SIZE
656
+
657
+ return sharp(Buffer.from(svgContent, 'utf8'))
658
+ .resize(width, height, {
659
+ background: {
660
+ alpha: 0,
661
+ b: 0,
662
+ g: 0,
663
+ r: 0,
664
+ },
665
+ fit: 'fill',
666
+ })
667
+ .ensureAlpha()
668
+ .raw()
669
+ .toBuffer({ resolveWithObject: true })
670
+ }
671
+
672
+ async function renderForLooksSameNormalized(svgContent) {
673
+ const input = Buffer.from(svgContent, 'utf8')
674
+ const renderSize = 256
675
+ const outputSize = 128
676
+
677
+ const { data, info } = await sharp(input)
678
+ .resize(renderSize, renderSize, {
679
+ background: { alpha: 0, b: 0, g: 0, r: 0 },
680
+ fit: 'contain',
681
+ })
682
+ .ensureAlpha()
683
+ .raw()
684
+ .toBuffer({ resolveWithObject: true })
685
+
686
+ let minX = info.width
687
+ let minY = info.height
688
+ let maxX = -1
689
+ let maxY = -1
690
+
691
+ for (let y = 0; y < info.height; y += 1) {
692
+ for (let x = 0; x < info.width; x += 1) {
693
+ const alpha = data[(y * info.width + x) * info.channels + 3]
694
+
695
+ if (alpha > ALPHA_THRESHOLD) {
696
+ minX = Math.min(minX, x)
697
+ minY = Math.min(minY, y)
698
+ maxX = Math.max(maxX, x)
699
+ maxY = Math.max(maxY, y)
700
+ }
701
+ }
702
+ }
703
+
704
+ if (maxX === -1 || maxY === -1) {
705
+ return sharp(data, {
706
+ raw: {
707
+ channels: info.channels,
708
+ height: info.height,
709
+ width: info.width,
710
+ },
711
+ })
712
+ .resize(outputSize, outputSize, {
713
+ background: { alpha: 1, b: 255, g: 255, r: 255 },
714
+ fit: 'contain',
715
+ })
716
+ .flatten({ background: { b: 255, g: 255, r: 255 } })
717
+ .png()
718
+ .toBuffer()
719
+ }
720
+
721
+ const left = Math.max(0, minX)
722
+ const top = Math.max(0, minY)
723
+ const width = Math.max(1, Math.min(info.width - left, maxX - minX + 1))
724
+ const height = Math.max(1, Math.min(info.height - top, maxY - minY + 1))
725
+
726
+ return sharp(data, {
727
+ raw: {
728
+ channels: info.channels,
729
+ height: info.height,
730
+ width: info.width,
731
+ },
732
+ })
733
+ .extract({
734
+ height,
735
+ left,
736
+ top,
737
+ width,
738
+ })
739
+ .resize(outputSize, outputSize, {
740
+ background: { alpha: 1, b: 255, g: 255, r: 255 },
741
+ fit: 'contain',
742
+ })
743
+ .flatten({ background: { b: 255, g: 255, r: 255 } })
744
+ .png()
745
+ .toBuffer()
746
+ }
747
+ function renderForShape(svgContent) {
748
+ return sharp(Buffer.from(svgContent, 'utf8'))
749
+ .resize(PHASH_IMAGE_SIZE, PHASH_IMAGE_SIZE, {
750
+ background: {
751
+ alpha: 0,
752
+ b: 0,
753
+ g: 0,
754
+ r: 0,
755
+ },
756
+ fit: 'contain',
757
+ })
758
+ .ensureAlpha()
759
+ .raw()
760
+ .toBuffer({ resolveWithObject: true })
761
+ }
762
+
763
+ function renderForStrictHash(svgContent) {
764
+ return sharp(Buffer.from(svgContent, 'utf8'))
765
+ .resize(STRICT_HASH_SIZE, STRICT_HASH_SIZE, {
766
+ background: {
767
+ alpha: 0,
768
+ b: 0,
769
+ g: 0,
770
+ r: 0,
771
+ },
772
+ fit: 'contain',
773
+ })
774
+ .ensureAlpha()
775
+ .raw()
776
+ .toBuffer({ resolveWithObject: true })
777
+ }
778
+
779
+ async function runLooksSameOnCandidates(entries, candidatePairs, progressTracker) {
780
+ const duplicatePairKeys = new Set()
781
+
782
+ if (candidatePairs.length === 0) {
783
+ return duplicatePairKeys
784
+ }
785
+
786
+ let nextPairIndex = 0
787
+
788
+ const worker = async () => {
789
+ while (true) {
790
+ const pairIndex = nextPairIndex
791
+ nextPairIndex += 1
792
+
793
+ if (pairIndex >= candidatePairs.length) {
794
+ break
795
+ }
796
+
797
+ const [leftIndex, rightIndex] = candidatePairs[pairIndex]
798
+ const leftImage = entries[leftIndex].looksSamePng
799
+ const rightImage = entries[rightIndex].looksSamePng
800
+ const { equal } = await looksSame(leftImage, rightImage, LOOKS_SAME_OPTIONS)
801
+
802
+ if (equal) {
803
+ duplicatePairKeys.add(pairKeyFromIndexes(leftIndex, rightIndex))
804
+ }
805
+
806
+ progressTracker?.tick()
807
+ }
808
+ }
809
+
810
+ const workers = Array.from(
811
+ { length: Math.min(LOOKS_SAME_CONCURRENCY, Math.max(candidatePairs.length, 1)) },
812
+ () => worker(),
813
+ )
814
+
815
+ await Promise.all(workers)
816
+
817
+ return duplicatePairKeys
818
+ }
819
+
820
+ const startAt = Date.now()
821
+ const svgFiles = globSync('**/*.svg', {
822
+ absolute: true,
823
+ cwd: iconsDir,
824
+ nodir: true,
825
+ }).sort()
826
+
827
+ if (svgFiles.length < 2) {
828
+ console.log(`[${scopeName}] skip: ${svgFiles.length} icon(s)`)
829
+ process.exit(0)
830
+ }
831
+
832
+ console.log(`Start icon-check for "${scopeName}"`)
833
+ console.log(`[${scopeName}] ${svgFiles.length} icons`)
834
+
835
+ const totalPairsEstimate = (svgFiles.length * (svgFiles.length - 1)) / 2
836
+ const progress = createProgressTracker(
837
+ `${scopeName} pipeline step 1/3 hash & render`,
838
+ svgFiles.length + totalPairsEstimate + 1,
839
+ )
840
+ const entries = []
841
+
842
+ for (const svgPath of svgFiles) {
843
+ const svg = fs.readFileSync(svgPath, 'utf8')
844
+ const normalizedSvg = normalizeSvg(svg, svgPath)
845
+ const artifacts = await createIconArtifacts(normalizedSvg, svgPath)
846
+
847
+ entries.push({
848
+ ...artifacts,
849
+ filePath: svgPath,
850
+ })
851
+
852
+ progress.tick()
853
+ }
854
+
855
+ const duplicateGraph = new Map(entries.map((entry) => [entry.filePath, new Set()]))
856
+ const exactPairKeys = new Set()
857
+ const exactGroups = buildExactHashMap(entries)
858
+
859
+ for (const indexes of exactGroups) {
860
+ for (let leftIndex = 0; leftIndex < indexes.length; leftIndex += 1) {
861
+ for (let rightIndex = leftIndex + 1; rightIndex < indexes.length; rightIndex += 1) {
862
+ const firstEntryIndex = indexes[leftIndex]
863
+ const secondEntryIndex = indexes[rightIndex]
864
+ exactPairKeys.add(pairKeyFromIndexes(firstEntryIndex, secondEntryIndex))
865
+ addEdgeByIndexes(duplicateGraph, entries, firstEntryIndex, secondEntryIndex)
866
+ }
867
+ }
868
+ }
869
+
870
+ console.log(` ${scopeName} step 1/3 exact hash pairs: ${exactPairKeys.size}`)
871
+
872
+ progress.setLabel(`${scopeName} pipeline step 2/3 simple filter`)
873
+ const { candidatePairs, directPairs, totalPairs } = buildSimpleCandidatePairs(entries, exactPairKeys, progress)
874
+
875
+ for (const [leftIndex, rightIndex] of directPairs) {
876
+ addEdgeByIndexes(duplicateGraph, entries, leftIndex, rightIndex)
877
+ }
878
+
879
+ console.log(
880
+ ` ${scopeName} step 2/3 direct(semantic 100%)=${directPairs.length}, candidates=${candidatePairs.length} (similarity >= ${SIMPLE_SIMILARITY_THRESHOLD}% and IoU >= ${SIMPLE_IOU_THRESHOLD_PERCENT}%)`,
881
+ )
882
+
883
+ progress.setTotal(svgFiles.length + totalPairs + candidatePairs.length)
884
+ progress.setLabel(`${scopeName} pipeline step 3/3 looks-same`)
885
+ const looksSamePairs = await runLooksSameOnCandidates(entries, candidatePairs, progress)
886
+
887
+ for (const pairKey of looksSamePairs) {
888
+ const [leftIndexRaw, rightIndexRaw] = pairKey.split(':')
889
+ const leftIndex = Number(leftIndexRaw)
890
+ const rightIndex = Number(rightIndexRaw)
891
+
892
+ if (!Number.isInteger(leftIndex) || !Number.isInteger(rightIndex)) {
893
+ continue
894
+ }
895
+
896
+ addEdgeByIndexes(duplicateGraph, entries, leftIndex, rightIndex)
897
+ }
898
+
899
+ console.log(` ${scopeName} step 3/3 looks-same duplicates: ${looksSamePairs.size}`)
900
+
901
+ const duplicateGroups = buildDuplicateGroups(duplicateGraph, iconsDir)
902
+ .map((group) => ({
903
+ ...group,
904
+ duplicateIcons: group.duplicateIcons.filter((duplicateIcon) => {
905
+ return !isAllowedDuplicateIconPair(scopeName, group.iconName, duplicateIcon)
906
+ }),
907
+ }))
908
+ .filter((group) => group.duplicateIcons.length > 0)
909
+
910
+ console.log(` ${scopeName} done in ${formatDuration(Date.now() - startAt)}; groups: ${duplicateGroups.length}`)
911
+ console.log(`\nicon-check finished in ${formatDuration(Date.now() - startAt)}`)
912
+
913
+ if (duplicateGroups.length === 0) {
914
+ console.log('✅ No duplicate icons found')
915
+ process.exit(0)
916
+ }
917
+
918
+ console.error('❌ Duplicate icons found:')
919
+ console.error(
920
+ `Pipeline: step1 exact-hash -> step2 simple-similarity>=${SIMPLE_SIMILARITY_THRESHOLD}% + IoU>=${SIMPLE_IOU_THRESHOLD_PERCENT}% -> step3 looks-same (strict equal only, tolerance=${LOOKS_SAME_OPTIONS.tolerance}, ignoreAntialiasing=${LOOKS_SAME_OPTIONS.ignoreAntialiasing})`,
921
+ )
922
+ console.error(`\n[${scopeName}]`)
923
+
924
+ for (const group of duplicateGroups) {
925
+ console.error(`${group.iconName} - [${group.duplicateIcons.join(', ')}]`)
926
+ }
927
+
928
+ process.exit(1)