Skip to content

Commit

Permalink
56 blurhash (#57)
Browse files Browse the repository at this point in the history
* feat(iOS): blurhash

* chore: wip
  • Loading branch information
duguyihou authored Dec 4, 2023
1 parent f091fc8 commit 9c1be3e
Show file tree
Hide file tree
Showing 7 changed files with 309 additions and 6 deletions.
12 changes: 6 additions & 6 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { SafeAreaView, ScrollView, StyleSheet, Text } from 'react-native';
import TurboImage from 'react-native-turbo-image';
import { base64Placeholder } from './mockData';
import { blurhashString } from './mockData';

export default function App() {
const imageURLs = Array.from(
Expand All @@ -24,13 +24,13 @@ export default function App() {
key={idx}
url={url}
style={styles.box}
resizeMode="contain"
resizeMode="stretch"
showActivityIndicator
fadeDuration={10}
base64Placeholder={base64Placeholder}
rounded
// fadeDuration={10}
blurhash={blurhashString}
// rounded
// tintColor="red"
// cachePolicy="memory"
cachePolicy="memory"
onSuccess={handleOnSuccess}
onError={handleOnError}
/>
Expand Down
2 changes: 2 additions & 0 deletions example/src/mockData.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export const base64Placeholder =
'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';

export const blurhashString = 'LMDSzI~pV=RO9ZV@xv%MRPRlxuog';
146 changes: 146 additions & 0 deletions ios/TurboImage/BlurHashDecode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import UIKit

extension UIImage {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil }

let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1

let quantisedMaximumValue = String(blurHash[1]).decode83()
let maximumValue = Float(quantisedMaximumValue + 1) / 166

guard blurHash.count == 4 + 2 * numX * numY else { return nil }

let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
if i == 0 {
let value = String(blurHash[2 ..< 6]).decode83()
return decodeDC(value)
} else {
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
return decodeAC(value, maximumValue: maximumValue * punch)
}
}

let width = Int(size.width)
let height = Int(size.height)
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }

for y in 0 ..< height {
for x in 0 ..< width {
var r: Float = 0
var g: Float = 0
var b: Float = 0

for j in 0 ..< numY {
for i in 0 ..< numX {
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
let colour = colours[i + j * numX]
r += colour.0 * basis
g += colour.1 * basis
b += colour.2 * basis
}
}

let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b))

pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG
pixels[3 * x + 2 + y * bytesPerRow] = intB
}
}

let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)

guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }

self.init(cgImage: cgImage)
}
}

private func decodeDC(_ value: Int) -> (Float, Float, Float) {
let intR = value >> 16
let intG = (value >> 8) & 255
let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
}

private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19

let rgb = (
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)

return rgb
}

private func signPow(_ value: Float, _ exp: Float) -> Float {
return copysign(pow(abs(value), exp), value)
}

private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
}

private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
}

private let encodeCharacters: [String] = {
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}()

private let decodeCharacters: [String: Int] = {
var dict: [String: Int] = [:]
for (index, character) in encodeCharacters.enumerated() {
dict[character] = index
}
return dict
}()

extension String {
func decode83() -> Int {
var value: Int = 0
for character in self {
if let digit = decodeCharacters[String(character)] {
value = value * 83 + digit
}
}
return value
}
}

private extension String {
subscript (offset: Int) -> Character {
return self[index(startIndex, offsetBy: offset)]
}

subscript (bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start...end]
}

subscript (bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start..<end]
}
}
145 changes: 145 additions & 0 deletions ios/TurboImage/BlurHashEncode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import UIKit

extension UIImage {
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
let pixelWidth = Int(round(size.width * scale))
let pixelHeight = Int(round(size.height * scale))

let context = CGContext(
data: nil,
width: pixelWidth,
height: pixelHeight,
bitsPerComponent: 8,
bytesPerRow: pixelWidth * 4,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.scaleBy(x: scale, y: -scale)
context.translateBy(x: 0, y: -size.height)

UIGraphicsPushContext(context)
draw(at: .zero)
UIGraphicsPopContext()

guard let cgImage = context.makeImage(),
let dataProvider = cgImage.dataProvider,
let data = dataProvider.data,
let pixels = CFDataGetBytePtr(data) else {
assertionFailure("Unexpected error!")
return nil
}

let width = cgImage.width
let height = cgImage.height
let bytesPerRow = cgImage.bytesPerRow

var factors: [(Float, Float, Float)] = []
for y in 0 ..< components.1 {
for x in 0 ..< components.0 {
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) {
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
}
factors.append(factor)
}
}

let dc = factors.first!
let ac = factors.dropFirst()

var hash = ""

let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
hash += sizeFlag.encode83(length: 1)

let maximumValue: Float
if ac.count > 0 {
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
maximumValue = Float(quantisedMaximumValue + 1) / 166
hash += quantisedMaximumValue.encode83(length: 1)
} else {
maximumValue = 1
hash += 0.encode83(length: 1)
}

hash += encodeDC(dc).encode83(length: 4)

for factor in ac {
hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2)
}

return hash
}

private func multiplyBasisFunction(pixels: UnsafePointer<UInt8>, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) {
var r: Float = 0
var g: Float = 0
var b: Float = 0

let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)

for x in 0 ..< width {
for y in 0 ..< height {
let basis = basisFunction(Float(x), Float(y))
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
}
}

let scale = 1 / Float(width * height)

return (r * scale, g * scale, b * scale)
}
}

private func encodeDC(_ value: (Float, Float, Float)) -> Int {
let roundedR = linearTosRGB(value.0)
let roundedG = linearTosRGB(value.1)
let roundedB = linearTosRGB(value.2)
return (roundedR << 16) + (roundedG << 8) + roundedB
}

private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))

return quantR * 19 * 19 + quantG * 19 + quantB
}

private func signPow(_ value: Float, _ exp: Float) -> Float {
return copysign(pow(abs(value), exp), value)
}

private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
}

private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
}

private let encodeCharacters: [String] = {
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}()

extension BinaryInteger {
func encode83(length: Int) -> String {
var result = ""
for i in 1 ... length {
let digit = (Int(self) / pow(83, length - i)) % 83
result += encodeCharacters[Int(digit)]
}
return result
}
}

private func pow(_ base: Int, _ exponent: Int) -> Int {
return (0 ..< exponent).reduce(1) { value, _ in value * base }
}
7 changes: 7 additions & 0 deletions ios/TurboImage/TurboImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ class TurboImageView : UIView {
}
}

@objc var blurhash: String? {
didSet {
placeholder = UIImage(blurHash: blurhash ?? "",
size: CGSize(width: 32, height: 32))
}
}

@objc var fadeDuration: NSNumber = 0.5

@objc var rounded: Bool = false {
Expand Down
2 changes: 2 additions & 0 deletions ios/TurboImage/TurboImageViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ @interface RCT_EXTERN_MODULE(TurboImageViewManager, RCTViewManager)

RCT_EXPORT_VIEW_PROPERTY(base64Placeholder, NSString)

RCT_EXPORT_VIEW_PROPERTY(blurhash, NSString)

RCT_EXPORT_VIEW_PROPERTY(fadeDuration, NSNumber)

RCT_EXPORT_VIEW_PROPERTY(rounded, BOOL)
Expand Down
1 change: 1 addition & 0 deletions src/TurboImage.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface TurboImageProps extends AccessibilityProps, ViewProps {
resizeMode?: ResizeMode;
showActivityIndicator?: boolean;
base64Placeholder?: string;
blurhash?: string;
fadeDuration?: number;
rounded?: boolean;
tintColor?: string;
Expand Down

0 comments on commit 9c1be3e

Please sign in to comment.