| 1 | import Foundation |
| 2 | import CoreGraphics |
| 3 | |
| 4 | // 🤖 BlurHash (blurha.sh) — the compact DCT thumbnail that stands in for a |
| 5 | // photo while its blob downloads + decrypts. The hash is DERIVED FROM IMAGE |
| 6 | // CONTENT, so it is user content: it rides inside the encrypted payload as |
| 7 | // `attachments[].bh` (beside w/h/name), never as server metadata. ~30 chars |
| 8 | // of base83 for a 4×3 component hash. |
| 9 | // |
| 10 | // Ported from the reference implementation (github.com/woltapp/blurhash, MIT); |
| 11 | // trimmed to what the app uses: encode from a CGImage, decode to a CGImage. |
| 12 | public enum Blurhash { |
| 13 | private static let alphabet = Array( |
| 14 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~") |
| 15 | private static let charValue: [Character: Int] = { |
| 16 | var m: [Character: Int] = [:] |
| 17 | for (i, c) in alphabet.enumerated() { m[c] = i } |
| 18 | return m |
| 19 | }() |
| 20 | |
| 21 | // ---- encode -------------------------------------------------------------- |
| 22 | |
| 23 | /// Encode a (small!) CGImage — callers downsample first; DCT cost is |
| 24 | /// O(pixels × components). 4×3 components suit chat thumbnails. |
| 25 | public static func encode(_ image: CGImage, componentsX: Int = 4, componentsY: Int = 3) -> String? { |
| 26 | guard (1...9).contains(componentsX), (1...9).contains(componentsY) else { return nil } |
| 27 | let w = image.width, h = image.height |
| 28 | guard w > 0, h > 0 else { return nil } |
| 29 | guard let data = rgbaPixels(image) else { return nil } |
| 30 | |
| 31 | var factors: [(Double, Double, Double)] = [] |
| 32 | for y in 0..<componentsY { |
| 33 | for x in 0..<componentsX { |
| 34 | let normalisation = (x == 0 && y == 0) ? 1.0 : 2.0 |
| 35 | var r = 0.0, g = 0.0, b = 0.0 |
| 36 | for py in 0..<h { |
| 37 | for px in 0..<w { |
| 38 | let basis = normalisation |
| 39 | * cos(Double.pi * Double(x) * Double(px) / Double(w)) |
| 40 | * cos(Double.pi * Double(y) * Double(py) / Double(h)) |
| 41 | let i = (py * w + px) * 4 |
| 42 | r += basis * sRGBToLinear(Int(data[i])) |
| 43 | g += basis * sRGBToLinear(Int(data[i + 1])) |
| 44 | b += basis * sRGBToLinear(Int(data[i + 2])) |
| 45 | } |
| 46 | } |
| 47 | let scale = 1.0 / Double(w * h) |
| 48 | factors.append((r * scale, g * scale, b * scale)) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | let dc = factors[0] |
| 53 | let ac = factors.dropFirst() |
| 54 | var hash = "" |
| 55 | hash += base83(componentsX - 1 + (componentsY - 1) * 9, length: 1) |
| 56 | let maxAC = ac.reduce(0.0) { m, f in max(m, abs(f.0), abs(f.1), abs(f.2)) } |
| 57 | let quantMax = ac.isEmpty ? 0 : max(0, min(82, Int(floor(maxAC * 166 - 0.5)))) |
| 58 | let acNorm = Double(quantMax + 1) / 166 |
| 59 | hash += base83(quantMax, length: 1) |
| 60 | hash += base83(encodeDC(dc), length: 4) |
| 61 | for f in ac { hash += base83(encodeAC(f, maximum: acNorm), length: 2) } |
| 62 | return hash |
| 63 | } |
| 64 | |
| 65 | // ---- decode -------------------------------------------------------------- |
| 66 | |
| 67 | /// Decode to a tiny CGImage (the renderer scales it up; blurriness IS the |
| 68 | /// look, so 32px on the long edge is plenty). Nil on a malformed hash. |
| 69 | public static func decode(_ hash: String, width: Int = 32, height: Int = 24, |
| 70 | punch: Double = 1) -> CGImage? { |
| 71 | let chars = Array(hash) |
| 72 | guard chars.count >= 6, let sizeFlag = value(chars[0]) else { return nil } |
| 73 | let cy = (sizeFlag / 9) + 1 |
| 74 | let cx = (sizeFlag % 9) + 1 |
| 75 | guard chars.count == 4 + 2 * cx * cy, let quantMax = value(chars[1]) else { return nil } |
| 76 | let maxAC = Double(quantMax + 1) / 166 |
| 77 | |
| 78 | var colours: [(Double, Double, Double)] = [] |
| 79 | guard let dcValue = value(String(chars[2..<6])) else { return nil } |
| 80 | colours.append(decodeDC(dcValue)) |
| 81 | for i in 1..<(cx * cy) { |
| 82 | guard let acValue = value(String(chars[(4 + i * 2)..<(6 + i * 2)])) else { return nil } |
| 83 | colours.append(decodeAC(acValue, maximum: maxAC * punch)) |
| 84 | } |
| 85 | |
| 86 | var pixels = [UInt8](repeating: 255, count: width * height * 4) |
| 87 | for py in 0..<height { |
| 88 | for px in 0..<width { |
| 89 | var r = 0.0, g = 0.0, b = 0.0 |
| 90 | for j in 0..<cy { |
| 91 | for i in 0..<cx { |
| 92 | let basis = cos(Double.pi * Double(px) * Double(i) / Double(width)) |
| 93 | * cos(Double.pi * Double(py) * Double(j) / Double(height)) |
| 94 | let c = colours[i + j * cx] |
| 95 | r += c.0 * basis |
| 96 | g += c.1 * basis |
| 97 | b += c.2 * basis |
| 98 | } |
| 99 | } |
| 100 | let o = (py * width + px) * 4 |
| 101 | pixels[o] = UInt8(linearToSRGB(r)) |
| 102 | pixels[o + 1] = UInt8(linearToSRGB(g)) |
| 103 | pixels[o + 2] = UInt8(linearToSRGB(b)) |
| 104 | } |
| 105 | } |
| 106 | let cs = CGColorSpaceCreateDeviceRGB() |
| 107 | guard let provider = CGDataProvider(data: Data(pixels) as CFData) else { return nil } |
| 108 | return CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, |
| 109 | bytesPerRow: width * 4, space: cs, |
| 110 | bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue), |
| 111 | provider: provider, decode: nil, shouldInterpolate: true, |
| 112 | intent: .defaultIntent) |
| 113 | } |
| 114 | |
| 115 | // ---- pieces --------------------------------------------------------------- |
| 116 | |
| 117 | private static func rgbaPixels(_ image: CGImage) -> [UInt8]? { |
| 118 | let w = image.width, h = image.height |
| 119 | var data = [UInt8](repeating: 0, count: w * h * 4) |
| 120 | let cs = CGColorSpaceCreateDeviceRGB() |
| 121 | guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, |
| 122 | bytesPerRow: w * 4, space: cs, |
| 123 | bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) |
| 124 | else { return nil } |
| 125 | ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| 126 | return data |
| 127 | } |
| 128 | |
| 129 | private static func encodeDC(_ c: (Double, Double, Double)) -> Int { |
| 130 | (linearToSRGB(c.0) << 16) + (linearToSRGB(c.1) << 8) + linearToSRGB(c.2) |
| 131 | } |
| 132 | |
| 133 | private static func decodeDC(_ value: Int) -> (Double, Double, Double) { |
| 134 | (sRGBToLinear((value >> 16) & 255), sRGBToLinear((value >> 8) & 255), |
| 135 | sRGBToLinear(value & 255)) |
| 136 | } |
| 137 | |
| 138 | private static func encodeAC(_ c: (Double, Double, Double), maximum: Double) -> Int { |
| 139 | func q(_ v: Double) -> Int { |
| 140 | max(0, min(18, Int(floor(signPow(v / maximum, 0.5) * 9 + 9.5)))) |
| 141 | } |
| 142 | return q(c.0) * 19 * 19 + q(c.1) * 19 + q(c.2) |
| 143 | } |
| 144 | |
| 145 | private static func decodeAC(_ value: Int, maximum: Double) -> (Double, Double, Double) { |
| 146 | func d(_ v: Int) -> Double { signPow((Double(v) - 9) / 9, 2) * maximum } |
| 147 | return (d(value / (19 * 19)), d((value / 19) % 19), d(value % 19)) |
| 148 | } |
| 149 | |
| 150 | private static func signPow(_ v: Double, _ exp: Double) -> Double { |
| 151 | (v < 0 ? -1 : 1) * pow(abs(v), exp) |
| 152 | } |
| 153 | |
| 154 | private static func sRGBToLinear(_ v: Int) -> Double { |
| 155 | let f = Double(v) / 255 |
| 156 | return f <= 0.04045 ? f / 12.92 : pow((f + 0.055) / 1.055, 2.4) |
| 157 | } |
| 158 | |
| 159 | private static func linearToSRGB(_ v: Double) -> Int { |
| 160 | let f = max(0, min(1, v)) |
| 161 | let s = f <= 0.0031308 ? f * 12.92 : 1.055 * pow(f, 1 / 2.4) - 0.055 |
| 162 | return Int(s * 255 + 0.5) |
| 163 | } |
| 164 | |
| 165 | private static func base83(_ value: Int, length: Int) -> String { |
| 166 | var out = "" |
| 167 | for i in 1...length { |
| 168 | let digit = (value / Int(pow(83.0, Double(length - i)))) % 83 |
| 169 | out.append(alphabet[digit]) |
| 170 | } |
| 171 | return out |
| 172 | } |
| 173 | |
| 174 | private static func value(_ c: Character) -> Int? { charValue[c] } |
| 175 | |
| 176 | private static func value(_ s: any StringProtocol) -> Int? { |
| 177 | var out = 0 |
| 178 | for c in s { |
| 179 | guard let v = charValue[c] else { return nil } |
| 180 | out = out * 83 + v |
| 181 | } |
| 182 | return out |
| 183 | } |
| 184 | } |
| 185 | |