Node.js and Rust Image and Video Libraries

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Baseline date: 2026-10-07. The Node.js examples were run against sharp 0.35, Jimp 1.6 and blurhash 2.0.5. The Rust example was compiled and run against axum 0.8, image 0.25 and blurhash 0.2.3. FFmpeg commands were run against FFmpeg 6.1. Check each project’s repository for newer releases.

Node.js

Library Licence Notes

sharp

Apache-2.0

libvips-based, fast and low in memory. Reads JPEG, PNG, WebP, AVIF, GIF, TIFF and SVG, and writes JPEG, PNG, WebP, AVIF, GIF and TIFF. The recommended default. Prebuilt binaries are installed with the package.

Jimp

MIT

Pure JavaScript, no native dependencies, so it installs anywhere. Much slower than sharp. WebP and AVIF are available through separate WASM plugins.

blurhash

MIT

The reference encoder and decoder, see the BlurHash page.

fluent-ffmpeg

MIT

Legacy. The repository was archived in May 2025 and the npm package is marked as no longer supported. Existing code keeps working with the FFmpeg it was written for, but new code should spawn ffmpeg directly.

sharp: variants, metadata and BlurHash

npm install sharp blurhash
import sharp from 'sharp';
import { encode } from 'blurhash';

export async function makeVariants(input, outDir) {
  const base = sharp(input).rotate();                    // apply EXIF orientation, then drop the tag
  // metadata() reports the input header, which ignores .rotate(); autoOrient (sharp 0.34+) gives the upright size.
  const { autoOrient: { width } } = await base.metadata();

  for (const w of [320, 640, 1280]) {
    const resized = base.clone().resize({ width: w, withoutEnlargement: true });
    await resized.clone().avif({ quality: 60, effort: 4 }).toFile(`${outDir}/w${w}.avif`);
    await resized.clone().webp({ quality: 78 }).toFile(`${outDir}/w${w}.webp`);
    await resized.clone().jpeg({ quality: 80, mozjpeg: true }).toFile(`${outDir}/w${w}.jpg`);
  }

  // A 2:3 cover with attention-based cropping.
  await base.clone().resize(640, 960, { fit: 'cover', position: 'attention' }).toFile(`${outDir}/cover.jpg`);

  // BlurHash from a 64 px raw RGBA buffer.
  const { data, info } = await base.clone()
    .resize(64, 64, { fit: 'inside' })
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });
  const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);

  return { width, blurhash };
}

sharp strips metadata by default, and withMetadata() (or keepMetadata() in recent versions) keeps it, so the default is what a public variant wants. Convert wide-gamut sources to sRGB with toColorspace() before output. Use sharp.concurrency() and sharp.cache() to control CPU and memory in a worker pod. References: sharp resize, sharp output and sharp input and metadata.

Jimp

import { Jimp } from 'jimp';

const image = await Jimp.read('original.jpg');
image.resize({ w: 320 });                                  // keeps the aspect ratio
await image.write('w320.jpg');
const buffer = await image.getBuffer('image/jpeg', { quality: 80 });

Use Jimp when you cannot install native code (some serverless runtimes, locked-down environments) and the images are small. Reference: Jimp documentation.

Video: spawn ffmpeg directly

import { spawn } from 'node:child_process';

export function transcode(src, dst, height = 720) {
  return new Promise((resolve, reject) => {
    const args = ['-y', '-i', src, '-vf', `scale=-2:${height}`, '-c:v', 'libx264', '-preset', 'slow', '-crf', '22',
      '-g', '48', '-keyint_min', '48', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', dst];
    const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
    const timer = setTimeout(() => child.kill('SIGKILL'), 60 * 60 * 1000);   // hard time limit
    let stderr = '';
    child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-4000); });
    child.on('error', (error) => { clearTimeout(timer); reject(error); });
    child.on('close', (code) => {
      clearTimeout(timer);
      code === 0 ? resolve() : reject(new Error(`ffmpeg exited with ${code}: ${stderr}`));
    });
  });
}

export function cover(src, dst, seconds) {
  return new Promise((resolve, reject) => {
    const child = spawn('ffmpeg', ['-y', '-ss', String(seconds), '-i', src, '-frames:v', '1', '-q:v', '2', dst]);
    child.on('error', reject);
    child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited with ${code}`))));
  });
}

Passing arguments as an array keeps user-provided names from reaching a shell. Reference: Node.js child_process.spawn and the commands in video encoding fundamentals. If you inherit fluent-ffmpeg code, plan its replacement: it only builds the same argument list.

NestJS with BullMQ

The upload side of NestJS is covered in file upload and streaming, and queues in events, scheduling and queues. The pattern for media is a controller that hands out a pre-signed URL and enqueues a job, and a processor that does the work:

npm install @nestjs/bullmq bullmq
// media.module.ts
@Module({
  imports: [
    BullModule.forRoot({ connection: { host: process.env.REDIS_HOST, port: 6379 } }),
    BullModule.registerQueue({
      name: 'media',
      defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 5000 }, removeOnComplete: 1000 },
    }),
  ],
  providers: [MediaService, MediaProcessor],
  controllers: [MediaController],
})
export class MediaModule {}

// media.service.ts
@Injectable()
export class MediaService {
  constructor(@InjectQueue('media') private readonly queue: Queue) {}

  async complete(id: string) {
    // jobId makes the enqueue idempotent: the same media id is never queued twice
    await this.queue.add('process', { id }, { jobId: `media-${id}` });
  }
}

// media.processor.ts
@Processor('media', { concurrency: 2 })
export class MediaProcessor extends WorkerHost {
  async process(job: Job<{ id: string }>) {
    const { width, blurhash } = await makeVariants(await this.storage.download(job.data.id), this.workDir);
    await this.repository.markReady(job.data.id, { width, blurhash });
  }
}

BullMQ retries a failed job with the configured backoff, and keeps failed jobs for inspection, which plays the role of a dead-letter queue. Run the processor in a separate process or deployment, so a CPU-heavy job does not block request handling. References: BullMQ documentation and NestJS queues.

Rust with axum

For the language and the framework themselves, see the Rust Reference and the Axum section (uploads are covered in File Uploads and background work in Background Jobs and Tasks). This example focuses on the media side. It uses axum ( docs.rs/axum ) for HTTP and multipart, the image crate for decoding, resizing and encoding, the blurhash crate for the hash, and tokio::process for FFmpeg.

[dependencies]
axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
image = "0.25"
blurhash = "0.2"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
use axum::{
    extract::{DefaultBodyLimit, Multipart, Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use image::{imageops::FilterType, ImageFormat, ImageReader};
use serde::Serialize;
use std::{collections::HashMap, io::Cursor, sync::Arc};
use tokio::{process::Command, sync::RwLock};

#[derive(Clone, Serialize)]
struct Media {
    id: String,
    status: &'static str,
    blurhash: Option<String>,
}

type Db = Arc<RwLock<HashMap<String, Media>>>;
const MAX_BYTES: usize = 10 * 1024 * 1024;

/// CPU-bound work: decode, resize, encode variants and compute the BlurHash.
fn process_image(bytes: Vec<u8>) -> Result<String, String> {
    let img = ImageReader::new(Cursor::new(bytes))
        .with_guessed_format()
        .map_err(|e| e.to_string())?
        .decode()
        .map_err(|e| e.to_string())?;

    for width in [320u32, 640, 1280] {
        if width >= img.width() {
            continue; // never upscale
        }
        let height = (img.height() as f64 * width as f64 / img.width() as f64).round() as u32;
        let resized = img.resize_exact(width, height, FilterType::Lanczos3);
        let mut out = Cursor::new(Vec::new());
        resized.write_to(&mut out, ImageFormat::Jpeg).map_err(|e| e.to_string())?;
        // upload out.into_inner() to object storage here
    }

    let small = img.resize(64, 64, FilterType::Triangle).to_rgba8();
    blurhash::encode(4, 3, small.width(), small.height(), small.as_raw()).map_err(|e| e.to_string())
}

async fn upload(
    State(db): State<Db>,
    mut multipart: Multipart,
) -> Result<(StatusCode, Json<Media>), StatusCode> {
    let field = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)?.ok_or(StatusCode::BAD_REQUEST)?;
    let bytes = field.bytes().await.map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
    if bytes.len() > MAX_BYTES {
        return Err(StatusCode::PAYLOAD_TOO_LARGE);
    }
    let id = uuid::Uuid::new_v4().to_string();
    let media = Media { id: id.clone(), status: "processing", blurhash: None };
    db.write().await.insert(id.clone(), media.clone());

    let db2 = db.clone();
    tokio::spawn(async move {
        // spawn_blocking keeps CPU-bound work off the async worker threads
        let result = tokio::task::spawn_blocking(move || process_image(bytes.to_vec())).await;
        if let Some(entry) = db2.write().await.get_mut(&id) {
            match result {
                Ok(Ok(hash)) => { entry.status = "ready"; entry.blurhash = Some(hash); }
                _ => entry.status = "failed",
            }
        }
    });
    Ok((StatusCode::ACCEPTED, Json(media)))
}

async fn status(State(db): State<Db>, Path(id): Path<String>) -> Result<Json<Media>, StatusCode> {
    db.read().await.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

/// FFmpeg as a child process. Arguments are a list, never a shell string.
async fn transcode(src: &str, dst: &str) -> std::io::Result<bool> {
    let status = Command::new("ffmpeg")
        .args(["-y", "-i", src, "-vf", "scale=-2:720", "-c:v", "libx264", "-crf", "22"])
        .args(["-c:a", "aac", "-movflags", "+faststart", dst])
        .kill_on_drop(true)
        .status()
        .await?;
    Ok(status.success())
}

#[tokio::main]
async fn main() {
    let db: Db = Arc::default();
    let app = Router::new()
        .route("/images", post(upload))
        .route("/images/{id}", get(status))
        .layer(DefaultBodyLimit::max(MAX_BYTES + 1024))
        .with_state(db);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Notes on the example:

  • axum 0.8 uses the {id} path syntax. Older versions used :id.

  • The image crate encodes JPEG, PNG and WebP (lossless) in pure Rust, and has an AVIF encoder (ravif) behind a feature flag. For AVIF at scale, HEIC and JPEG XL, use libvips through a binding or the vips command line.

  • The in-memory map stands in for a database, and tokio::spawn for a queue. In production the upload handler writes to object storage and a worker consumes a queue, see upload pipelines.

  • DefaultBodyLimit guards the request size, and with_guessed_format reads the magic bytes rather than trusting the file name. The image crate has decoding limits that can be set with Limits, which protects against huge images.

  • kill_on_drop(true) stops FFmpeg if the task is cancelled. Add tokio::time::timeout around long jobs.