Spaces:
Running
Running
Upload 6 files
Browse files- Dockerfile +21 -0
- extract-colors.js +329 -0
- fast-average-color.js +510 -0
- index.js +94 -0
- package.json +20 -0
- phash.js +99 -0
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Menggunakan image Node.js yang lebih stabil
|
| 2 |
+
FROM node:16-slim
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
# Tentukan direktori kerja di dalam container
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Salin file package.json dan package-lock.json terlebih dahulu
|
| 9 |
+
COPY package*.json ./
|
| 10 |
+
|
| 11 |
+
# Install dependensi Node.js
|
| 12 |
+
RUN npm install
|
| 13 |
+
|
| 14 |
+
# Salin seluruh kode aplikasi ke dalam container
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Ekspose port 3000 yang akan digunakan aplikasi Express
|
| 18 |
+
EXPOSE 3000
|
| 19 |
+
|
| 20 |
+
# Tentukan perintah untuk menjalankan aplikasi Express.js
|
| 21 |
+
CMD ["npm", "start"]
|
extract-colors.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class Color {
|
| 2 |
+
constructor(red, green, blue, hex = red << 16 | green << 8 | blue) {
|
| 3 |
+
this._count = 1
|
| 4 |
+
this.__saturation = -1
|
| 5 |
+
this.__hue = -1
|
| 6 |
+
this.__lightness = -1
|
| 7 |
+
this.__intensity = -1
|
| 8 |
+
this._red = red
|
| 9 |
+
this._green = green
|
| 10 |
+
this._blue = blue
|
| 11 |
+
this._hex = hex
|
| 12 |
+
}
|
| 13 |
+
static distance(colorA, colorB) {
|
| 14 |
+
return (Math.abs(colorB._red - colorA._red) + Math.abs(colorB._green - colorA._green) + Math.abs(colorB._blue - colorA._blue)) / (3 * 255)
|
| 15 |
+
}
|
| 16 |
+
updateHSL() {
|
| 17 |
+
const red = this._red / 255
|
| 18 |
+
const green = this._green / 255
|
| 19 |
+
const blue = this._blue / 255
|
| 20 |
+
const max = Math.max(red, green, blue)
|
| 21 |
+
const min = Math.min(red, green, blue)
|
| 22 |
+
this.__lightness = (max + min) / 2
|
| 23 |
+
if (max === min) {
|
| 24 |
+
this.__hue = 0
|
| 25 |
+
this.__saturation = 0
|
| 26 |
+
this.__intensity = 0
|
| 27 |
+
} else {
|
| 28 |
+
const distance2 = max - min
|
| 29 |
+
this.__saturation = this.__lightness > 0.5 ? distance2 / (2 - max - min) : distance2 / (max + min)
|
| 30 |
+
this.__intensity = this.__saturation * ((0.5 - Math.abs(0.5 - this.__lightness)) * 2)
|
| 31 |
+
switch (max) {
|
| 32 |
+
case red:
|
| 33 |
+
this.__hue = ((green - blue) / distance2 + (green < blue ? 6 : 0)) / 6
|
| 34 |
+
break
|
| 35 |
+
case green:
|
| 36 |
+
this.__hue = ((blue - red) / distance2 + 2) / 6
|
| 37 |
+
break
|
| 38 |
+
case blue:
|
| 39 |
+
this.__hue = ((red - green) / distance2 + 4) / 6
|
| 40 |
+
break
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
get _hue() {
|
| 45 |
+
if (this.__hue === -1) {
|
| 46 |
+
this.updateHSL()
|
| 47 |
+
}
|
| 48 |
+
return this.__hue
|
| 49 |
+
}
|
| 50 |
+
get _saturation() {
|
| 51 |
+
if (this.__saturation === -1) {
|
| 52 |
+
this.updateHSL()
|
| 53 |
+
}
|
| 54 |
+
return this.__saturation
|
| 55 |
+
}
|
| 56 |
+
get _lightness() {
|
| 57 |
+
if (this.__lightness === -1) {
|
| 58 |
+
this.updateHSL()
|
| 59 |
+
}
|
| 60 |
+
return this.__lightness
|
| 61 |
+
}
|
| 62 |
+
get _intensity() {
|
| 63 |
+
if (this.__intensity === -1) {
|
| 64 |
+
this.updateHSL()
|
| 65 |
+
}
|
| 66 |
+
return this.__intensity
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
const distance = (a, b) => Math.abs(a - b)
|
| 70 |
+
const hueDistance = (a, b) => Math.min(distance(a, b), distance((a + 0.5) % 1, (b + 0.5) % 1))
|
| 71 |
+
class AverageGroup {
|
| 72 |
+
constructor() {
|
| 73 |
+
this.colors = []
|
| 74 |
+
this._average = null
|
| 75 |
+
}
|
| 76 |
+
addColor(color) {
|
| 77 |
+
this.colors.push(color)
|
| 78 |
+
this._average = null
|
| 79 |
+
}
|
| 80 |
+
isSamePalette(color, hue, saturation, lightness) {
|
| 81 |
+
for (let i = 0; i < this.colors.length; i++) {
|
| 82 |
+
const currentColor = this.colors[i]
|
| 83 |
+
const isSame = hueDistance(currentColor._hue, color._hue) < hue && distance(currentColor._saturation, color._saturation) < saturation && distance(currentColor._lightness, color._lightness) < lightness
|
| 84 |
+
if (!isSame) {
|
| 85 |
+
return false
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
return true
|
| 89 |
+
}
|
| 90 |
+
get average() {
|
| 91 |
+
if (!this._average) {
|
| 92 |
+
const { r, g, b } = this.colors.reduce((total2, color) => {
|
| 93 |
+
total2.r += color._red
|
| 94 |
+
total2.g += color._green
|
| 95 |
+
total2.b += color._blue
|
| 96 |
+
return total2
|
| 97 |
+
}, { r: 0, g: 0, b: 0 })
|
| 98 |
+
const total = this.colors.reduce((_count, color) => _count + color._count, 0)
|
| 99 |
+
this._average = new Color(
|
| 100 |
+
Math.round(r / this.colors.length),
|
| 101 |
+
Math.round(g / this.colors.length),
|
| 102 |
+
Math.round(b / this.colors.length)
|
| 103 |
+
)
|
| 104 |
+
this._average._count = total
|
| 105 |
+
}
|
| 106 |
+
return this._average
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
class AverageManager {
|
| 110 |
+
constructor(hue, saturation, lightness) {
|
| 111 |
+
this._groups = []
|
| 112 |
+
this._hue = hue
|
| 113 |
+
this._saturation = saturation
|
| 114 |
+
this._lightness = lightness
|
| 115 |
+
}
|
| 116 |
+
addColor(color) {
|
| 117 |
+
const samePalette = this._groups.find((averageGroup) => averageGroup.isSamePalette(color, this._hue, this._saturation, this._lightness))
|
| 118 |
+
if (samePalette) {
|
| 119 |
+
samePalette.addColor(color)
|
| 120 |
+
} else {
|
| 121 |
+
const averageGroup = new AverageGroup()
|
| 122 |
+
averageGroup.addColor(color)
|
| 123 |
+
this._groups.push(averageGroup)
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
getGroups() {
|
| 127 |
+
return this._groups.map((averageGroup) => averageGroup.average)
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
var sortColors = (list, _pixels, _hueDistance, _saturationDistance, _lightnessDistance) => {
|
| 131 |
+
const averageManager = new AverageManager(_hueDistance, _saturationDistance, _lightnessDistance)
|
| 132 |
+
list.forEach((color) => averageManager.addColor(color))
|
| 133 |
+
const sorted = averageManager.getGroups()
|
| 134 |
+
sorted.sort((a, b) => {
|
| 135 |
+
const bPower = (b._intensity + 0.1) * (0.9 - b._count / _pixels)
|
| 136 |
+
const aPower = (a._intensity + 0.1) * (0.9 - a._count / _pixels)
|
| 137 |
+
return bPower - aPower
|
| 138 |
+
})
|
| 139 |
+
return sorted
|
| 140 |
+
}
|
| 141 |
+
const createFinalColor = (color, pixels) => {
|
| 142 |
+
return {
|
| 143 |
+
hex: `#${'0'.repeat(6 - color._hex.toString(16).length)}${color._hex.toString(16)}`,
|
| 144 |
+
red: color._red,
|
| 145 |
+
green: color._green,
|
| 146 |
+
blue: color._blue,
|
| 147 |
+
area: color._count / pixels,
|
| 148 |
+
hue: color._hue,
|
| 149 |
+
saturation: color._saturation,
|
| 150 |
+
lightness: color._lightness,
|
| 151 |
+
intensity: color._intensity
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
const testUint = (label, val, min = 0, max = Number.MAX_SAFE_INTEGER) => {
|
| 155 |
+
if (!Number.isInteger(val) || val < min || val > max) {
|
| 156 |
+
throw new Error(`${label} is invalid (${val})`)
|
| 157 |
+
}
|
| 158 |
+
return val
|
| 159 |
+
}
|
| 160 |
+
const testNumber = (label, val, min = 0, max = Number.MAX_VALUE) => {
|
| 161 |
+
if (Number(val) !== val || val < min || val > max) {
|
| 162 |
+
throw new Error(`${label} is invalid (${val})`)
|
| 163 |
+
}
|
| 164 |
+
return val
|
| 165 |
+
}
|
| 166 |
+
const testFunction = (label, val) => {
|
| 167 |
+
if (!val || {}.toString.call(val) !== '[object Function]') {
|
| 168 |
+
throw new Error(`${label} is invalid (${val})`)
|
| 169 |
+
}
|
| 170 |
+
return val
|
| 171 |
+
}
|
| 172 |
+
var cleanInputs = (options) => {
|
| 173 |
+
var _a, _b, _c, _d, _e, _f, _g, _h
|
| 174 |
+
return [
|
| 175 |
+
testUint('pixels', (_a = options.pixels) != null ? _a : 1e4, 1),
|
| 176 |
+
testNumber('distance', (_b = options.distance) != null ? _b : 0.12, 0, 1),
|
| 177 |
+
testUint('splitPower', (_c = options.splitPower) != null ? _c : 10, 2, 15),
|
| 178 |
+
testFunction('colorValidator', (_d = options.colorValidator) != null ? _d : (_red, _green, _blue, _alpha) => (_alpha != null ? _alpha : 255) > 250),
|
| 179 |
+
testNumber('hueDistance', (_e = options.hueDistance) != null ? _e : 0.08333333333333333, 0, 1),
|
| 180 |
+
testNumber('saturationDistance', (_f = options.saturationDistance) != null ? _f : 0.2, 0, 1),
|
| 181 |
+
testNumber('lightnessDistance', (_g = options.lightnessDistance) != null ? _g : 0.2, 0, 1),
|
| 182 |
+
(_h = options.crossOrigin) != null ? _h : null
|
| 183 |
+
]
|
| 184 |
+
}
|
| 185 |
+
class BudGroup {
|
| 186 |
+
constructor() {
|
| 187 |
+
this._count = 1
|
| 188 |
+
this._children = {}
|
| 189 |
+
}
|
| 190 |
+
addColor(_hex, _red, _green, _blue) {
|
| 191 |
+
if (this._children[_hex]) {
|
| 192 |
+
this._children[_hex]._count++
|
| 193 |
+
} else {
|
| 194 |
+
this._children[_hex] = new Color(_red, _green, _blue, _hex)
|
| 195 |
+
}
|
| 196 |
+
return this._children[_hex]
|
| 197 |
+
}
|
| 198 |
+
getList() {
|
| 199 |
+
return Object.keys(this._children).map((key) => this._children[key])
|
| 200 |
+
}
|
| 201 |
+
getMaxWeight(_count) {
|
| 202 |
+
if (this._maxWeight === void 0) {
|
| 203 |
+
const list = this.getList().map((child) => child._count / _count)
|
| 204 |
+
list.sort((a, b) => b - a)
|
| 205 |
+
this._maxWeight = list[0] || 0
|
| 206 |
+
}
|
| 207 |
+
return this._maxWeight
|
| 208 |
+
}
|
| 209 |
+
getMaxWeightColor(_count) {
|
| 210 |
+
const list = this.getList()
|
| 211 |
+
list.sort((a, b) => {
|
| 212 |
+
return b._count / _count - a._count / _count
|
| 213 |
+
})
|
| 214 |
+
return list[0]
|
| 215 |
+
}
|
| 216 |
+
getMaxCountColor() {
|
| 217 |
+
const list = this.getList()
|
| 218 |
+
const biggest = list.reduce((a, b) => a._count >= b._count ? a : b)
|
| 219 |
+
return biggest
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
class RootGroup {
|
| 223 |
+
constructor() {
|
| 224 |
+
this._count = 1
|
| 225 |
+
this._children = {}
|
| 226 |
+
}
|
| 227 |
+
addRootGroup(key) {
|
| 228 |
+
if (this._children[key]) {
|
| 229 |
+
this._children[key]._count++
|
| 230 |
+
} else {
|
| 231 |
+
this._children[key] = new RootGroup()
|
| 232 |
+
}
|
| 233 |
+
return this._children[key]
|
| 234 |
+
}
|
| 235 |
+
getList() {
|
| 236 |
+
return Object.keys(this._children).map((key) => this._children[key])
|
| 237 |
+
}
|
| 238 |
+
addBudGroup(key) {
|
| 239 |
+
if (this._children[key]) {
|
| 240 |
+
this._children[key]._count++
|
| 241 |
+
} else {
|
| 242 |
+
this._children[key] = new BudGroup()
|
| 243 |
+
}
|
| 244 |
+
return this._children[key]
|
| 245 |
+
}
|
| 246 |
+
getMaxWeight(_count) {
|
| 247 |
+
if (this._maxWeight === void 0) {
|
| 248 |
+
const list = this.getList().map((child) => child._count / _count)
|
| 249 |
+
list.sort((a, b) => b - a)
|
| 250 |
+
this._maxWeight = list[0] || 0
|
| 251 |
+
}
|
| 252 |
+
return this._maxWeight
|
| 253 |
+
}
|
| 254 |
+
getMaxWeightColor(_count) {
|
| 255 |
+
const list = this.getList()
|
| 256 |
+
list.sort((a, b) => {
|
| 257 |
+
return b._count / _count - a._count / _count
|
| 258 |
+
})
|
| 259 |
+
return list[0].getMaxWeightColor(_count)
|
| 260 |
+
}
|
| 261 |
+
getMaxCountColor() {
|
| 262 |
+
const list = this.getList()
|
| 263 |
+
const biggest = list.reduce((a, b) => a.getMaxCountColor()._count >= b.getMaxCountColor()._count ? a : b)
|
| 264 |
+
return biggest.getMaxCountColor()
|
| 265 |
+
}
|
| 266 |
+
getColors(_distance, _count) {
|
| 267 |
+
const list = this.getList().map((child) => {
|
| 268 |
+
const { _count: _count2 } = child
|
| 269 |
+
const color = child.getMaxCountColor()
|
| 270 |
+
color._count = _count2
|
| 271 |
+
return color
|
| 272 |
+
})
|
| 273 |
+
list.sort((a, b) => b._count / _count - a._count / _count)
|
| 274 |
+
const newList = []
|
| 275 |
+
list.forEach((color) => {
|
| 276 |
+
const near = newList.find((col) => Color.distance(col, color) < _distance)
|
| 277 |
+
if (!near) {
|
| 278 |
+
newList.push(color)
|
| 279 |
+
} else {
|
| 280 |
+
near._count += color._count
|
| 281 |
+
}
|
| 282 |
+
})
|
| 283 |
+
return newList
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
var extractor = ({ data, width, height }, _pixels, _distance, _splitPower, _colorValidator) => {
|
| 287 |
+
const rootGroup = new RootGroup()
|
| 288 |
+
const reducer = width && height ? Math.floor(width * height / _pixels) || 1 : 1
|
| 289 |
+
for (let i = 0; i < data.length; i += 4 * reducer) {
|
| 290 |
+
const r = data[i]
|
| 291 |
+
const g = data[i + 1]
|
| 292 |
+
const b = data[i + 2]
|
| 293 |
+
const a = data[i + 3]
|
| 294 |
+
if (_colorValidator(r, g, b, a)) {
|
| 295 |
+
const real = r << 16 | g << 8 | b
|
| 296 |
+
const medium = (r >> 4 & 15) << 2 | (g >> 4 & 15) << 1 | b >> 4 & 15
|
| 297 |
+
const small = Math.round(r * (_splitPower - 1) / 255) * (_splitPower * _splitPower) + Math.round(g * (_splitPower - 1) / 255) * _splitPower + Math.round(b * (_splitPower - 1) / 255)
|
| 298 |
+
const smallGroup = rootGroup.addRootGroup(small)
|
| 299 |
+
const mediumGroup = smallGroup.addBudGroup(medium)
|
| 300 |
+
mediumGroup.addColor(real, r, g, b)
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
return rootGroup.getColors(_distance, _pixels)
|
| 304 |
+
}
|
| 305 |
+
const sortFinalColors = (_colors, _pixels, _hueDistance, _saturationDistance, _lightnessDistance) => {
|
| 306 |
+
const list = sortColors(_colors, _pixels, _hueDistance, _saturationDistance, _lightnessDistance)
|
| 307 |
+
return list.map((color) => createFinalColor(color, _pixels))
|
| 308 |
+
}
|
| 309 |
+
const extractColorsFromImageData = (imageData, options = {}) => {
|
| 310 |
+
const [_pixels, _distance, _splitPower, _colorValidator, _hueDistance, _saturationDistance, _lightnessDistance] = cleanInputs(options)
|
| 311 |
+
const colors = extractor(imageData, _pixels, _distance, _splitPower, _colorValidator)
|
| 312 |
+
const px = imageData.width && imageData.height ? Math.min(imageData.width * imageData.height, _pixels) : _pixels
|
| 313 |
+
return sortFinalColors(colors, px, _hueDistance, _saturationDistance, _lightnessDistance)
|
| 314 |
+
}
|
| 315 |
+
const extractColors = (imageData, options) => {
|
| 316 |
+
if (imageData.data) {
|
| 317 |
+
return new Promise((resolve) => {
|
| 318 |
+
resolve(extractColorsFromImageData(imageData, options))
|
| 319 |
+
})
|
| 320 |
+
}
|
| 321 |
+
throw new Error('Send imageData to extractColors')
|
| 322 |
+
}
|
| 323 |
+
const extractColorsFromImage = () => {
|
| 324 |
+
throw new Error('Can not use extractColorsFromImage for Node.js')
|
| 325 |
+
}
|
| 326 |
+
const extractColorsFromSrc = () => {
|
| 327 |
+
throw new Error('Can not use extractColorsFromSrc for Node.js')
|
| 328 |
+
}
|
| 329 |
+
export { extractColors as default, extractColors, extractColorsFromImage, extractColorsFromImageData, extractColorsFromSrc }
|
fast-average-color.js
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*! Fast Average Color | © 2022 Denis Seleznev | MIT License | https://github.com/fast-average-color/fast-average-color */
|
| 2 |
+
function toHex(num) {
|
| 3 |
+
let str = num.toString(16)
|
| 4 |
+
return str.length === 1 ? '0' + str : str
|
| 5 |
+
}
|
| 6 |
+
function arrayToHex(arr) {
|
| 7 |
+
return '#' + arr.map(toHex).join('')
|
| 8 |
+
}
|
| 9 |
+
function isDark(color) {
|
| 10 |
+
// http://www.w3.org/TR/AERT#color-contrast
|
| 11 |
+
let result = (color[0] * 299 + color[1] * 587 + color[2] * 114) / 1000
|
| 12 |
+
return result < 128
|
| 13 |
+
}
|
| 14 |
+
function prepareIgnoredColor(color) {
|
| 15 |
+
if (!color) {
|
| 16 |
+
return []
|
| 17 |
+
}
|
| 18 |
+
return isRGBArray(color) ? color : [color]
|
| 19 |
+
}
|
| 20 |
+
function isRGBArray(value) {
|
| 21 |
+
return Array.isArray(value[0])
|
| 22 |
+
}
|
| 23 |
+
function isIgnoredColor(data, index, ignoredColor) {
|
| 24 |
+
for (let i = 0; i < ignoredColor.length; i++) {
|
| 25 |
+
if (isIgnoredColorAsNumbers(data, index, ignoredColor[i])) {
|
| 26 |
+
return true
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
return false
|
| 30 |
+
}
|
| 31 |
+
function isIgnoredColorAsNumbers(data, index, ignoredColor) {
|
| 32 |
+
switch (ignoredColor.length) {
|
| 33 |
+
case 3:
|
| 34 |
+
// [red, green, blue]
|
| 35 |
+
if (isIgnoredRGBColor(data, index, ignoredColor)) {
|
| 36 |
+
return true
|
| 37 |
+
}
|
| 38 |
+
break
|
| 39 |
+
case 4:
|
| 40 |
+
// [red, green, blue, alpha]
|
| 41 |
+
if (isIgnoredRGBAColor(data, index, ignoredColor)) {
|
| 42 |
+
return true
|
| 43 |
+
}
|
| 44 |
+
break
|
| 45 |
+
case 5:
|
| 46 |
+
// [red, green, blue, alpha, threshold]
|
| 47 |
+
if (isIgnoredRGBAColorWithThreshold(data, index, ignoredColor)) {
|
| 48 |
+
return true
|
| 49 |
+
}
|
| 50 |
+
break
|
| 51 |
+
default:
|
| 52 |
+
return false
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
function isIgnoredRGBColor(data, index, ignoredColor) {
|
| 56 |
+
// Ignore if the pixel are transparent.
|
| 57 |
+
if (data[index + 3] !== 255) {
|
| 58 |
+
return true
|
| 59 |
+
}
|
| 60 |
+
if (data[index] === ignoredColor[0] &&
|
| 61 |
+
data[index + 1] === ignoredColor[1] &&
|
| 62 |
+
data[index + 2] === ignoredColor[2]) {
|
| 63 |
+
return true
|
| 64 |
+
}
|
| 65 |
+
return false
|
| 66 |
+
}
|
| 67 |
+
function isIgnoredRGBAColor(data, index, ignoredColor) {
|
| 68 |
+
if (data[index + 3] && ignoredColor[3]) {
|
| 69 |
+
return data[index] === ignoredColor[0] &&
|
| 70 |
+
data[index + 1] === ignoredColor[1] &&
|
| 71 |
+
data[index + 2] === ignoredColor[2] &&
|
| 72 |
+
data[index + 3] === ignoredColor[3]
|
| 73 |
+
}
|
| 74 |
+
// Ignore rgb components if the pixel are fully transparent.
|
| 75 |
+
return data[index + 3] === ignoredColor[3]
|
| 76 |
+
}
|
| 77 |
+
function inRange(colorComponent, ignoredColorComponent, value) {
|
| 78 |
+
return colorComponent >= (ignoredColorComponent - value) &&
|
| 79 |
+
colorComponent <= (ignoredColorComponent + value)
|
| 80 |
+
}
|
| 81 |
+
function isIgnoredRGBAColorWithThreshold(data, index, ignoredColor) {
|
| 82 |
+
let redIgnored = ignoredColor[0]
|
| 83 |
+
let greenIgnored = ignoredColor[1]
|
| 84 |
+
let blueIgnored = ignoredColor[2]
|
| 85 |
+
let alphaIgnored = ignoredColor[3]
|
| 86 |
+
let threshold = ignoredColor[4]
|
| 87 |
+
let alphaData = data[index + 3]
|
| 88 |
+
let alphaInRange = inRange(alphaData, alphaIgnored, threshold)
|
| 89 |
+
if (!alphaIgnored) {
|
| 90 |
+
return alphaInRange
|
| 91 |
+
}
|
| 92 |
+
if (!alphaData && alphaInRange) {
|
| 93 |
+
return true
|
| 94 |
+
}
|
| 95 |
+
if (inRange(data[index], redIgnored, threshold) &&
|
| 96 |
+
inRange(data[index + 1], greenIgnored, threshold) &&
|
| 97 |
+
inRange(data[index + 2], blueIgnored, threshold) &&
|
| 98 |
+
alphaInRange) {
|
| 99 |
+
return true
|
| 100 |
+
}
|
| 101 |
+
return false
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function dominantAlgorithm(arr, len, options) {
|
| 105 |
+
let colorHash = {}
|
| 106 |
+
let divider = 24
|
| 107 |
+
let ignoredColor = options.ignoredColor
|
| 108 |
+
let step = options.step
|
| 109 |
+
let max = [0, 0, 0, 0, 0]
|
| 110 |
+
for (let i = 0; i < len; i += step) {
|
| 111 |
+
let red = arr[i]
|
| 112 |
+
let green = arr[i + 1]
|
| 113 |
+
let blue = arr[i + 2]
|
| 114 |
+
let alpha = arr[i + 3]
|
| 115 |
+
if (ignoredColor && isIgnoredColor(arr, i, ignoredColor)) {
|
| 116 |
+
continue
|
| 117 |
+
}
|
| 118 |
+
let key = Math.round(red / divider) + ',' +
|
| 119 |
+
Math.round(green / divider) + ',' +
|
| 120 |
+
Math.round(blue / divider)
|
| 121 |
+
if (colorHash[key]) {
|
| 122 |
+
colorHash[key] = [
|
| 123 |
+
colorHash[key][0] + red * alpha,
|
| 124 |
+
colorHash[key][1] + green * alpha,
|
| 125 |
+
colorHash[key][2] + blue * alpha,
|
| 126 |
+
colorHash[key][3] + alpha,
|
| 127 |
+
colorHash[key][4] + 1
|
| 128 |
+
]
|
| 129 |
+
}
|
| 130 |
+
else {
|
| 131 |
+
colorHash[key] = [red * alpha, green * alpha, blue * alpha, alpha, 1]
|
| 132 |
+
}
|
| 133 |
+
if (max[4] < colorHash[key][4]) {
|
| 134 |
+
max = colorHash[key]
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
let redTotal = max[0]
|
| 138 |
+
let greenTotal = max[1]
|
| 139 |
+
let blueTotal = max[2]
|
| 140 |
+
let alphaTotal = max[3]
|
| 141 |
+
let count = max[4]
|
| 142 |
+
return alphaTotal ? [
|
| 143 |
+
Math.round(redTotal / alphaTotal),
|
| 144 |
+
Math.round(greenTotal / alphaTotal),
|
| 145 |
+
Math.round(blueTotal / alphaTotal),
|
| 146 |
+
Math.round(alphaTotal / count)
|
| 147 |
+
] : options.defaultColor
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function simpleAlgorithm(arr, len, options) {
|
| 151 |
+
let redTotal = 0
|
| 152 |
+
let greenTotal = 0
|
| 153 |
+
let blueTotal = 0
|
| 154 |
+
let alphaTotal = 0
|
| 155 |
+
let count = 0
|
| 156 |
+
let ignoredColor = options.ignoredColor
|
| 157 |
+
let step = options.step
|
| 158 |
+
for (let i = 0; i < len; i += step) {
|
| 159 |
+
let alpha = arr[i + 3]
|
| 160 |
+
let red = arr[i] * alpha
|
| 161 |
+
let green = arr[i + 1] * alpha
|
| 162 |
+
let blue = arr[i + 2] * alpha
|
| 163 |
+
if (ignoredColor && isIgnoredColor(arr, i, ignoredColor)) {
|
| 164 |
+
continue
|
| 165 |
+
}
|
| 166 |
+
redTotal += red
|
| 167 |
+
greenTotal += green
|
| 168 |
+
blueTotal += blue
|
| 169 |
+
alphaTotal += alpha
|
| 170 |
+
count++
|
| 171 |
+
}
|
| 172 |
+
return alphaTotal ? [
|
| 173 |
+
Math.round(redTotal / alphaTotal),
|
| 174 |
+
Math.round(greenTotal / alphaTotal),
|
| 175 |
+
Math.round(blueTotal / alphaTotal),
|
| 176 |
+
Math.round(alphaTotal / count)
|
| 177 |
+
] : options.defaultColor
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function sqrtAlgorithm(arr, len, options) {
|
| 181 |
+
let redTotal = 0
|
| 182 |
+
let greenTotal = 0
|
| 183 |
+
let blueTotal = 0
|
| 184 |
+
let alphaTotal = 0
|
| 185 |
+
let count = 0
|
| 186 |
+
let ignoredColor = options.ignoredColor
|
| 187 |
+
let step = options.step
|
| 188 |
+
for (let i = 0; i < len; i += step) {
|
| 189 |
+
let red = arr[i]
|
| 190 |
+
let green = arr[i + 1]
|
| 191 |
+
let blue = arr[i + 2]
|
| 192 |
+
let alpha = arr[i + 3]
|
| 193 |
+
if (ignoredColor && isIgnoredColor(arr, i, ignoredColor)) {
|
| 194 |
+
continue
|
| 195 |
+
}
|
| 196 |
+
redTotal += red * red * alpha
|
| 197 |
+
greenTotal += green * green * alpha
|
| 198 |
+
blueTotal += blue * blue * alpha
|
| 199 |
+
alphaTotal += alpha
|
| 200 |
+
count++
|
| 201 |
+
}
|
| 202 |
+
return alphaTotal ? [
|
| 203 |
+
Math.round(Math.sqrt(redTotal / alphaTotal)),
|
| 204 |
+
Math.round(Math.sqrt(greenTotal / alphaTotal)),
|
| 205 |
+
Math.round(Math.sqrt(blueTotal / alphaTotal)),
|
| 206 |
+
Math.round(alphaTotal / count)
|
| 207 |
+
] : options.defaultColor
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function getDefaultColor(options) {
|
| 211 |
+
return getOption(options, 'defaultColor', [0, 0, 0, 0])
|
| 212 |
+
}
|
| 213 |
+
function getOption(options, name, defaultValue) {
|
| 214 |
+
return (options[name] === undefined ? defaultValue : options[name])
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
let MIN_SIZE = 10
|
| 218 |
+
let MAX_SIZE = 100
|
| 219 |
+
function isSvg(filename) {
|
| 220 |
+
return filename.search(/\.svg(\?|$)/i) !== -1
|
| 221 |
+
}
|
| 222 |
+
function getOriginalSize(resource) {
|
| 223 |
+
if (isInstanceOfHTMLImageElement(resource)) {
|
| 224 |
+
let width = resource.naturalWidth
|
| 225 |
+
let height = resource.naturalHeight
|
| 226 |
+
// For SVG images with only viewBox attribute
|
| 227 |
+
if (!resource.naturalWidth && isSvg(resource.src)) {
|
| 228 |
+
width = height = MAX_SIZE
|
| 229 |
+
}
|
| 230 |
+
return {
|
| 231 |
+
width: width,
|
| 232 |
+
height: height,
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
if (isInstanceOfHTMLVideoElement(resource)) {
|
| 236 |
+
return {
|
| 237 |
+
width: resource.videoWidth,
|
| 238 |
+
height: resource.videoHeight
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
return {
|
| 242 |
+
width: resource.width,
|
| 243 |
+
height: resource.height
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
function getSrc(resource) {
|
| 247 |
+
if (isInstanceOfHTMLCanvasElement(resource)) {
|
| 248 |
+
return 'canvas'
|
| 249 |
+
}
|
| 250 |
+
if (isInstanceOfOffscreenCanvas(resource)) {
|
| 251 |
+
return 'offscreencanvas'
|
| 252 |
+
}
|
| 253 |
+
if (isInstanceOfImageBitmap(resource)) {
|
| 254 |
+
return 'imagebitmap'
|
| 255 |
+
}
|
| 256 |
+
return resource.src
|
| 257 |
+
}
|
| 258 |
+
function isInstanceOfHTMLImageElement(resource) {
|
| 259 |
+
return typeof HTMLImageElement !== 'undefined' && resource instanceof HTMLImageElement
|
| 260 |
+
}
|
| 261 |
+
let hasOffscreenCanvas = typeof OffscreenCanvas !== 'undefined'
|
| 262 |
+
function isInstanceOfOffscreenCanvas(resource) {
|
| 263 |
+
return hasOffscreenCanvas && resource instanceof OffscreenCanvas
|
| 264 |
+
}
|
| 265 |
+
function isInstanceOfHTMLVideoElement(resource) {
|
| 266 |
+
return typeof HTMLVideoElement !== 'undefined' && resource instanceof HTMLVideoElement
|
| 267 |
+
}
|
| 268 |
+
function isInstanceOfHTMLCanvasElement(resource) {
|
| 269 |
+
return typeof HTMLCanvasElement !== 'undefined' && resource instanceof HTMLCanvasElement
|
| 270 |
+
}
|
| 271 |
+
function isInstanceOfImageBitmap(resource) {
|
| 272 |
+
return typeof ImageBitmap !== 'undefined' && resource instanceof ImageBitmap
|
| 273 |
+
}
|
| 274 |
+
function prepareSizeAndPosition(originalSize, options) {
|
| 275 |
+
let srcLeft = getOption(options, 'left', 0)
|
| 276 |
+
let srcTop = getOption(options, 'top', 0)
|
| 277 |
+
let srcWidth = getOption(options, 'width', originalSize.width)
|
| 278 |
+
let srcHeight = getOption(options, 'height', originalSize.height)
|
| 279 |
+
let destWidth = srcWidth
|
| 280 |
+
let destHeight = srcHeight
|
| 281 |
+
if (options.mode === 'precision') {
|
| 282 |
+
return {
|
| 283 |
+
srcLeft: srcLeft,
|
| 284 |
+
srcTop: srcTop,
|
| 285 |
+
srcWidth: srcWidth,
|
| 286 |
+
srcHeight: srcHeight,
|
| 287 |
+
destWidth: destWidth,
|
| 288 |
+
destHeight: destHeight
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
let factor
|
| 292 |
+
if (srcWidth > srcHeight) {
|
| 293 |
+
factor = srcWidth / srcHeight
|
| 294 |
+
destWidth = MAX_SIZE
|
| 295 |
+
destHeight = Math.round(destWidth / factor)
|
| 296 |
+
}
|
| 297 |
+
else {
|
| 298 |
+
factor = srcHeight / srcWidth
|
| 299 |
+
destHeight = MAX_SIZE
|
| 300 |
+
destWidth = Math.round(destHeight / factor)
|
| 301 |
+
}
|
| 302 |
+
if (destWidth > srcWidth || destHeight > srcHeight ||
|
| 303 |
+
destWidth < MIN_SIZE || destHeight < MIN_SIZE) {
|
| 304 |
+
destWidth = srcWidth
|
| 305 |
+
destHeight = srcHeight
|
| 306 |
+
}
|
| 307 |
+
return {
|
| 308 |
+
srcLeft: srcLeft,
|
| 309 |
+
srcTop: srcTop,
|
| 310 |
+
srcWidth: srcWidth,
|
| 311 |
+
srcHeight: srcHeight,
|
| 312 |
+
destWidth: destWidth,
|
| 313 |
+
destHeight: destHeight
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
let isWebWorkers = typeof window === 'undefined'
|
| 317 |
+
function makeCanvas() {
|
| 318 |
+
if (isWebWorkers) {
|
| 319 |
+
return hasOffscreenCanvas ? new OffscreenCanvas(1, 1) : null
|
| 320 |
+
}
|
| 321 |
+
return document.createElement('canvas')
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
let ERROR_PREFIX = 'FastAverageColor: '
|
| 325 |
+
function getError(message) {
|
| 326 |
+
return Error(ERROR_PREFIX + message)
|
| 327 |
+
}
|
| 328 |
+
function outputError(error, silent) {
|
| 329 |
+
if (!silent) {
|
| 330 |
+
console.error(error)
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
let FastAverageColor = /** @class */ (function () {
|
| 335 |
+
function FastAverageColor() {
|
| 336 |
+
this.canvas = null
|
| 337 |
+
this.ctx = null
|
| 338 |
+
}
|
| 339 |
+
/**
|
| 340 |
+
* Get asynchronously the average color from not loaded image.
|
| 341 |
+
*/
|
| 342 |
+
FastAverageColor.prototype.getColorAsync = function (resource, options) {
|
| 343 |
+
if (!resource) {
|
| 344 |
+
return Promise.reject(getError('call .getColorAsync() without resource.'))
|
| 345 |
+
}
|
| 346 |
+
if (typeof resource === 'string') {
|
| 347 |
+
// Web workers
|
| 348 |
+
if (typeof Image === 'undefined') {
|
| 349 |
+
return Promise.reject(getError('resource as string is not supported in this environment'))
|
| 350 |
+
}
|
| 351 |
+
let img = new Image()
|
| 352 |
+
img.crossOrigin = options && options.crossOrigin || ''
|
| 353 |
+
img.src = resource
|
| 354 |
+
return this.bindImageEvents(img, options)
|
| 355 |
+
}
|
| 356 |
+
else if (isInstanceOfHTMLImageElement(resource) && !resource.complete) {
|
| 357 |
+
return this.bindImageEvents(resource, options)
|
| 358 |
+
}
|
| 359 |
+
else {
|
| 360 |
+
let result = this.getColor(resource, options)
|
| 361 |
+
return result.error ? Promise.reject(result.error) : Promise.resolve(result)
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
/**
|
| 365 |
+
* Get the average color from images, videos and canvas.
|
| 366 |
+
*/
|
| 367 |
+
FastAverageColor.prototype.getColor = function (resource, options) {
|
| 368 |
+
options = options || {}
|
| 369 |
+
let defaultColor = getDefaultColor(options)
|
| 370 |
+
if (!resource) {
|
| 371 |
+
let error = getError('call .getColor(null) without resource')
|
| 372 |
+
outputError(error, options.silent)
|
| 373 |
+
return this.prepareResult(defaultColor, error)
|
| 374 |
+
}
|
| 375 |
+
let originalSize = getOriginalSize(resource)
|
| 376 |
+
let size = prepareSizeAndPosition(originalSize, options)
|
| 377 |
+
if (!size.srcWidth || !size.srcHeight || !size.destWidth || !size.destHeight) {
|
| 378 |
+
let error = getError("incorrect sizes for resource \"".concat(getSrc(resource), "\""))
|
| 379 |
+
outputError(error, options.silent)
|
| 380 |
+
return this.prepareResult(defaultColor, error)
|
| 381 |
+
}
|
| 382 |
+
if (!this.canvas) {
|
| 383 |
+
this.canvas = makeCanvas()
|
| 384 |
+
if (!this.canvas) {
|
| 385 |
+
let error = getError('OffscreenCanvas is not supported in this browser')
|
| 386 |
+
outputError(error, options.silent)
|
| 387 |
+
return this.prepareResult(defaultColor, error)
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
if (!this.ctx) {
|
| 391 |
+
this.ctx = this.canvas.getContext('2d', { willReadFrequently: true })
|
| 392 |
+
if (!this.ctx) {
|
| 393 |
+
let error = getError('Canvas Context 2D is not supported in this browser')
|
| 394 |
+
outputError(error, options.silent)
|
| 395 |
+
return this.prepareResult(defaultColor)
|
| 396 |
+
}
|
| 397 |
+
this.ctx.imageSmoothingEnabled = false
|
| 398 |
+
}
|
| 399 |
+
this.canvas.width = size.destWidth
|
| 400 |
+
this.canvas.height = size.destHeight
|
| 401 |
+
try {
|
| 402 |
+
this.ctx.clearRect(0, 0, size.destWidth, size.destHeight)
|
| 403 |
+
this.ctx.drawImage(resource, size.srcLeft, size.srcTop, size.srcWidth, size.srcHeight, 0, 0, size.destWidth, size.destHeight)
|
| 404 |
+
let bitmapData = this.ctx.getImageData(0, 0, size.destWidth, size.destHeight).data
|
| 405 |
+
return this.prepareResult(this.getColorFromArray4(bitmapData, options))
|
| 406 |
+
}
|
| 407 |
+
catch (originalError) {
|
| 408 |
+
let error = getError("security error (CORS) for resource ".concat(getSrc(resource), ".\nDetails: https://developer.mozilla.org/en/docs/Web/HTML/CORS_enabled_image"))
|
| 409 |
+
outputError(error, options.silent)
|
| 410 |
+
!options.silent && console.error(originalError)
|
| 411 |
+
return this.prepareResult(defaultColor, error)
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
/**
|
| 415 |
+
* Get the average color from a array when 1 pixel is 4 bytes.
|
| 416 |
+
*/
|
| 417 |
+
FastAverageColor.prototype.getColorFromArray4 = function (arr, options) {
|
| 418 |
+
options = options || {}
|
| 419 |
+
let bytesPerPixel = 4
|
| 420 |
+
let arrLength = arr.length
|
| 421 |
+
let defaultColor = getDefaultColor(options)
|
| 422 |
+
if (arrLength < bytesPerPixel) {
|
| 423 |
+
return defaultColor
|
| 424 |
+
}
|
| 425 |
+
let len = arrLength - arrLength % bytesPerPixel
|
| 426 |
+
let step = (options.step || 1) * bytesPerPixel
|
| 427 |
+
let algorithm
|
| 428 |
+
switch (options.algorithm || 'sqrt') {
|
| 429 |
+
case 'simple':
|
| 430 |
+
algorithm = simpleAlgorithm
|
| 431 |
+
break
|
| 432 |
+
case 'sqrt':
|
| 433 |
+
algorithm = sqrtAlgorithm
|
| 434 |
+
break
|
| 435 |
+
case 'dominant':
|
| 436 |
+
algorithm = dominantAlgorithm
|
| 437 |
+
break
|
| 438 |
+
default:
|
| 439 |
+
throw getError("".concat(options.algorithm, " is unknown algorithm"))
|
| 440 |
+
}
|
| 441 |
+
return algorithm(arr, len, {
|
| 442 |
+
defaultColor: defaultColor,
|
| 443 |
+
ignoredColor: prepareIgnoredColor(options.ignoredColor),
|
| 444 |
+
step: step
|
| 445 |
+
})
|
| 446 |
+
}
|
| 447 |
+
/**
|
| 448 |
+
* Get color data from value ([r, g, b, a]).
|
| 449 |
+
*/
|
| 450 |
+
FastAverageColor.prototype.prepareResult = function (value, error) {
|
| 451 |
+
let rgb = value.slice(0, 3)
|
| 452 |
+
let rgba = [value[0], value[1], value[2], value[3] / 255]
|
| 453 |
+
let isDarkColor = isDark(value)
|
| 454 |
+
return {
|
| 455 |
+
value: [value[0], value[1], value[2], value[3]],
|
| 456 |
+
rgb: 'rgb(' + rgb.join(',') + ')',
|
| 457 |
+
rgba: 'rgba(' + rgba.join(',') + ')',
|
| 458 |
+
hex: arrayToHex(rgb),
|
| 459 |
+
hexa: arrayToHex(value),
|
| 460 |
+
isDark: isDarkColor,
|
| 461 |
+
isLight: !isDarkColor,
|
| 462 |
+
error: error,
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
/**
|
| 466 |
+
* Destroy the instance.
|
| 467 |
+
*/
|
| 468 |
+
FastAverageColor.prototype.destroy = function () {
|
| 469 |
+
if (this.canvas) {
|
| 470 |
+
this.canvas.width = 1
|
| 471 |
+
this.canvas.height = 1
|
| 472 |
+
this.canvas = null
|
| 473 |
+
}
|
| 474 |
+
this.ctx = null
|
| 475 |
+
}
|
| 476 |
+
FastAverageColor.prototype.bindImageEvents = function (resource, options) {
|
| 477 |
+
let _this = this
|
| 478 |
+
return new Promise(function (resolve, reject) {
|
| 479 |
+
let onload = function () {
|
| 480 |
+
unbindEvents()
|
| 481 |
+
let result = _this.getColor(resource, options)
|
| 482 |
+
if (result.error) {
|
| 483 |
+
reject(result.error)
|
| 484 |
+
}
|
| 485 |
+
else {
|
| 486 |
+
resolve(result)
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
let onerror = function () {
|
| 490 |
+
unbindEvents()
|
| 491 |
+
reject(getError("Error loading image \"".concat(resource.src, "\".")))
|
| 492 |
+
}
|
| 493 |
+
let onabort = function () {
|
| 494 |
+
unbindEvents()
|
| 495 |
+
reject(getError("Image \"".concat(resource.src, "\" loading aborted")))
|
| 496 |
+
}
|
| 497 |
+
let unbindEvents = function () {
|
| 498 |
+
resource.removeEventListener('load', onload)
|
| 499 |
+
resource.removeEventListener('error', onerror)
|
| 500 |
+
resource.removeEventListener('abort', onabort)
|
| 501 |
+
}
|
| 502 |
+
resource.addEventListener('load', onload)
|
| 503 |
+
resource.addEventListener('error', onerror)
|
| 504 |
+
resource.addEventListener('abort', onabort)
|
| 505 |
+
})
|
| 506 |
+
}
|
| 507 |
+
return FastAverageColor
|
| 508 |
+
}())
|
| 509 |
+
|
| 510 |
+
export default FastAverageColor
|
index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Koa from 'koa'
|
| 2 |
+
import http from 'axios'
|
| 3 |
+
import sharp from 'sharp'
|
| 4 |
+
import tf from '@tensorflow/tfjs-node'
|
| 5 |
+
import nsfw from 'nsfwjs'
|
| 6 |
+
|
| 7 |
+
import { extractColors } from './extract-colors.js'
|
| 8 |
+
import FastAverageColor from './fast-average-color.js'
|
| 9 |
+
import { phash } from './phash.js'
|
| 10 |
+
|
| 11 |
+
const app = new Koa()
|
| 12 |
+
|
| 13 |
+
app.use(async (ctx) => {
|
| 14 |
+
const { data: image_buffer } = await http({
|
| 15 |
+
method: 'GET',
|
| 16 |
+
url: String(ctx.request.query.url).replace(
|
| 17 |
+
'i.pximg.net',
|
| 18 |
+
'i.pixiv.re',
|
| 19 |
+
),
|
| 20 |
+
responseType: 'arraybuffer',
|
| 21 |
+
})
|
| 22 |
+
const { data: image_raw, info } = await sharp(image_buffer, {
|
| 23 |
+
animated: true,
|
| 24 |
+
})
|
| 25 |
+
.ensureAlpha()
|
| 26 |
+
.raw()
|
| 27 |
+
.toBuffer({ resolveWithObject: true })
|
| 28 |
+
const image_phash = await phash(image_buffer)
|
| 29 |
+
const colors = await extractColors(
|
| 30 |
+
{ data: image_raw, width: info.width, height: info.height },
|
| 31 |
+
{
|
| 32 |
+
pixels: info.width * info.height,
|
| 33 |
+
distance: 0.05,
|
| 34 |
+
splitPower: 6,
|
| 35 |
+
colorValidator: (red, green, blue, alpha = 255) => alpha >= 255,
|
| 36 |
+
hueDistance: 0,
|
| 37 |
+
saturationDistance: 1,
|
| 38 |
+
lightnessDistance: 1,
|
| 39 |
+
},
|
| 40 |
+
)
|
| 41 |
+
const fac = new FastAverageColor()
|
| 42 |
+
const {
|
| 43 |
+
value: [r, g, b, a],
|
| 44 |
+
hex,
|
| 45 |
+
isLight: blight,
|
| 46 |
+
} = fac.prepareResult(fac.getColorFromArray4(image_raw))
|
| 47 |
+
const image_resize = await sharp(image_buffer, { animated: true })
|
| 48 |
+
.resize({
|
| 49 |
+
width: 128,
|
| 50 |
+
height: 128,
|
| 51 |
+
fit: 'inside',
|
| 52 |
+
})
|
| 53 |
+
.gif({ dither: 0 })
|
| 54 |
+
.toBuffer()
|
| 55 |
+
// const model = await nsfw.load(`https://raw.githubusercontent.com/infinitered/nsfwjs/refs/heads/master/models/mobilenet_v2_mid/model.json`, { type: 'graph' })
|
| 56 |
+
// // const model = await nsfw.load(`https://raw.githubusercontent.com/infinitered/nsfwjs/refs/heads/master/models/mobilenet_v2/model.json`)
|
| 57 |
+
// // const model = await nsfw.load(`https://s3.amazonaws.com/ir_public/nsfwjscdn/TFJS_nsfw_mobilenet/tfjs_quant_nsfw_mobilenet/model.json`)
|
| 58 |
+
// const model = await nsfw.load(`https://raw.githubusercontent.com/infinitered/nsfwjs/refs/heads/master/models/inception_v3/model.json`, { size: 299 })
|
| 59 |
+
const predictions = await model.classifyGif(image_resize, { fps: 1 })
|
| 60 |
+
const detail = new Map()
|
| 61 |
+
predictions
|
| 62 |
+
.flat(Infinity)
|
| 63 |
+
.sort((a, b) => a.probability - b.probability)
|
| 64 |
+
.forEach((i) => {
|
| 65 |
+
detail.set(
|
| 66 |
+
i.className.toLocaleLowerCase(),
|
| 67 |
+
Math.floor(i.probability * 100),
|
| 68 |
+
)
|
| 69 |
+
})
|
| 70 |
+
const fail = ['hentai', 'porn', 'sexy']
|
| 71 |
+
const result = [...detail]
|
| 72 |
+
.filter((i) => i.pop() > 7)
|
| 73 |
+
.some((i) => fail.includes(i.pop()))
|
| 74 |
+
tf.dispose()
|
| 75 |
+
model.model.dispose()
|
| 76 |
+
ctx.body = {
|
| 77 |
+
nsfw: Object.assign({ result }, Object.fromEntries(detail)),
|
| 78 |
+
colors: colors
|
| 79 |
+
.sort((a, b) => b.area - a.area)
|
| 80 |
+
.slice(0, 3)
|
| 81 |
+
.map((i) => {
|
| 82 |
+
// hex: `#${Number(十进制).toString(16).padStart(6, 0)}`
|
| 83 |
+
return {
|
| 84 |
+
color: parseInt(String(i.hex).replace('#', ''), 16),
|
| 85 |
+
ratio: Number(i.area.toFixed(2)),
|
| 86 |
+
}
|
| 87 |
+
}),
|
| 88 |
+
color: hex,
|
| 89 |
+
// blight,
|
| 90 |
+
phash: image_phash,
|
| 91 |
+
}
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
app.listen(process.env.PORT || 7860)
|
package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "nsfw",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "",
|
| 5 |
+
"main": "index.js",
|
| 6 |
+
"type": "module",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"test": "echo \"Error: no test specified\" && exit 1"
|
| 9 |
+
},
|
| 10 |
+
"keywords": [],
|
| 11 |
+
"author": "",
|
| 12 |
+
"license": "ISC",
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@tensorflow/tfjs-node": "^4.4.0",
|
| 15 |
+
"axios": "^1.3.5",
|
| 16 |
+
"form-data": "^4.0.0",
|
| 17 |
+
"koa": "3.0.1",
|
| 18 |
+
"nsfwjs": "^2.4.2"
|
| 19 |
+
}
|
| 20 |
+
}
|
phash.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sharp from 'sharp'
|
| 2 |
+
|
| 3 |
+
const SAMPLE_SIZE = 32
|
| 4 |
+
|
| 5 |
+
const initSQRT = (N) => {
|
| 6 |
+
const c = new Array(N)
|
| 7 |
+
for (let i = 1; i < N; i++) {
|
| 8 |
+
c[i] = 1
|
| 9 |
+
}
|
| 10 |
+
c[0] = 1 / Math.sqrt(2.0)
|
| 11 |
+
return c
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const SQRT = initSQRT(SAMPLE_SIZE)
|
| 15 |
+
|
| 16 |
+
const initCOS = (N) => {
|
| 17 |
+
const cosines = new Array(N)
|
| 18 |
+
for (let k = 0; k < N; k++) {
|
| 19 |
+
cosines[k] = new Array(N)
|
| 20 |
+
for (let n = 0; n < N; n++) {
|
| 21 |
+
cosines[k][n] = Math.cos(((2 * k + 1) / (2.0 * N)) * n * Math.PI)
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
return cosines
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const COS = initCOS(SAMPLE_SIZE)
|
| 28 |
+
|
| 29 |
+
const applyDCT = (f, size) => {
|
| 30 |
+
var N = size
|
| 31 |
+
|
| 32 |
+
var F = new Array(N)
|
| 33 |
+
for (var u = 0; u < N; u++) {
|
| 34 |
+
F[u] = new Array(N)
|
| 35 |
+
for (var v = 0; v < N; v++) {
|
| 36 |
+
var sum = 0
|
| 37 |
+
for (var i = 0; i < N; i++) {
|
| 38 |
+
for (var j = 0; j < N; j++) {
|
| 39 |
+
sum += COS[i][u] * COS[j][v] * f[i][j]
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
sum *= (SQRT[u] * SQRT[v]) / 4
|
| 43 |
+
F[u][v] = sum
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
return F
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const LOW_SIZE = 8
|
| 50 |
+
|
| 51 |
+
export const phash = async (image, options) => {
|
| 52 |
+
const data = await sharp(image, options)
|
| 53 |
+
.greyscale()
|
| 54 |
+
.resize(SAMPLE_SIZE, SAMPLE_SIZE, { fit: 'fill' })
|
| 55 |
+
.rotate()
|
| 56 |
+
.raw()
|
| 57 |
+
.toBuffer()
|
| 58 |
+
// copy signal
|
| 59 |
+
const s = new Array(SAMPLE_SIZE)
|
| 60 |
+
for (let x = 0; x < SAMPLE_SIZE; x++) {
|
| 61 |
+
s[x] = new Array(SAMPLE_SIZE)
|
| 62 |
+
for (let y = 0; y < SAMPLE_SIZE; y++) {
|
| 63 |
+
s[x][y] = data[SAMPLE_SIZE * y + x]
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// apply 2D DCT II
|
| 68 |
+
const dct = applyDCT(s, SAMPLE_SIZE)
|
| 69 |
+
|
| 70 |
+
// get AVG on high frequencies
|
| 71 |
+
let totalSum = 0
|
| 72 |
+
for (let x = 0; x < LOW_SIZE; x++) {
|
| 73 |
+
for (let y = 0; y < LOW_SIZE; y++) {
|
| 74 |
+
totalSum += dct[x + 1][y + 1]
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const avg = totalSum / (LOW_SIZE * LOW_SIZE)
|
| 79 |
+
|
| 80 |
+
// compute hash
|
| 81 |
+
let fingerprint = []
|
| 82 |
+
|
| 83 |
+
for (let x = 0; x < LOW_SIZE; x++) {
|
| 84 |
+
for (let y = 0; y < LOW_SIZE; y++) {
|
| 85 |
+
fingerprint.push(dct[x + 1][y + 1] > avg ? 1 : 0)
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
return parseInt(fingerprint.join(''), 2).toString(16)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export const distance = (a, b) => {
|
| 92 |
+
let count = 0
|
| 93 |
+
for (let i = 0; i < a.length; i++) {
|
| 94 |
+
if (a[i] !== b[i]) {
|
| 95 |
+
count++
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
return count
|
| 99 |
+
}
|