Creando librerias para node con Rust
Creando librerias para node con Rust
En este artículo exploraremos cómo crear librerías para Node.js utilizando Rust y Neon Bindings una biblioteca que nos permite crear bindings para Node.js con Rust de manera sencilla y eficiente.
¿Por qué Rust?
Rust es un lenguaje de programación de sistemas que se enfoca en la seguridad, la concurrencia y el rendimiento. Es un lenguaje moderno que nos permite escribir código eficiente y seguro. Además, Rust tiene una gran comunidad y un ecosistema de herramientas muy activo.
Definiendo nuestra librería
Después de considerar diferentes enfoques, he decidido crear una librería que nos permita extraer los colores de una imagen. Esta idea está inspirada en ColorThief, una herramienta popular para extraer colores de imágenes.
A diferencia de la librería image de Rust, vamos a interactuar directamente con las librerías de decodificación existentes en Rust. Antes de comenzar, es importante definir los formatos de imágenes que vamos a soportar.
El diagrama de la librería se vería así:
graph TD
A[Node.js] -->|Llama a| B[Librería de TypeScript]
B -->|Decide qué hacer con el buffer y el formato| C[Buffer y Formato]
C -->|Pasa el buffer y el formato al addon de Node.js| D[Addon de Node.js]
D -->|Devuelve la paleta de colores| E[Paleta de Colores]
E -->|Devuelve la paleta de colores a Node.js| A
Soporte de formatos de imagen
Para nuestro proyecto, vamos a soportar los siguientes formatos de imagen: JPG, PNG, GIF, BMP y WEBP. Para ello, vamos a utilizar las siguientes librerías de Rust: palette_extract, zune-jpeg, png, gif, zune-bmp y libwebp.
Creando el proyecto
Comenzando el proyecto para esta parte simplemente seguiremos la documentacion de Neon Bindings para crear un proyecto de Rust con soporte para Node.js.
Nos indica que hay que ejecutar el comando npm init neon PixDecodeRust
esto nos dejara una estructura de archivos como la siguiente:
PixDecodeRust/
├── Cargo.toml
├── README.md
├── package.json
└── src
└── lib.rs
si entramos a a la carpeta con cd PixDecodeRust
y ejecutamos npm install
nos instalara las dependencias necesarias para el proyecto.
Implementando la extracción de colores
Ahora que tenemos nuestro proyecto listo, vamos a implementar la extracción de colores. Para ello, vamos a crear una función en Rust que reciba una imagen y nos devuelva los colores dominantes.
NOTA: Para maximizar la eficiencia, se recomienda pasar el buffer de la imagen desde JavaScript en lugar de la ruta de la imagen. De esta manera, el programa no necesita gastar tiempo en leer el archivo desde una ruta. Además, también se solicitará desde JavaScript el formato de la imagen, pero eso lo veremos mas tarde.
La idea del programa hecho en rust sera usar las librerias que mencionamos anteriormente para decodificar la imagen para obtener los pixeles de la imagen y luego usamos la libreria palette_extract que es una biblioteca que se basa en el algoritmo de Leptonica para extraer los colores dominantes de una imagen, entonces nuestra estructura quedaria asi:
struct Image {
pixels: Vec<u8>,
quality: u8,
max_colors: u8,
}
Implementaremos un método en la estructura que nos permita obtener los colores dominantes de la imagen:
impl Image {
fn to_object<'a, C: Context<'a>>(cx: &mut C, img: Image) -> Handle<'a, JsObject> {
let obj = JsObject::new(cx);
let r: Vec<Color> = get_palette_with_options(
&img.pixels,
PixelEncoding::Rgb,
Quality::new(img.quality),
MaxColors::new(img.max_colors),
PixelFilter::White,
);
let palette = JsArray::new(cx, r.len() as u32);
r.iter().enumerate().for_each(|(_i, color)| {
let obj = JsObject::new(cx);
let r = cx.number(color.r as f64);
let g = cx.number(color.g as f64);
let b = cx.number(color.b as f64);
obj.set(cx, "r", r).unwrap();
obj.set(cx, "g", g).unwrap();
obj.set(cx, "b", b).unwrap();
palette.set(cx, _i as u32, obj).unwrap();
});
obj.set(cx, "palette", palette).unwrap();
obj
}
}
A continuación, crearemos una función para un tipo de imagen específico y repetiremos el proceso para cada tipo de imagen que soportemos:
fn png(mut cx: FunctionContext) -> JsResult<JsObject> {
let buffer = cx.argument::<JsBuffer>(0)?;
let quality_fs64 = cx.argument::<JsNumber>(1)?.value(&mut cx);
let quality = format!("{:.0}", quality_fs64).parse::<u8>().unwrap();
let max_colors_fs64 = cx.argument::<JsNumber>(2)?.value(&mut cx);
let max_colors = format!("{:.0}", max_colors_fs64).parse::<u8>().unwrap();
let data = buffer.as_slice(&mut cx);
let decoder = png::Decoder::new(data);
let mut reader = decoder.read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size()];
reader.next_frame(&mut pixels).unwrap();
let obj = Image::to_object(
&mut cx,
Image {
pixels,
quality,
max_colors,
},
);
Ok(obj)
}
Luego, exportamos la función png
para que sea accesible desde JavaScript:
#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
cx.export_function("png", png)?;
Ok(())
}
Ahora solo tenemos que compilar el proyecto con npm install
y ya tendremos nuestro archivo index.node
que podremos usar en nuestro proyecto de node.js.
Usando la librería en Node.js
En node simplemente iniciaremos un proyecto con npm init -y
y en mi caso configurare el proyecto para que funcione con typescript con npx tsc --init
. Por ultimo asi quedaria mi archivo tsconfig.json
:
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es7",
"ESNext",
"DOM"
],
"target": "ES2018",
"removeComments": false,
"esModuleInterop": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"strictPropertyInitialization": false,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"downlevelIteration": true,
"isolatedModules": true,
"noImplicitAny": false,
"declaration": true
},
"include": [
"../src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
A continuación, crearemos la estructura del proyecto:
Pix/
├── src
│ ├── index.ts
│ ├── lib
│ │ ├── addon.js
│ │ ├── index.node
├── package.json
└── tsconfig.json
En la estructura del proyecto, podemos observar un archivo .js
y un archivo .node
. El archivo .js
se utiliza para exportar las funciones que serán utilizadas en Node.js, mientras que el archivo .node
es el resultado de la compilación del proyecto en Rust.
A continuación, en el archivo addon.js
, agregaremos el siguiente código:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { png } = require(".");
export { png };
Estamos usando node:module
para poder importar el modulo require
y asi poder importar el archivo index.node
que nos genero el proyecto de rust, en este caso solo estamos exportando la funcion png
que es la que usamos en el proyecto de rust.
Ahora en el archivo index.ts
implementaremos los Formatos de imagen que soportaremos de esta manera sera mas facil de usar en el futuro, ademas agrega algunos tipos de datos que nos ayudaran a trabajar con la libreria:
import { png } from "./lib/addon.js"
export enum Format {
JPG,
}
type PaletteColor = [number, number, number];
type TPalette = PaletteColor[];
type Output = {
palette: TPalette;
};
Ahora implementaremos la clase Color
que nos ayudará a trabajar con los colores. También agregaremos una función llamada toHex()
que devolverá el color en formato hexadecimal:
class Color {
r: number
g: number
b: number
toHex(): string {
return `#${this.r.toString(16).padStart(2, '0')}${this.g.toString(16).padStart(2, '0')}${this.b.toString(16).padStart(2, '0')}`
}
}
Ahora implementaremos la clase Palette
y Dominant
que nos ayudara a tener mas control sobre los colores que extraigamos de las imagenes:
class Palette {
colors: Color[];
constructor(colors: Color[]) {
this.colors = colors;
}
toHex(): string[] {
return this.colors.map(color => color.toHex());
}
}
class Dominant {
color: Color
constructor(color: Color) {
this.color = color;
}
toHex(): string {
return this.color.toHex();
}
}
Por ultimo solo faltaria implementar la clase Pix
que sera el nucleo de nuestra libreria, a esta clase se la pasara por parametro el buffer de la imagen y el formato de la imagen que se quiere extraer los colores, pero ademas dentro del constructor aprovecharemos para extraer el color dominante y la paleta de colores de la imagen, ademas de que tambien se encargara de convertir los colores a la clase Color
que creamos anteriormente, asi quedaria la clase Pix
:
class Pix {
static Format = Format
public palette: Palette
dominant: Dominant
/**
* Creates a new Pix instance.
* @param buffer - The image buffer.
* @param filetype - The file format of the image.
* @throws Error if the buffer is empty or the filetype is unsupported.
*/
constructor(buffer: Buffer, filetype: Format.JPG | Format.PNG | Format.GIF | Format.BMP | Format.WEBP) {
if (!buffer || buffer.length === 0) {
throw new Error("Buffer is empty")
}
if (!filetype) {
throw new Error("Filetype is empty")
}
let output: Output;
switch (filetype) {
// @ts-ignore
case Format.JPG:
output = jpg(buffer);
break;
case Format.PNG:
output = png(buffer);
break;
case Format.GIF:
output = gif(buffer);
break;
case Format.BMP:
output = bmp(buffer);
break;
case Format.WEBP:
output = webp(buffer);
break;
default:
throw new Error("Unsupported file format")
}
if (!output.palette) {
throw new Error("Palette not found")
}
const dominantColor = new Color();
dominantColor.r = output.palette[0][0];
dominantColor.g = output.palette[0][1];
dominantColor.b = output.palette[0][2];
const paletteColors = output.palette.slice(1).map(colorArray => {
const color = new Color();
color.r = colorArray[0];
color.g = colorArray[1];
color.b = colorArray[2];
return color;
});
this.palette = new Palette(paletteColors);
this.dominant = new Dominant(dominantColor);
}
}
Solo queda exportar la clase Pix
para que sea accesible desde Node.js:
export { Pix }
Una vez hecho esto, debemos compilar el proyecto con tsc
. Con eso, nuestra librería estará lista para ser utilizada en Node.js. A continuación, se muestra un ejemplo de cómo se usaría:
import Pix from '../dist/pix';
import * as fs from 'fs';
const imageBuffer = fs.promises.readFile('path_to_your_image.jpg');
const image = new Pix(imageBuffer, Pix.Format.JPG);
console.log('Dominant Color:', image.dominant.toHex());
console.log('Palette:', image.palette.toHex());
En este ejemplo, importamos la clase Pix
desde nuestra librería, leemos una imagen del sistema de archivos y creamos una nueva instancia de Pix
con el buffer de la imagen y el formato de la imagen. Luego, imprimimos el color dominante y la paleta de colores de la imagen en formato hexadecimal.
Rendimiento y eficiencia
Una vez hecho esto podemos testear la libreria y ver que tan eficiente es, en mi caso hice una prueba con una imagen de 3120x3920 y la libreria tardo 0.2 segundos en extraer los colores de la imagen, a diferencia de la libreria que use como referencia que tardo 2.3 segundos, asi que podemos decir que la libreria es bastante eficiente, dejo una tabla con los resultados con diferentes formatos de imagen:
File Type | Pix (ms) | ColorThief (ms) |
PNG | 274 | 2325 |
JPEG | 309 | 3781 |
GIF | 262 | 237 |
BMP | 122 | No Supported |
WEBP | 726 | No Supported |
Conclusión
En este artículo hemos explorado cómo crear una librería para Node.js utilizando Rust y Neon Bindings. Hemos creado una librería que nos permite extraer los colores de una imagen de manera eficiente y segura. Además, hemos visto cómo usar la librería en un proyecto de Node.js y hemos comprobado su eficiencia y rendimiento.