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 |
|---|---|---|
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. |
|
MIT |
Pure JavaScript, no native dependencies, so it installs anywhere. Much slower than sharp. WebP and AVIF are available through separate WASM plugins. |
|
MIT |
The reference encoder and decoder, see the BlurHash page. |
|
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
imagecrate 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 thevipscommand line. -
The in-memory map stands in for a database, and
tokio::spawnfor a queue. In production the upload handler writes to object storage and a worker consumes a queue, see upload pipelines. -
DefaultBodyLimitguards the request size, andwith_guessed_formatreads the magic bytes rather than trusting the file name. Theimagecrate has decoding limits that can be set withLimits, which protects against huge images. -
kill_on_drop(true)stops FFmpeg if the task is cancelled. Addtokio::time::timeoutaround long jobs.
References: axum on docs.rs, image on docs.rs, blurhash on docs.rs and tokio::process.