Browser-native image compression & video transcoding. Zero framework dependency. One function call.
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
Blobyou can upload however you want - Tree-shakeable -- import only
imageorvideoto keep your bundle small - TypeScript-first -- full type definitions with IntelliSense-friendly APIs
- Framework-agnostic -- works with React, Vue, Svelte, Angular, or vanilla JS
npm install snapblobInstall 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// 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 });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
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[];
}Same as validateImage, but throws ImageValidationError on failure.
import { validateImageOrThrow } from "snapblob/image";
await validateImageOrThrow(file, { maxFileSize: 5 * 1024 * 1024 });
// throws ImageValidationError if invalidBatch-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)
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
Returns true if the browser supports WebP encoding.
Returns: boolean
Returns true if the browser supports AVIF encoding.
Returns: boolean
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
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" }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" }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");
}
}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 itParameters:
| 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
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 uploadParameters:
| 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
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 resourcesThis is especially useful in single-page applications where you want to free memory when the user navigates away from a media processing view.
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>
);
}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>
);
}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>
);
}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>
);
}<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>// 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 };
}<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><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}// 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;
}
}
}<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><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>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 });
}const thumbnail = await compressImage(file, {
maxWidth: 200,
maxHeight: 200,
quality: 0.6,
mimeType: ImageMimeType.WEBP,
});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 });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}%`);
},
});// MP4 to WebM
const webm = await transcodeVideo(mp4File, {
codec: "libvpx-vp9",
crf: 30,
audioCodec: "libopus",
audioBitrate: "128k",
outputFormat: "webm",
});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();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);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 });const blob = await transcodeVideo(file, {
...applyPreset("balanced"),
ffmpegBaseUrl: "https://cdn.example.com/ffmpeg",
ffmpegMTBaseUrl: "https://cdn.example.com/ffmpeg-mt",
});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";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);
}
}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.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.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.config.js
module.exports = {
devServer: {
headers: {
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
},
},
};add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;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";| 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.
| 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 |
| 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 |
| 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 |
| 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 |
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()ortranscodeVideo()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.
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