Lens
v0.1.3Graceful AVIF fallback built in

Images, optimized. Stored anywhere.

Resize, compress and convert, then hand the result to whatever storage you already use. A core with no dependencies, and everything heavy behind an interface you control.

Get started
npm install @lens-image/core sharp

Fig. 01 · Measured, not mocked

The 11 images flying past were optimized by Lens, 7.2 MB down to 660 KB, 91% smaller.

PIPELINE STEPS

Everything that happens
to an upload, by name.

FLOW

Wire it up as a pipeline.

Every image goes through the same stages: read, sniff, validate, plan, encode, store. Swap the engine or the adapter and nothing else moves.

  1. Source: a file path, Buffer, URL or stream becomes bytes.
  2. Validate: the format is sniffed from the bytes, and size and pixel limits are checked before any decoder runs.
  3. Encode: the engine, sharp by default, resizes and encodes each variant, with metadata stripped and no upscaling.
  4. Formats: each requested format, such as AVIF, WebP and JPEG, is produced independently.
  5. Adapter: every variant is stored through your storage adapter, for example S3.
RESULTS

A lighter web,
by default.

91%

Fewer bytes

Measured across the 11 photographs in the hero: 7.2 MB down to 660 KB.

0

Runtime dependencies

In @lens-image/core. Installing it adds one package to your lockfile, and CI enforces it.

3

Storage adapters

S3, filesystem and Cloudinary ship today. Or forty lines of your own.

The 11 hero photographs, total size

Optimized by Lens with LocalAdapter

Original7.2 MB
After Lens660 KB
QUICK START

From install to srcset
in four steps.

Read the walkthrough
  1. Step 1: Install

    The core is one package with nothing underneath it. The codec is yours to choose.

    In your lockfile

    • One package: @lens-image/core has zero dependencies
    • sharp is an optional peer, installed by you and visible there

    To start

    • Node 18.19 or newer
    • No adapter needed: without one, Lens hands the bytes back
    npm install @lens-image/core sharp
    optimizer.tsts
    import { ImageOptimizer } from '@lens-image/core';
    
    // No adapter yet? Fine. Lens runs in process-only mode.
    const optimizer = new ImageOptimizer();
  2. Step 2: Pick an adapter

    Storage is an interface with one required method. Choose where the files land.

    Ships today

    • S3, and R2, MinIO, Spaces or B2 through an endpoint
    • Filesystem, with atomic writes
    • Cloudinary, signed over plain fetch

    Or your own

    • One required method: upload()
    • Retries, concurrency limits and error wrapping are handled for you
    npm install @lens-image/adapter-s3 @aws-sdk/client-s3
    optimizer.tsts
    import { S3Adapter } from '@lens-image/adapter-s3';
    
    const optimizer = new ImageOptimizer({
      adapter: new S3Adapter({ bucket: 'my-images', region: 'us-east-1' }),
    });
  3. Step 3: Optimize

    One call produces every format at every size, uploads them, and returns the URLs.

    Per call

    • Every format at every size, planned up front
    • Formats fail independently: a failure is a warning

    By default

    • Metadata stripped, EXIF orientation applied
    • Never upscales, never trusts a file extension
    upload.tsts
    const result = await optimizer.optimize({
      source: './photo.jpg',
      quality: 80,
      formats: ['webp', 'jpg'],
      sizes: [{ width: 1200 }, { width: 600 }, { width: 300 }],
    });
  4. Step 4: Ship the srcset

    The result comes back grouped by format, ready to drop into markup.

    In the result

    • A srcset per format, sorted ascending
    • result.savings and result.warnings alongside

    In your markup

    • toPicture() returns picture sources and an img fallback
    • Keys are content-addressed, so immutable caching is safe
    page.tsxts
    result.formats.webp.srcset;
    // 'https://…-300w.webp 300w, https://…-600w.webp 600w, https://…-1200w.webp 1200w'
    
    import { toPicture } from '@lens-image/core';
    
    const pic = toPicture(result, {
      alt: 'Cabin at dusk',
      sizes: '(max-width: 768px) 100vw, 50vw',
    });
STORAGE

One interface,
one required method.

Processing and storage are separate concerns, so they are separate packages. Pick a backend, or write one. The call above it never changes.

RESILIENCE

A missing codec
is not a 500.

Prebuilt sharp binaries do not all ship AV1. Where AVIF is unavailable it is skipped and recorded as a warning, and the WebP and JPEG your page needs still resolve.

Read the pipeline
degrade.tsts
const result = await optimizer.optimize({
  source,
  formats: ['avif', 'webp', 'jpg'],
});

// AVIF unavailable on this platform? You still get WebP and JPEG.
result.warnings;
// [{ code: 'format_unsupported', format: 'avif', message: '…' }]

result.formats

  • avifskipped
  • webpready
  • jpgready

result.warnings

format_unsupported · avif

Only a total failure throws, and then it is a LensError with a stable code and a suggested HTTP status.

FAQ

Good questions,
answered by humans.

The straight versions, checked against the source.

Does the core really have zero dependencies?

Yes, in the literal sense: @lens-image/core has no entries in dependencies, so installing it adds one package to your lockfile and downloads no native binaries. A CI check enforces it.

The qualifier is that pixel work needs sharp, which Lens declares as an optional peer dependency and you install yourself. The adapters depend on the core and nothing else; the S3 adapter takes the AWS SDK as a peer.

Do I need sharp?

To resize and encode, yes, by default: it is imported lazily the first time you encode something. If you only validate, hash and store originals, the built-in passthrough engine needs no codec at all.

The codec is an interface too (ImageEngine, three methods), so you can swap sharp out. If it is missing when needed, you get a clear ENGINE_UNAVAILABLE error naming the install command.

Which storage backends are supported?

Three packages ship today: S3 (plus R2, MinIO, DigitalOcean Spaces and Backblaze B2 through an endpoint), the local filesystem, and Cloudinary.

Anything else is an adapter with one required method, upload(). You can also leave the adapter out entirely and get the encoded bytes back to handle yourself. Storage adapters

Does it run in the browser or at the edge?

Part of it does. @lens-image/core/browser is a subset with no node: imports (format sniffing, validation, key templating) that bundles for a browser, a Worker or an edge runtime, so you can reject a bad file from its first 4 KB before the upload starts.

Encoding is a different matter: the default engine is sharp, a native module, so it runs where sharp does. The upload handler is a plain Request to Response function, so it is not tied to one framework.

What happens if AVIF is unavailable?

Prebuilt sharp binaries do not all ship AV1. When AVIF cannot be encoded, that format is skipped and recorded in result.warnings as format_unsupported; the other formats you asked for still come back.

If every requested format fails, Lens retries in fallbackFormat (JPEG by default), and only a total failure throws. Treat AVIF as best-effort.

Is it safe with untrusted uploads?

The defaults are built for it: format is sniffed from the bytes rather than trusted from the extension, a 100-megapixel guard (checked from the header, before any decoder runs) stops decompression bombs, and uploads are capped at 25 MB. Metadata such as EXIF is stripped, remote URL fetching is off, and the filesystem adapter refuses keys that escape its root.

Authentication is still yours: the upload handler takes an authorize hook for it. And the library is pre-1.0, so pin an exact version.

Stop rewriting
the same upload pipeline.