sharp

SKILL.md

Sharp

High-performance Node.js image processing. 4-5x faster than ImageMagick for resizing JPEG, PNG, WebP, GIF, AVIF, and TIFF images. Uses libvips under the hood.

Supported Runtimes: Node.js (^18.17.0 or >= 20.3.0), Deno, Bun

Quick Start

npm install sharp
import sharp from 'sharp';

// Resize and convert
await sharp('input.jpg')
  .resize(800, 600)
  .toFormat('webp')
  .toFile('output.webp');

// From buffer
const buffer = await sharp(inputBuffer)
  .resize(400)
  .toBuffer();

Constructor Options

// With input options
const image = sharp('input.jpg', {
  animated: true,           // Extract all frames from GIF/WebP
  limitInputPixels: 268402689,  // Max input pixels (default ~268M)
  failOn: 'warning',        // When to abort: 'none', 'truncated', 'error', 'warning'
  density: 300,             // DPI for vector input (SVG, PDF)
  pages: -1,                // All pages (-1) or specific count
  page: 0,                  // Starting page
});

// From raw pixel data
const raw = sharp(buffer, {
  raw: {
    width: 800,
    height: 600,
    channels: 4  // RGBA
  }
});

// Create new image from scratch
const blank = sharp({
  create: {
    width: 800,
    height: 600,
    channels: 4,
    background: { r: 255, g: 255, b: 255, alpha: 1 }
  }
});

Resize

// Fixed dimensions (may crop based on fit)
await sharp('input.jpg')
  .resize(800, 600)
  .toFile('output.jpg');

// Fit within dimensions (maintain aspect ratio)
await sharp('input.jpg')
  .resize(800, 600, { fit: 'inside' })
  .toFile('output.jpg');

// Fill dimensions (crop to fit)
await sharp('input.jpg')
  .resize(800, 600, { fit: 'cover' })
  .toFile('output.jpg');

// Width only (auto height)
await sharp('input.jpg')
  .resize({ width: 800 })
  .toFile('output.jpg');

// Without upscaling
await sharp('input.jpg')
  .resize(2000, null, { withoutEnlargement: true })
  .toFile('output.jpg');

// Without downscaling
await sharp('input.jpg')
  .resize(200, null, { withoutReduction: true })
  .toFile('output.jpg');

Fit Options

Option Description
cover Crop to cover dimensions (default)
contain Fit within, add background if needed
fill Stretch to fill (ignores aspect ratio)
inside Fit within, never exceed dimensions
outside Fit to cover, may exceed one dimension

Position (for cover/contain)

await sharp('input.jpg')
  .resize(800, 600, {
    fit: 'cover',
    position: 'top'  // top, right top, right, right bottom, bottom, left bottom, left, left top, center
  })
  .toFile('output.jpg');

// Smart crop strategies
await sharp('input.jpg')
  .resize(800, 600, {
    fit: 'cover',
    position: sharp.strategy.entropy   // Focus on high-detail region
  })
  .toFile('output.jpg');

await sharp('input.jpg')
  .resize(800, 600, {
    fit: 'cover',
    position: sharp.strategy.attention  // Focus on faces/skin tones
  })
  .toFile('output.jpg');

Resize Kernels

await sharp('input.jpg')
  .resize(800, 600, {
    kernel: 'lanczos3'  // lanczos3 (default), lanczos2, nearest, cubic, mitchell, linear
  })
  .toFile('output.jpg');

Format Conversion

// To WebP
await sharp('input.jpg')
  .webp({ quality: 80 })
  .toFile('output.webp');

// To AVIF (best compression)
await sharp('input.jpg')
  .avif({ quality: 60 })
  .toFile('output.avif');

// To PNG
await sharp('input.jpg')
  .png({ compressionLevel: 9 })
  .toFile('output.png');

// To JPEG
await sharp('input.png')
  .jpeg({ quality: 80, mozjpeg: true })
  .toFile('output.jpg');

// To GIF
await sharp('input.jpg')
  .gif()
  .toFile('output.gif');

// To HEIF (requires libheif)
await sharp('input.jpg')
  .heif({ quality: 80, compression: 'av1' })
  .toFile('output.heif');

// Auto format based on extension
await sharp('input.jpg')
  .toFormat('webp', { quality: 80 })
  .toFile('output.webp');

Format Options

// JPEG
.jpeg({
  quality: 80,           // 1-100
  progressive: true,     // Progressive JPEG
  mozjpeg: true,         // MozJPEG encoder (better compression)
  chromaSubsampling: '4:4:4',  // or '4:2:0' (default)
  trellisQuantisation: true,
  overshootDeringing: true,
})

// PNG
.png({
  compressionLevel: 9,   // 0-9
  palette: true,         // Quantize to palette
  quality: 80,           // For palette mode (1-100)
  colors: 256,           // Max colors for palette
  dither: 1.0,           // Floyd-Steinberg dithering
})

// WebP
.webp({
  quality: 80,           // 1-100
  lossless: false,       // Lossless compression
  nearLossless: false,   // Near-lossless mode
  effort: 4,             // 0-6, higher = slower + smaller
  loop: 0,               // Animation loops (0 = infinite)
  delay: 100,            // Frame delay in ms
})

// AVIF
.avif({
  quality: 60,           // 1-100
  effort: 4,             // 0-9, higher = slower + smaller
  lossless: false,
  chromaSubsampling: '4:4:4',
})

// GIF
.gif({
  colors: 256,           // 2-256 palette colors
  effort: 7,             // 1-10
  loop: 0,               // 0 = infinite loop
  delay: 100,            // Frame delay in ms (or array)
  dither: 1.0,           // Floyd-Steinberg dithering
})

// TIFF
.tiff({
  quality: 80,
  compression: 'lzw',    // lzw, deflate, jpeg, ccittfax4, etc.
  bitdepth: 8,           // 1, 2, 4, 8
  tile: true,            // Tiled TIFF
  tileWidth: 256,
  tileHeight: 256,
})

Animated Images (GIF/WebP)

// Read animated GIF/WebP
const image = sharp('animated.gif', { animated: true });

// Get frame count
const metadata = await image.metadata();
console.log(`Frames: ${metadata.pages}, Delay: ${metadata.delay}`);

// Resize animated image (preserves animation)
await sharp('animated.gif', { animated: true })
  .resize(400)
  .gif()
  .toFile('resized.gif');

// Convert animated GIF to WebP
await sharp('animated.gif', { animated: true })
  .webp({ loop: 0 })  // 0 = infinite loop
  .toFile('animated.webp');

// Extract single frame
await sharp('animated.gif', { pages: 1, page: 5 })  // Frame 5
  .toFile('frame5.png');

Metadata

// Get image info
const metadata = await sharp('input.jpg').metadata();
console.log(metadata);
// { width, height, format, space, channels, depth, density,
//   hasAlpha, pages, loop, delay, isProgressive, ... }

// Get stats (pixel analysis)
const stats = await sharp('input.jpg').stats();
console.log(stats);
// { channels: [{ min, max, sum, squaresSum, mean, stdev, ... }] }

// Keep EXIF/XMP/IPTC metadata in output
await sharp('input.jpg')
  .keepMetadata()
  .resize(800)
  .toFile('output.jpg');

// Selectively keep metadata
await sharp('input.jpg')
  .withMetadata({
    orientation: 1,      // Override orientation
    density: 300,        // Set DPI
    exif: { IFD0: { Copyright: 'My Company' } }
  })
  .toFile('output.jpg');

Operations

Crop/Extract

// Extract region
await sharp('input.jpg')
  .extract({ left: 100, top: 100, width: 300, height: 200 })
  .toFile('output.jpg');

// Trim whitespace/borders
await sharp('input.jpg')
  .trim()  // Auto-detect border color
  .toFile('output.jpg');

await sharp('input.jpg')
  .trim({ threshold: 10 })  // Tolerance for border detection
  .toFile('output.jpg');

Rotate & Flip

// Rotate (auto from EXIF by default)
await sharp('input.jpg')
  .rotate(90)  // Degrees clockwise
  .toFile('output.jpg');

// Rotate with background
await sharp('input.jpg')
  .rotate(45, { background: { r: 255, g: 255, b: 255 } })
  .toFile('output.jpg');

// Flip
await sharp('input.jpg')
  .flip()   // Vertical
  .flop()   // Horizontal
  .toFile('output.jpg');

// Auto-orient from EXIF (default behavior)
await sharp('input.jpg')
  .rotate()  // No angle = use EXIF orientation
  .toFile('output.jpg');

Color Adjustments

await sharp('input.jpg')
  .grayscale()
  .toFile('output.jpg');

await sharp('input.jpg')
  .tint({ r: 255, g: 200, b: 200 })
  .toFile('output.jpg');

await sharp('input.jpg')
  .modulate({
    brightness: 1.2,  // 1 = no change
    saturation: 0.8,  // 0 = grayscale, 1 = no change
    hue: 180,         // Degrees rotation
    lightness: 10,    // Add/subtract lightness
  })
  .toFile('output.jpg');

await sharp('input.jpg')
  .negate()  // Invert colors
  .toFile('output.jpg');

await sharp('input.jpg')
  .negate({ alpha: false })  // Don't negate alpha
  .toFile('output.jpg');

Effects

// Blur
await sharp('input.jpg')
  .blur(5)  // Sigma value, 0.3-1000
  .toFile('output.jpg');

// Sharpen
await sharp('input.jpg')
  .sharpen()  // Default
  .toFile('output.jpg');

await sharp('input.jpg')
  .sharpen({
    sigma: 1,    // Gaussian mask size
    m1: 1,       // Flat areas
    m2: 3,       // Jagged areas
    x1: 2,       // Threshold for flat
    y2: 10,      // Max for m1
    y3: 20,      // Max for m2
  })
  .toFile('output.jpg');

// Normalize (stretch contrast)
await sharp('input.jpg')
  .normalize()
  .toFile('output.jpg');

// Median filter (noise reduction)
await sharp('input.jpg')
  .median(3)  // Window size
  .toFile('output.jpg');

// Gamma correction
await sharp('input.jpg')
  .gamma(2.2)  // Apply gamma
  .gamma(2.2, 1.8)  // Different for RGB and alpha
  .toFile('output.jpg');

// Recomb (color matrix transformation)
await sharp('input.jpg')
  .recomb([
    [0.3588, 0.7044, 0.1368],  // Sepia effect
    [0.2990, 0.5870, 0.1140],
    [0.2392, 0.4696, 0.0912],
  ])
  .toFile('output.jpg');

Composite (Overlays/Watermarks)

// Add watermark
await sharp('input.jpg')
  .composite([
    {
      input: 'watermark.png',
      gravity: 'southeast',
      blend: 'over',
    }
  ])
  .toFile('output.jpg');

// Multiple overlays
await sharp('base.jpg')
  .composite([
    { input: 'layer1.png', top: 0, left: 0 },
    { input: 'layer2.png', top: 100, left: 100, blend: 'multiply' },
    {
      input: Buffer.from('<svg>...</svg>'),
      top: 50,
      left: 50,
    }
  ])
  .toFile('output.jpg');

// Text overlay via SVG
const textSvg = `
  <svg width="400" height="50">
    <text x="0" y="35" font-size="30" fill="white">© My Company</text>
  </svg>
`;

await sharp('input.jpg')
  .composite([
    {
      input: Buffer.from(textSvg),
      gravity: 'south',
    }
  ])
  .toFile('output.jpg');

// Blend modes
// over, clear, source, in, out, atop, dest, dest-over, dest-in,
// dest-out, dest-atop, xor, add, saturate, multiply, screen,
// overlay, darken, lighten, colour-dodge, color-dodge,
// colour-burn, color-burn, hard-light, soft-light, difference,
// exclusion

Add Background/Extend

// Add padding with background color
await sharp('input.png')
  .extend({
    top: 20,
    bottom: 20,
    left: 20,
    right: 20,
    background: { r: 255, g: 255, b: 255, alpha: 1 }
  })
  .toFile('output.png');

// Extend with strategy
await sharp('input.png')
  .extend({
    top: 50,
    background: { r: 0, g: 0, b: 0 },
    extendWith: 'mirror'  // copy, repeat, mirror, background
  })
  .toFile('output.png');

// Flatten transparency to solid background
await sharp('input.png')
  .flatten({ background: '#ffffff' })
  .toFile('output.jpg');

Pipeline Chaining

// All operations chain together
await sharp('input.jpg')
  .resize(800, 600, { fit: 'cover' })
  .rotate(90)
  .sharpen()
  .modulate({ brightness: 1.1 })
  .webp({ quality: 80 })
  .toFile('output.webp');

Clone for Parallel Processing

// Clone to create multiple outputs from single input
const pipeline = sharp('input.jpg');

const [thumb, medium, large] = await Promise.all([
  pipeline.clone().resize(150, 150).toBuffer(),
  pipeline.clone().resize(400).toBuffer(),
  pipeline.clone().resize(1200).toBuffer(),
]);

Streams & Buffers

import fs from 'fs';

// Stream input/output
const readStream = fs.createReadStream('input.jpg');
const writeStream = fs.createWriteStream('output.webp');

readStream
  .pipe(sharp().resize(800).webp())
  .pipe(writeStream);

// Buffer to buffer
const inputBuffer = fs.readFileSync('input.jpg');
const outputBuffer = await sharp(inputBuffer)
  .resize(400)
  .toBuffer();

// With info
const { data, info } = await sharp(inputBuffer)
  .resize(400)
  .toBuffer({ resolveWithObject: true });

console.log(info);
// { format, width, height, channels, size }

Next.js / API Routes

// app/api/image/route.ts
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');
  const width = parseInt(searchParams.get('w') || '800');
  const quality = parseInt(searchParams.get('q') || '80');

  // Fetch original image
  const response = await fetch(url!);
  const buffer = Buffer.from(await response.arrayBuffer());

  // Process
  const processed = await sharp(buffer)
    .resize(width)
    .webp({ quality })
    .toBuffer();

  return new NextResponse(processed, {
    headers: {
      'Content-Type': 'image/webp',
      'Cache-Control': 'public, max-age=31536000',
    },
  });
}

Generate Thumbnails

import sharp from 'sharp';
import path from 'path';

async function generateThumbnails(inputPath, outputDir) {
  const sizes = [
    { name: 'thumb', width: 150, height: 150 },
    { name: 'small', width: 400 },
    { name: 'medium', width: 800 },
    { name: 'large', width: 1200 },
  ];

  const basename = path.basename(inputPath, path.extname(inputPath));
  const pipeline = sharp(inputPath);

  const results = await Promise.all(
    sizes.map(async ({ name, width, height }) => {
      const outputPath = path.join(outputDir, `${basename}-${name}.webp`);

      const info = await pipeline
        .clone()
        .resize(width, height, {
          fit: height ? 'cover' : 'inside',
          withoutEnlargement: true
        })
        .webp({ quality: 80 })
        .toFile(outputPath);

      return { name, path: outputPath, ...info };
    })
  );

  return results;
}

Handle Uploads

import formidable from 'formidable';
import sharp from 'sharp';

async function handleUpload(req) {
  const form = formidable();
  const [fields, files] = await form.parse(req);
  const file = files.image[0];

  // Validate and process
  const metadata = await sharp(file.filepath).metadata();

  if (!['jpeg', 'png', 'webp', 'gif'].includes(metadata.format)) {
    throw new Error('Invalid format');
  }

  // Process and save
  const filename = `${Date.now()}-${file.originalFilename}`;
  const processed = await sharp(file.filepath)
    .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
    .keepMetadata()
    .webp({ quality: 80 })
    .toFile(`./uploads/${filename}.webp`);

  return {
    url: `/uploads/${filename}.webp`,
    width: processed.width,
    height: processed.height,
    size: processed.size,
  };
}

Batch Processing

import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';

async function batchOptimize(inputGlob, outputDir, options = {}) {
  const files = await glob(inputGlob);
  const { maxWidth = 1920, quality = 80, format = 'webp' } = options;

  const results = await Promise.all(
    files.map(async (file) => {
      const basename = path.basename(file, path.extname(file));
      const output = path.join(outputDir, `${basename}.${format}`);

      try {
        const info = await sharp(file)
          .resize(maxWidth, null, {
            fit: 'inside',
            withoutEnlargement: true
          })
          .toFormat(format, { quality })
          .toFile(output);

        const originalSize = (await sharp(file).metadata()).size;
        const savings = originalSize ?
          Math.round((1 - info.size / originalSize) * 100) : 0;

        return {
          input: file,
          output,
          width: info.width,
          height: info.height,
          size: info.size,
          savings: `${savings}%`,
          status: 'success'
        };
      } catch (error) {
        return { input: file, status: 'error', error: error.message };
      }
    })
  );

  return results;
}

// Usage
const results = await batchOptimize('./photos/*.jpg', './optimized', {
  maxWidth: 1600,
  quality: 75,
  format: 'webp'
});

Common Errors

Error Cause Solution
Input file is missing File path doesn't exist Verify file path is correct
unsupported image format Unrecognized input format Check input is valid image
memory allocation failed Image too large Use limitInputPixels or streams
sharp: Installation failed Native dependency issue Run npm rebuild sharp
Input image exceeds pixel limit Exceeds 268M pixels Set higher limitInputPixels
VipsJpeg: Corrupt JPEG data Damaged JPEG file Use failOn: 'none' to try anyway

Best Practices

  1. Use streams for large files to reduce memory
  2. Set concurrency with sharp.concurrency(1) for low-memory environments
  3. Pre-compute sizes when possible (eager thumbnails)
  4. Use WebP or AVIF for best compression
  5. Cache processed images - don't reprocess on every request
  6. Handle EXIF rotation - Sharp auto-rotates by default
  7. Clone pipelines for multiple outputs from single input
  8. Use withoutEnlargement to prevent upscaling artifacts
  9. Validate uploads - check format before processing
  10. Keep metadata when needed with keepMetadata()
Weekly Installs
1
First Seen
7 days ago
Installed on
zencoder1
amp1
cline1
openclaw1
opencode1
cursor1