exposure-watermark
v0.3.0
Published
Watermark images with exposure settings from EXIF metadata using Effect
Downloads
23
Maintainers
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-watermarkQuick 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 ratiosHow 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
outputWidthandoutputHeightare provided, custom dimensions are used as-is - When only
outputWidthis provided, height is derived to maintain a 3:4 portrait ratio - When only
outputHeightis 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) // trueUsing 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
