Skip to content

aazamov/snapblob

Repository files navigation

snapblob

Browser-native image compression & video transcoding. Zero framework dependency. One function call.

npm version License: MIT TypeScript

Documentation & Playground | GitHub | npm

  • Image compression -- resize, convert format, and adjust quality entirely in the browser
  • Batch processing -- compress multiple images with concurrency control
  • Format detection -- automatically pick the best format (AVIF > WebP > JPEG)
  • Video transcoding -- transcode videos using FFmpeg WASM with full codec control
  • Audio extraction -- extract audio tracks from video files
  • Video thumbnails -- generate thumbnail images from any point in a video
  • Zero backend dependency -- returns a Blob you can upload however you want
  • Tree-shakeable -- import only image or video to keep your bundle small
  • TypeScript-first -- full type definitions with IntelliSense-friendly APIs
  • Framework-agnostic -- works with React, Vue, Svelte, Angular, or vanilla JS

Installation

npm install snapblob

Install only the peer dependencies you need:

# Image compression only
npm install pica

# Video transcoding only
npm install @ffmpeg/ffmpeg @ffmpeg/util

# Both
npm install pica @ffmpeg/ffmpeg @ffmpeg/util

Quick Start

// Compress an image
import { compressImage } from "snapblob/image";

const blob = await compressImage(file, { maxWidth: 1920, quality: 0.8 });

// Transcode a video
import { transcodeVideo, applyPreset } from "snapblob/video";

const video = await transcodeVideo(file, {
  ...applyPreset("balanced"),
  onProgress: (p) => console.log(`${p}%`),
});

All processing functions return a standard Blob -- upload it, display it, or download it.

// Batch compress multiple images
import { compressImages } from "snapblob/image";

const blobs = await compressImages(files, {
  maxWidth: 1280,
  quality: 0.8,
  concurrency: 4,
});

// Auto-detect best format
import { getBestImageFormat } from "snapblob/image";

const format = getBestImageFormat(); // AVIF > WebP > JPEG
const blob = await compressImage(file, { mimeType: format });

Image API

compressImage(file, options?)

Compresses and optionally resizes an image. Uses the Pica library for high-quality downscaling.

import { compressImage, ImageMimeType, ResizeFilter } from "snapblob/image";

const blob = await compressImage(file, {
  maxWidth: 1280,
  maxHeight: 720,
  quality: 0.8,
  mimeType: ImageMimeType.WEBP,
  resizeFilter: ResizeFilter.LANCZOS3,
  adjustOrientation: true,
  skipIfSmaller: true,
  onProgress: (p) => console.log(`${p}%`),
});

Parameters:

Option Type Default Description
maxWidth number Source width Maximum output width in pixels (never upscales)
maxHeight number Source height Maximum output height in pixels (never upscales)
quality number 0.8 Encoding quality, 0 to 1
mimeType ImageMimeType WEBP Output format: WEBP, JPEG, PNG, GIF, BMP, TIFF
resizeFilter ResizeFilter MKS2013 Algorithm: MKS2013 (best quality), LANCZOS3, LANCZOS2, HAMMING, BOX (fastest)
adjustOrientation boolean true Adjust target dimensions to match source orientation
skipIfSmaller boolean false Return the original if compression would increase file size
onProgress (p: number) => void -- Progress callback (0--100)

Input: File | Blob Returns: Promise<Blob> Throws: ImageProcessingError

validateImage(file, options?)

Validates an image against dimension, type, and size constraints. Returns a result object (does not throw).

import { validateImage, ImageMimeType } from "snapblob/image";

const result = await validateImage(file, {
  maxFileSize: 10 * 1024 * 1024, // 10MB in bytes
  minSize: [300, 300],
  maxSize: [4096, 4096],
  allowedTypes: [ImageMimeType.JPEG, ImageMimeType.PNG, ImageMimeType.WEBP],
});

if (!result.valid) {
  console.error(result.errors); // string[]
}
// result.width, result.height -- actual image dimensions
Option Type Description
maxFileSize number Maximum file size in bytes
minSize [width, height] Minimum dimensions in pixels
maxSize [width, height] Maximum dimensions in pixels
allowedTypes ImageMimeType[] Allowed MIME types

Input: File (not Blob -- needs .type for MIME check) Returns: Promise<ImageValidationResult>

interface ImageValidationResult {
  valid: boolean;
  width?: number;
  height?: number;
  errors: string[];
}

validateImageOrThrow(file, options?)

Same as validateImage, but throws ImageValidationError on failure.

import { validateImageOrThrow } from "snapblob/image";

await validateImageOrThrow(file, { maxFileSize: 5 * 1024 * 1024 });
// throws ImageValidationError if invalid

compressImages(files, options?)

Batch-compresses multiple images with built-in concurrency control and per-file progress tracking.

import { compressImages } from "snapblob/image";

const blobs = await compressImages(files, {
  maxWidth: 1280,
  quality: 0.8,
  concurrency: 4,
  onFileProgress: (index, total, pct) => {
    console.log(`File ${index + 1}/${total}: ${pct}%`);
  },
});

Parameters:

Options extend CompressImageOptions with additional batch-specific fields:

Option Type Default Description
concurrency number 3 Maximum number of images to compress in parallel
onFileProgress (index: number, total: number, pct: number) => void -- Progress callback fired per file with file index, total count, and percentage

All other options from compressImage (e.g. maxWidth, quality, mimeType) are applied to every file in the batch.

Input: (File | Blob)[] Returns: Promise<Blob[]> Throws: ImageProcessingError (fails fast on first error)

getBestImageFormat()

Detects the best image format supported by the current browser. Checks support in order of preference: AVIF, WebP, JPEG.

import { getBestImageFormat, supportsWebp, supportsAvif } from "snapblob/image";

const format = getBestImageFormat(); // Returns the best supported ImageMimeType

// Or check individual format support
if (supportsAvif()) {
  console.log("AVIF is supported");
}
if (supportsWebp()) {
  console.log("WebP is supported");
}

Returns: ImageMimeType -- the best format the browser supports

supportsWebp()

Returns true if the browser supports WebP encoding.

Returns: boolean

supportsAvif()

Returns true if the browser supports AVIF encoding.

Returns: boolean


Video API

transcodeVideo(file, options?)

Transcodes a video using FFmpeg WASM. The FFmpeg core (~30MB) is downloaded and cached on the first call.

import { transcodeVideo, applyPreset } from "snapblob/video";

// Using a preset
const blob = await transcodeVideo(file, {
  ...applyPreset("balanced"),
  outputFormat: "mp4",
  onProgress: (p) => console.log(`${p}%`),
});

// Full custom control
const blob = await transcodeVideo(file, {
  codec: "libx264",
  preset: "medium",
  crf: 23,
  maxBitrate: "5M",
  audioBitrate: "128k",
  audioCodec: "aac",
  pixelFormat: "yuv420p",
  outputFormat: "mp4",
  threads: 4,
  onProgress: (p) => updateUI(p),
  signal: abortController.signal,
});

Parameters:

Option Type Default Description
presetName VideoPresetName -- Named preset: "high-quality", "balanced", "small-file", "social-media"
codec string "libx264" Video codec ("libx264", "libx265", "libvpx-vp9", "mpeg4")
preset string -- Encoder speed/quality trade-off: "ultrafast" to "veryslow"
crf number -- Constant Rate Factor. 18 = high quality, 23 = balanced, 28 = small file
maxBitrate string | number -- Max video bitrate ("5M", "2500k", or 2500000)
audioBitrate string | number -- Audio bitrate ("128k", "192k")
audioCodec string -- Audio codec ("aac", "libopus")
pixelFormat string "yuv420p" Pixel format for compatibility
outputFormat string Input ext Output container ("mp4", "webm")
threads number 0 (auto) Number of encoding threads
signal AbortSignal -- Cancel the transcoding operation
onProgress (p: number) => void -- Progress callback (0--100)
ffmpegBaseUrl string unpkg CDN Custom URL for FFmpeg core files
ffmpegMTBaseUrl string unpkg CDN Custom URL for FFmpeg multi-thread core

Input: File | Blob Returns: Promise<Blob> Throws: VideoTranscodeError, VideoAbortError, VideoValidationError

Video Presets

Use applyPreset() to get pre-configured options for common scenarios:

Preset CRF Max Bitrate Speed Use Case
"high-quality" 18 8M slow Archiving, professional
"balanced" 23 5M medium General purpose
"small-file" 28 2M fast File size priority
"social-media" 26 3M fast Social platform sharing
"instagram-feed" 23 3.5M fast Instagram feed posts
"instagram-story" 23 4M fast Instagram/Facebook stories
"tiktok" 23 4M fast TikTok vertical videos
"youtube-1080p" 20 8M medium YouTube 1080p uploads
"youtube-4k" 18 20M slow YouTube 4K uploads
"twitter" 24 5M fast Twitter/X timeline videos
import { applyPreset, VIDEO_PRESETS } from "snapblob/video";

// Spread preset and override individual values
const blob = await transcodeVideo(file, {
  ...applyPreset("high-quality"),
  outputFormat: "webm",
});

// Inspect preset values directly
console.log(VIDEO_PRESETS["balanced"]);
// { codec: "libx264", preset: "medium", crf: 23, maxBitrate: "5M", audioBitrate: "128k", pixelFormat: "yuv420p" }

getVideoInfo(file)

Returns video metadata without transcoding. Uses an HTML <video> element internally.

import { getVideoInfo } from "snapblob/video";

const info = await getVideoInfo(file);
// { duration: 12.5, width: 1920, height: 1080, fileSize: 5242880, mimeType: "video/mp4" }

Cancelling Transcoding

const controller = new AbortController();

const promise = transcodeVideo(file, {
  ...applyPreset("balanced"),
  signal: controller.signal,
});

cancelButton.onclick = () => controller.abort();

try {
  const blob = await promise;
} catch (err) {
  if (err instanceof VideoAbortError) {
    console.log("Cancelled by user");
  }
}

extractAudio(file, options?)

Extracts the audio track from a video file and encodes it in the specified format.

import { extractAudio } from "snapblob/video";

const controller = new AbortController();

const audio = await extractAudio(videoFile, {
  format: "mp3", // "mp3" | "aac" | "opus" | "wav"
  bitrate: "192k",
  signal: controller.signal,
  onProgress: (p) => console.log(`${p}%`),
});

// audio is a Blob -- download it, play it, or upload it

Parameters:

Option Type Default Description
format AudioFormat "mp3" Output format: "mp3", "aac", "opus", "wav"
bitrate string -- Audio bitrate (e.g. "128k", "192k", "320k")
signal AbortSignal -- Cancel the extraction
onProgress (p: number) => void -- Progress callback (0--100)

Input: File | Blob Returns: Promise<Blob> Throws: VideoTranscodeError, VideoAbortError

getVideoThumbnail(file, options?)

Generates a thumbnail image from a specific point in a video using FFmpeg WASM.

import { getVideoThumbnail } from "snapblob/video";

const thumbnail = await getVideoThumbnail(videoFile, {
  time: 5, // capture at 5 seconds
  width: 320, // optional resize
  format: "jpeg", // "jpeg" | "png" | "webp"
  quality: 3, // 1-31 for JPEG (lower = better)
});

// thumbnail is a Blob -- use as an <img> src or upload

Parameters:

Option Type Default Description
time number 0 Timestamp in seconds to capture the frame
width number -- Output width in pixels (height scales proportionally)
format ThumbnailFormat "jpeg" Output format: "jpeg", "png", "webp"
quality number -- Quality level, 1--31 for JPEG (lower = better quality)

Input: File | Blob Returns: Promise<Blob> Throws: VideoTranscodeError


Lifecycle Management

The library lazily initializes FFmpeg WASM and Pica when first needed. You can control this lifecycle explicitly for better performance or memory management.

import { preloadFFmpeg, preloadPica, destroyFFmpeg, destroyPica } from "snapblob";

// Preload during idle time (e.g. after page load)
await preloadFFmpeg(); // Downloads and initializes FFmpeg WASM (~30MB)
await preloadPica(); // Initializes Pica instance

// ... process files ...

// Cleanup when done (e.g. navigating away in an SPA)
destroyFFmpeg(); // Frees ~30MB WASM memory
destroyPica(); // Releases Pica resources

This is especially useful in single-page applications where you want to free memory when the user navigates away from a media processing view.


Framework Integration

React

import { useState, useCallback } from "react";
import { compressImage, ImageMimeType } from "snapblob/image";

function ImageUploader() {
  const [preview, setPreview] = useState<string | null>(null);
  const [progress, setProgress] = useState(0);
  const [uploading, setUploading] = useState(false);

  const handleFile = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    setProgress(0);

    const blob = await compressImage(file, {
      maxWidth: 1280,
      quality: 0.8,
      mimeType: ImageMimeType.WEBP,
      onProgress: setProgress,
    });

    // Preview
    setPreview(URL.createObjectURL(blob));

    // Upload
    const formData = new FormData();
    formData.append("image", blob, "photo.webp");
    await fetch("/api/upload", { method: "POST", body: formData });

    setUploading(false);
  }, []);

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFile} disabled={uploading} />
      {uploading && <progress value={progress} max={100} />}
      {preview && <img src={preview} alt="Preview" />}
    </div>
  );
}

React: Video with Cancel

import { useState, useRef } from "react";
import { transcodeVideo, applyPreset, VideoAbortError } from "snapblob/video";

function VideoTranscoder() {
  const [progress, setProgress] = useState(0);
  const [processing, setProcessing] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setProcessing(true);
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const blob = await transcodeVideo(file, {
        ...applyPreset("balanced"),
        outputFormat: "mp4",
        onProgress: setProgress,
        signal: controller.signal,
      });

      // Trigger download
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = "transcoded.mp4";
      a.click();
    } catch (err) {
      if (!(err instanceof VideoAbortError)) console.error(err);
    } finally {
      setProcessing(false);
    }
  };

  return (
    <div>
      <input type="file" accept="video/*" onChange={handleFile} disabled={processing} />
      {processing && (
        <>
          <progress value={progress} max={100} />
          <button onClick={() => abortRef.current?.abort()}>Cancel</button>
        </>
      )}
    </div>
  );
}

React: Custom Hook

import { useState, useCallback } from "react";
import { compressImage } from "snapblob/image";
import type { CompressImageOptions } from "snapblob/image";

export function useImageCompressor(options?: CompressImageOptions) {
  const [progress, setProgress] = useState(0);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const compress = useCallback(
    async (file: File) => {
      setLoading(true);
      setError(null);
      setProgress(0);
      try {
        const blob = await compressImage(file, { ...options, onProgress: setProgress });
        return blob;
      } catch (err) {
        setError(err instanceof Error ? err : new Error("Compression failed"));
        return null;
      } finally {
        setLoading(false);
      }
    },
    [options]
  );

  return { compress, progress, loading, error };
}

// Usage
function Avatar() {
  const { compress, progress, loading } = useImageCompressor({
    maxWidth: 400,
    maxHeight: 400,
    quality: 0.8,
  });

  const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const blob = await compress(file);
    if (blob) {
      // upload...
    }
  };

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFile} disabled={loading} />
      {loading && <span>{Math.round(progress)}%</span>}
    </div>
  );
}

React: Batch Upload

import { useState } from "react";
import { compressImages } from "snapblob/image";

function BatchUploader() {
  const [fileProgress, setFileProgress] = useState<Record<number, number>>({});
  const [processing, setProcessing] = useState(false);

  const handleFiles = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files ?? []);
    if (files.length === 0) return;

    setProcessing(true);

    const blobs = await compressImages(files, {
      maxWidth: 1280,
      quality: 0.8,
      concurrency: 4,
      onFileProgress: (index, total, pct) => {
        setFileProgress((prev) => ({ ...prev, [index]: pct }));
      },
    });

    // Upload all compressed images
    const formData = new FormData();
    blobs.forEach((blob, i) => formData.append("images", blob, `photo-${i}.webp`));
    await fetch("/api/upload-batch", { method: "POST", body: formData });

    setProcessing(false);
  };

  return (
    <div>
      <input type="file" accept="image/*" multiple onChange={handleFiles} disabled={processing} />
      {processing &&
        Object.entries(fileProgress).map(([i, pct]) => (
          <div key={i}>
            File {Number(i) + 1}: {Math.round(pct)}%
          </div>
        ))}
    </div>
  );
}

Vue 3

<script setup lang="ts">
import { ref } from "vue";
import { compressImage, ImageMimeType } from "snapblob/image";

const preview = ref<string | null>(null);
const progress = ref(0);
const loading = ref(false);

async function handleFile(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (!file) return;

  loading.value = true;
  progress.value = 0;

  try {
    const blob = await compressImage(file, {
      maxWidth: 1280,
      quality: 0.8,
      mimeType: ImageMimeType.WEBP,
      onProgress: (p) => {
        progress.value = p;
      },
    });

    preview.value = URL.createObjectURL(blob);

    // Upload
    const formData = new FormData();
    formData.append("image", blob, "compressed.webp");
    await fetch("/api/upload", { method: "POST", body: formData });
  } catch (err) {
    console.error("Compression failed:", err);
  } finally {
    loading.value = false;
  }
}
</script>

<template>
  <div>
    <input type="file" accept="image/*" @change="handleFile" :disabled="loading" />
    <p v-if="loading">Compressing: {{ Math.round(progress) }}%</p>
    <img v-if="preview" :src="preview" alt="Preview" />
  </div>
</template>

Vue 3: Composable

// composables/useImageCompressor.ts
import { ref } from "vue";
import { compressImage } from "snapblob/image";
import type { CompressImageOptions } from "snapblob/image";

export function useImageCompressor(options?: CompressImageOptions) {
  const progress = ref(0);
  const loading = ref(false);
  const error = ref<Error | null>(null);

  async function compress(file: File): Promise<Blob | null> {
    loading.value = true;
    error.value = null;
    progress.value = 0;
    try {
      return await compressImage(file, {
        ...options,
        onProgress: (p) => {
          progress.value = p;
        },
      });
    } catch (err) {
      error.value = err instanceof Error ? err : new Error("Compression failed");
      return null;
    } finally {
      loading.value = false;
    }
  }

  return { compress, progress, loading, error };
}

Vue 3: Video with Cancel

<script setup lang="ts">
import { ref } from "vue";
import { transcodeVideo, applyPreset, VideoAbortError } from "snapblob/video";

const progress = ref(0);
const processing = ref(false);
let controller: AbortController | null = null;

async function handleFile(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (!file) return;

  processing.value = true;
  controller = new AbortController();

  try {
    const blob = await transcodeVideo(file, {
      ...applyPreset("balanced"),
      outputFormat: "mp4",
      onProgress: (p) => {
        progress.value = p;
      },
      signal: controller.signal,
    });

    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "transcoded.mp4";
    a.click();
  } catch (err) {
    if (!(err instanceof VideoAbortError)) console.error(err);
  } finally {
    processing.value = false;
  }
}
</script>

<template>
  <div>
    <input type="file" accept="video/*" @change="handleFile" :disabled="processing" />
    <div v-if="processing">
      <progress :value="progress" max="100" />
      <button @click="controller?.abort()">Cancel</button>
    </div>
  </div>
</template>

Svelte

<script lang="ts">
  import { compressImage, ImageMimeType } from "snapblob/image";

  let preview: string | null = null;
  let progress = 0;
  let loading = false;

  async function handleFile(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    loading = true;
    progress = 0;

    try {
      const blob = await compressImage(file, {
        maxWidth: 1280,
        quality: 0.8,
        mimeType: ImageMimeType.WEBP,
        onProgress: (p) => { progress = p; },
      });

      preview = URL.createObjectURL(blob);
    } catch (err) {
      console.error(err);
    } finally {
      loading = false;
    }
  }
</script>

<input type="file" accept="image/*" on:change={handleFile} disabled={loading} />
{#if loading}
  <p>Compressing: {Math.round(progress)}%</p>
{/if}
{#if preview}
  <img src={preview} alt="Compressed" />
{/if}

Angular

// image-upload.component.ts
import { Component } from "@angular/core";
import { compressImage, ImageMimeType } from "snapblob/image";

@Component({
  selector: "app-image-upload",
  template: `
    <input type="file" accept="image/*" (change)="handleFile($event)" [disabled]="loading" />
    <div *ngIf="loading">
      <progress [value]="progress" max="100"></progress>
      <span>{{ progress | number: "1.0-0" }}%</span>
    </div>
    <img *ngIf="preview" [src]="preview" alt="Preview" />
  `,
})
export class ImageUploadComponent {
  preview: string | null = null;
  progress = 0;
  loading = false;

  async handleFile(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    this.loading = true;
    this.progress = 0;

    try {
      const blob = await compressImage(file, {
        maxWidth: 1280,
        quality: 0.8,
        mimeType: ImageMimeType.WEBP,
        onProgress: (p) => {
          this.progress = p;
        },
      });

      this.preview = URL.createObjectURL(blob);
    } catch (err) {
      console.error(err);
    } finally {
      this.loading = false;
    }
  }
}

Vanilla JavaScript

<input type="file" id="fileInput" accept="image/*" />
<progress id="bar" value="0" max="100" hidden></progress>
<img id="preview" hidden />

<script type="module">
  import { compressImage } from "snapblob/image";

  document.getElementById("fileInput").addEventListener("change", async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    const bar = document.getElementById("bar");
    bar.hidden = false;

    const blob = await compressImage(file, {
      maxWidth: 1920,
      quality: 0.8,
      onProgress: (p) => {
        bar.value = p;
      },
    });

    const preview = document.getElementById("preview");
    preview.src = URL.createObjectURL(blob);
    preview.hidden = false;
    bar.hidden = true;

    console.log(
      `${file.size} -> ${blob.size} (${Math.round((1 - blob.size / file.size) * 100)}% smaller)`
    );
  });
</script>

Vanilla JavaScript: Video

<input type="file" id="videoInput" accept="video/*" />
<progress id="bar" value="0" max="100" hidden></progress>
<button id="cancelBtn" hidden>Cancel</button>

<script type="module">
  import { transcodeVideo, applyPreset } from "snapblob/video";

  let controller;

  document.getElementById("videoInput").addEventListener("change", async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    controller = new AbortController();
    const bar = document.getElementById("bar");
    const cancelBtn = document.getElementById("cancelBtn");
    bar.hidden = false;
    cancelBtn.hidden = false;

    try {
      const blob = await transcodeVideo(file, {
        ...applyPreset("balanced"),
        outputFormat: "mp4",
        signal: controller.signal,
        onProgress: (p) => {
          bar.value = p;
        },
      });

      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = "video.mp4";
      a.click();
    } catch (err) {
      if (err.name !== "VideoAbortError") console.error(err);
    } finally {
      bar.hidden = true;
      cancelBtn.hidden = true;
    }
  });

  document.getElementById("cancelBtn").addEventListener("click", () => controller?.abort());
</script>

Common Recipes

Validate Then Compress

import { compressImage, validateImage, ImageMimeType } from "snapblob/image";

async function processImage(file: File): Promise<Blob> {
  const { valid, errors } = await validateImage(file, {
    maxFileSize: 20 * 1024 * 1024,
    minSize: [200, 200],
    maxSize: [8000, 8000],
    allowedTypes: [ImageMimeType.JPEG, ImageMimeType.PNG, ImageMimeType.WEBP],
  });

  if (!valid) throw new Error(errors.join(", "));

  return compressImage(file, { maxWidth: 1280, quality: 0.8 });
}

Generate Thumbnail

const thumbnail = await compressImage(file, {
  maxWidth: 200,
  maxHeight: 200,
  quality: 0.6,
  mimeType: ImageMimeType.WEBP,
});

Upload with FormData

const blob = await compressImage(file, { maxWidth: 1920, quality: 0.8 });

const formData = new FormData();
formData.append("avatar", blob, "avatar.webp");
formData.append("userId", "123");

await fetch("/api/upload", { method: "POST", body: formData });

Batch Compress

import { compressImages } from "snapblob/image";

const files = Array.from(fileInput.files);

const blobs = await compressImages(files, {
  maxWidth: 1280,
  quality: 0.8,
  concurrency: 4,
  onFileProgress: (index, total, pct) => {
    console.log(`File ${index + 1}/${total}: ${pct}%`);
  },
});

Convert Video Format

// MP4 to WebM
const webm = await transcodeVideo(mp4File, {
  codec: "libvpx-vp9",
  crf: 30,
  audioCodec: "libopus",
  audioBitrate: "128k",
  outputFormat: "webm",
});

Extract Audio from Video

import { extractAudio } from "snapblob/video";

const mp3 = await extractAudio(videoFile, {
  format: "mp3",
  bitrate: "192k",
  onProgress: (p) => console.log(`${p}%`),
});

// Download the audio file
const a = document.createElement("a");
a.href = URL.createObjectURL(mp3);
a.download = "audio.mp3";
a.click();

Generate Video Thumbnail

import { getVideoThumbnail } from "snapblob/video";

const thumbnail = await getVideoThumbnail(videoFile, {
  time: 5,
  width: 320,
  format: "jpeg",
  quality: 3,
});

const img = document.createElement("img");
img.src = URL.createObjectURL(thumbnail);

Auto-Detect Best Image Format

import { compressImage, getBestImageFormat } from "snapblob/image";

const format = getBestImageFormat(); // AVIF if supported, else WebP, else JPEG
const blob = await compressImage(file, { maxWidth: 1280, mimeType: format });

Self-Hosted FFmpeg

const blob = await transcodeVideo(file, {
  ...applyPreset("balanced"),
  ffmpegBaseUrl: "https://cdn.example.com/ffmpeg",
  ffmpegMTBaseUrl: "https://cdn.example.com/ffmpeg-mt",
});

Tree-Shaking

Import from subpaths to only include what you use:

// Image only (~45KB gzipped) -- no FFmpeg code
import { compressImage, compressImages, getBestImageFormat } from "snapblob/image";

// Video only -- FFmpeg WASM loaded at runtime on first call
import { transcodeVideo, extractAudio, getVideoThumbnail } from "snapblob/video";

// Lifecycle management
import { preloadFFmpeg, destroyFFmpeg, preloadPica, destroyPica } from "snapblob";

// Everything (only if you need both)
import { compressImage, transcodeVideo } from "snapblob";

Error Handling

All errors are typed classes that extend Error with optional cause chaining:

import { ImageProcessingError, ImageValidationError } from "snapblob/image";
import { VideoTranscodeError, VideoAbortError, VideoValidationError } from "snapblob/video";

try {
  const blob = await compressImage(file);
} catch (err) {
  if (err instanceof ImageProcessingError) {
    console.error(err.message, err.cause);
  }
}

try {
  const blob = await transcodeVideo(file, { signal: controller.signal });
} catch (err) {
  if (err instanceof VideoAbortError) {
    // User cancelled -- not a real error
  } else if (err instanceof VideoTranscodeError) {
    console.error(err.message);
  }
}

FFmpeg WASM Setup

Video transcoding requires Cross-Origin Isolation headers for multi-threaded mode. Without them, FFmpeg runs single-threaded (slower but functional).

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Vite

// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
  },
  server: {
    headers: {
      "Cross-Origin-Embedder-Policy": "require-corp",
      "Cross-Origin-Opener-Policy": "same-origin",
    },
  },
});

Next.js

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
        ],
      },
    ];
  },
};

Webpack

// webpack.config.js
module.exports = {
  devServer: {
    headers: {
      "Cross-Origin-Embedder-Policy": "require-corp",
      "Cross-Origin-Opener-Policy": "same-origin",
    },
  },
};

Nginx

add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;

TypeScript

Full type definitions included. Import types from subpaths:

import type {
  CompressImageOptions,
  BatchCompressOptions,
  ValidateImageOptions,
  ImageValidationResult,
  ProgressCallback,
} from "snapblob/image";

import type {
  TranscodeVideoOptions,
  ExtractAudioOptions,
  AudioFormat,
  VideoThumbnailOptions,
  ThumbnailFormat,
  VideoInfo,
  VideoPresetName,
  VideoPreset,
} from "snapblob/video";

Browser Support

Feature Chrome Firefox Safari Edge
Image compression 66+ 65+ 15+ 79+
Video transcoding 79+ 72+ 16.4+ 79+
Multi-thread video* 92+ 79+ 15.2+ 92+

* Requires Cross-Origin Isolation headers.

Bundle Size

Import Path Size (gzipped) Notes
/image ~45 KB Pica library
/video < 1 KB FFmpeg WASM (~30 MB) loaded at runtime on first call
Types / constants only < 1 KB Zero runtime cost

API Reference

Image

Function Signature Description
compressImage (file: File | Blob, options?: CompressImageOptions) => Promise<Blob> Compress and resize a single image
compressImages (files: (File | Blob)[], options?: BatchCompressOptions) => Promise<Blob[]> Batch compress multiple images with concurrency control
validateImage (file: File, options?: ValidateImageOptions) => Promise<ImageValidationResult> Validate image dimensions, type, and size
validateImageOrThrow (file: File, options?: ValidateImageOptions) => Promise<void> Same as validateImage but throws on failure
getBestImageFormat () => ImageMimeType Detect best supported format (AVIF > WebP > JPEG)
supportsWebp () => boolean Check if the browser supports WebP encoding
supportsAvif () => boolean Check if the browser supports AVIF encoding

Video

Function Signature Description
transcodeVideo (file: File | Blob, options?: TranscodeVideoOptions) => Promise<Blob> Transcode a video with full codec control
extractAudio (file: File | Blob, options?: ExtractAudioOptions) => Promise<Blob> Extract audio track from a video file
getVideoThumbnail (file: File | Blob, options?: VideoThumbnailOptions) => Promise<Blob> Generate a thumbnail image from a video
getVideoInfo (file: File | Blob) => Promise<VideoInfo> Get video metadata (duration, dimensions, size)
applyPreset (name: VideoPresetName) => Partial<TranscodeVideoOptions> Get preset options for common scenarios

Lifecycle

Function Signature Description
preloadFFmpeg () => Promise<void> Pre-initialize FFmpeg WASM (~30MB download)
destroyFFmpeg () => void Free FFmpeg WASM memory
preloadPica () => Promise<void> Pre-initialize the Pica instance
destroyPica () => void Release Pica resources

Migration from v0.x

v1.0 replaces the class-based API with standalone functions:

// Before (v0.x)
const handler = new TypedImageHandler({
  processingConfig: { imageSize: [1280, 720], resizeQuality: 0.8 },
  // ...callbacks, upload config
});
await handler.handle(file);

// After (v1.x)
const blob = await compressImage(file, { maxWidth: 1280, quality: 0.8 });

Key changes:

  • No upload logic -- returns a Blob. You handle uploading.
  • No class instantiation -- call compressImage() or transcodeVideo() directly.
  • Simpler options -- flat options object with sensible defaults.
  • Tree-shakeable -- subpath imports mean you only bundle what you use.

The legacy classes (TypedImageHandler, TypedFFMPEGHandler, BaseFileHandler) are still exported from the main entry point for backward compatibility.

License

MIT

Contributing

Contributions are welcome. Please open an issue first to discuss changes.

git clone https://github.com/aazamov/snapblob.git
cd snapblob
npm install
npm test          # run tests
npm run build     # build library
npm run dev       # dev server with playground

About

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages