npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

exposure-watermark

v0.3.0

Published

Watermark images with exposure settings from EXIF metadata using Effect

Downloads

23

Readme

exposure-watermark

A functional TypeScript library for watermarking images with EXIF exposure metadata. Built with Effect for type-safe, composable error handling.

Features

  • 📷 Automatic EXIF extraction - Reads aperture, shutter speed, ISO, focal length, camera, and lens info
  • 🎨 Customizable watermarks - Position, colors, fonts, and sizing
  • 📐 Responsive sizing - Automatically scales watermark based on output dimensions
  • 🖼️ 3:4 matte output - Preserves image ratio inside a white 3:4 frame
  • 🏔️ Panorama support - Slice wide images into gallery-ready segments
  • 🔧 Type-safe errors - All errors are typed and composable with Effect
  • 🧪 Fully tested - Comprehensive test suite with real image fixtures

Installation

bun add exposure-watermark

Quick Start

import { watermarkFile } from "exposure-watermark"
import { Effect } from "effect"

// Watermark an image file using its EXIF data
const program = watermarkFile("photo.jpg", "photo-watermarked.jpg", {
  position: "bottom-right",
})

await Effect.runPromise(program)

API

watermarkFile

Watermark an image file using its embedded EXIF metadata.

import { watermarkFile } from "exposure-watermark"
import { Effect } from "effect"

const program = watermarkFile("input.jpg", "output.jpg", {
  position: "bottom-right",     // Position of watermark
  textColor: "#111111",          // Text color
  backgroundColor: "white",      // Background color
  includeCamera: true,          // Include camera make/model
  includeLens: true,            // Include lens info
})

await Effect.runPromise(program)

watermarkBuffer

Watermark an image buffer using its embedded EXIF metadata.

import { watermarkBuffer } from "exposure-watermark"
import { Effect } from "effect"
import * as fs from "node:fs/promises"

const imageBuffer = await fs.readFile("photo.jpg")
const program = watermarkBuffer(imageBuffer, {
  position: "top-left",
})

const result = await Effect.runPromise(program)
await fs.writeFile("output.jpg", result)

watermarkFileWithExposure

Watermark an image with custom exposure settings (bypasses EXIF reading).

import { watermarkFileWithExposure, Exposure } from "exposure-watermark"
import { Effect } from "effect"

const exposure = Exposure.make({
  aperture: 2.8,
  shutterSpeed: "1/250",
  iso: 100,
  focalLength: 50,
  cameraMake: "Canon",
  cameraModel: "EOS R5",
})

const program = watermarkFileWithExposure("input.jpg", "output.jpg", exposure)
await Effect.runPromise(program)

extractExposure

Extract exposure settings from an image file without watermarking.

import { extractExposure, formatExposure } from "exposure-watermark"
import { Effect } from "effect"

const program = Effect.gen(function* () {
  const exposure = yield* extractExposure("photo.jpg")
  console.log(formatExposure(exposure))
  // Output: "f/2.8 | 1/250s | ISO 100 | 50mm"
})

await Effect.runPromise(program)

panoramaFile

Process a panorama image and generate multiple gallery-ready outputs.

For wide panoramic images, this function slices the image into multiple segments that can be displayed as a swipeable gallery (e.g., on Instagram), plus a full matted version.

import { panoramaFile } from "exposure-watermark"
import { Effect } from "effect"

const program = panoramaFile("panorama.jpg", "output.jpg", {
  position: "bottom-right",
})

const result = await Effect.runPromise(program)
console.log(result.outputPaths) 
// ["output-1.jpg", "output-2.jpg", "output-3.jpg", "output-4.jpg"]
console.log(result.sliceMode) // "trisect" for 2:1+ aspect ratios

How it works:

  • For images with aspect ratio ≥ 2:1 (very wide): Generates 4 images — 3 trisected slices + 1 full matted image
  • For images with aspect ratio < 2:1: Generates 3 images — 2 bisected slices + 1 full matted image

Each slice is precisely calculated so that when displayed side-by-side in a gallery, they form a seamless panorama. Each slice includes the watermark.

panoramaFileWithExposure

Process a panorama with custom exposure settings.

import { panoramaFileWithExposure, Exposure } from "exposure-watermark"
import { Effect } from "effect"

const exposure = Exposure.make({
  aperture: 8,
  shutterSpeed: "1/125",
  iso: 200,
  focalLength: 16,
})

const program = panoramaFileWithExposure("panorama.jpg", "output.jpg", exposure)
await Effect.runPromise(program)

Buffer-based Panorama Processing

For in-memory processing, use panoramaBuffer or panoramaBufferWithExposure:

import { panoramaBuffer } from "exposure-watermark"
import { Effect } from "effect"
import * as fs from "node:fs/promises"

const buffer = await fs.readFile("panorama.jpg")
const program = panoramaBuffer(buffer, { position: "bottom-right" })

const { slices, full, sliceMode } = await Effect.runPromise(program)
// slices: Buffer[] - the individual slice images
// full: Buffer - the full matted panorama
// sliceMode: "trisect" | "bisect"

Configuration Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | position | WatermarkPosition | "bottom-right" | Watermark position | | fontSize | number | Auto-calculated | Font size in pixels | | textColor | string | "#111111" | Text color (CSS color) | | backgroundColor | string | "white" | Background color (CSS color) | | padding | number | Auto-calculated | Padding around text | | margin | number | Auto-calculated | Margin from image edge | | includeCamera | boolean | true | Include camera make/model | | includeLens | boolean | true | Include lens info | | outputWidth | number | 3000 | Output width in pixels | | outputHeight | number | 4000 | Output height in pixels |

Note on output dimensions:

  • When both outputWidth and outputHeight are provided, custom dimensions are used as-is
  • When only outputWidth is provided, height is derived to maintain a 3:4 portrait ratio
  • When only outputHeight is provided, width is derived to maintain a 3:4 portrait ratio
  • When neither is provided, defaults to 3000×4000 (3:4 portrait)

Positions

  • "top-left", "top-right", "top-center"
  • "bottom-left", "bottom-right", "bottom-center"

Error Handling

All functions return Effect values with typed errors. You can handle errors explicitly:

import { watermarkFile, FileReadError, ExifReadError } from "exposure-watermark"
import { Effect, Match } from "effect"

const program = watermarkFile("input.jpg", "output.jpg").pipe(
  Effect.catchAll((error) =>
    Match.value(error).pipe(
      Match.tag("FileReadError", (e) => 
        Effect.logError(`Could not read file: ${e.path}`)
      ),
      Match.tag("ExifReadError", (e) => 
        Effect.logError(`Could not read EXIF from: ${e.path}`)
      ),
      Match.tag("ImageProcessingError", (e) => 
        Effect.logError(`Processing failed: ${e.operation}`)
      ),
      Match.tag("FileWriteError", (e) => 
        Effect.logError(`Could not write file: ${e.path}`)
      ),
      Match.exhaustive
    )
  )
)

Error Types

| Error | Description | |-------|-------------| | FileReadError | Could not read the input file | | FileWriteError | Could not write the output file | | ExifReadError | Could not parse EXIF metadata | | MissingExposureDataError | Required exposure fields are missing | | ImageProcessingError | Sharp/image manipulation failed |

Advanced Usage

Using the Exposure Module

import { Exposure } from "exposure-watermark"
import { Option } from "effect"

// Create exposure settings manually
const exposure = Exposure.make({
  aperture: 2.8,
  shutterSpeed: "1/250",
  iso: 100,
  focalLength: 50,
})

// Format individual values
Exposure.formatAperture(2.8)     // "f/2.8"
Exposure.formatShutterSpeed("1/250") // "1/250s"
Exposure.formatIso(100)          // "ISO 100"
Exposure.formatFocalLength(50)   // "50mm"

// Format all settings
Exposure.format(exposure)        // "f/2.8 | 1/250s | ISO 100 | 50mm"

// Check if exposure data exists
Exposure.hasAnyExposureData(exposure) // true

Using the Exif Module

import { Exif } from "exposure-watermark"
import { Effect } from "effect"

// Extract from file
const fromFile = Exif.extractFromFile("photo.jpg")

// Extract from buffer
const fromBuffer = Exif.extractFromBuffer(arrayBuffer)

Using the Panorama Module

import { Panorama } from "exposure-watermark"

// Calculate aspect ratio
const ratio = Panorama.calculateAspectRatio(6000, 2000) // 3.0

// Determine slice mode
const mode = Panorama.determineSliceMode(ratio) // "trisect"

Requirements

  • Bun >= 1.0.0
  • Images with EXIF metadata (JPEG, TIFF)

License

MIT