import gleam/int import gleam/list import gleam/result pub type EncodingError { InvalidComponentsSize(message: String) InvalidImageSize(message: String) InvalidPixelArraySize(message: String) InvalidPixelValue(message: String) } /// Data representation of an image - width and height cannot be zero or negeative. /// Pixels is a list of values from 0 to 255 inclusive. Each pixel is represented by 3 values in this order: [r, g, b]. /// /// For example - an image that's 320x240 pixels should contain 320x240x3 = 230400 values. pub type ImageData { ImageData(width: Int, height: Int, pixels: List(Int)) } /// Components size - both x and y should be an integer between 1 and 9 inclusive. /// Higher values produce "better" hash image, but the resulting blurhash string is longer. /// A good default would be something like 4x4. pub type Components { Components(x: Int, y: Int) } /// Encodes the provided image into a BlurHash - that can be later decoded into thumbnail image or used with other BlurHash-compatible libraries. pub fn encode( image: ImageData, components: Components, ) -> Result(String, EncodingError) { use _ <- result.try(validate_components(components)) use _ <- result.try(validate_image_data(image)) let blurhash = encode_blurhash_via_ffi( image.pixels, image.width, image.height, components.x, components.y, ) Ok(blurhash) } @external(erlang, "Elixir.BlurHash", "encode") @external(javascript, "./blush_ffi.mjs", "encode") fn encode_blurhash_via_ffi( pixels: List(Int), width: Int, height: Int, components_x: Int, components_y: Int, ) -> String fn validate_components(components: Components) -> Result(Nil, EncodingError) { use _ <- result.try(validate_component_size(components.x, "x")) use _ <- result.try(validate_component_size(components.y, "y")) Ok(Nil) } fn validate_component_size( value: Int, label: String, ) -> Result(Nil, EncodingError) { case value { valid if valid > 0 && valid < 10 -> Ok(Nil) _invalid -> { let message = "Invalid component size: " <> label <> ". Expected an integer value that's between 1 and 9 inclusive." Error(InvalidComponentsSize(message)) } } } fn validate_image_data(image: ImageData) -> Result(Nil, EncodingError) { use _ <- result.try(validate_image_size(image.width, "width")) use _ <- result.try(validate_image_size(image.height, "height")) use _ <- result.try(validate_pixels(image)) [ validate_image_size(image.width, "width"), validate_image_size(image.height, "height"), ] |> result.all() |> result.replace(Nil) } fn validate_image_size(value: Int, label: String) -> Result(Nil, EncodingError) { case value { valid if valid > 0 -> Ok(Nil) _invalid -> { let message = "Invalid image size. Expected " <> label <> " to be greater than zero." Error(InvalidImageSize(message)) } } } fn validate_pixels(image: ImageData) -> Result(Nil, EncodingError) { let expected_items = image.width * image.height * 3 let actual_items = list.length(image.pixels) case actual_items == expected_items { False -> { let message = "Expected " <> int.to_string(image.width) <> " * " <> int.to_string(image.height) <> " * 3 (r, g, b) = " <> int.to_string(expected_items) <> " items in the pixel array, but the pixel array size contains " <> int.to_string(actual_items) <> " items instead." Error(InvalidPixelArraySize(message)) } True -> validate_pixel_values(image) } } fn validate_pixel_values(image: ImageData) -> Result(Nil, EncodingError) { let offending_pixel_value = list.find(image.pixels, fn(value) { case value { valid if valid >= 0 && valid <= 255 -> False _invalid -> True } }) case offending_pixel_value { Ok(pixel) -> { let message = "All pixel values must be integers between 0 and 255 inclusive. Encountered pixel with value " <> int.to_string(pixel) <> " which is not valid." Error(InvalidPixelValue(message)) } _ -> Ok(Nil) } }