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

@paramission-lab/phantom

v1.1.0

Published

Blazing-fast RGBA image processing for massive resolutions — tiles, WebGPU, WASM, and AI mask removal, zero full-frame allocations.

Readme

phantom

CI License: Apache 2.0

Maintained by Paramission Lab.

Phantom is a TypeScript-first RGBA image-processing SDK for large browser and Node.js workloads. It keeps memory bounded with overlap-aware tiles, provides a deterministic CPU baseline, and exposes optional browser workers, WebGPU, WASM, and AI background-removal paths.

Table of Contents

When to Use Phantom

Use Phantom when you need:

  • A strict TypeScript SDK for raw RGBA image workflows.
  • Tile-first processing for large images where full-frame operations are too expensive.
  • Safe convolution filters that preserve tile edges with explicit overlap.
  • A simple public facade for common editing tasks.
  • Lower-level TileSource and TileSink contracts for custom decoders, encoders, storage, or streaming integrations.
  • Optional browser acceleration through workers, WebGPU, or WASM (compiled from Zig).
  • Optional AI background removal that stays outside the core import path.

Start with phantom.edit(image) for product features. Drop down to processRawImage(), processTileSource(), workers, GPU, or WASM only when you need more control over memory, execution, or integration boundaries.

Installation

Install from npm:

npm install @paramission-lab/phantom

The unscoped phantom package name is already used on npm, so the public package is scoped under Paramission Lab while the SDK brand remains Phantom.

Install directly from GitHub when you need a specific tag or commit:

npm install git+https://github.com/ParamissionLab/phantom.git#<release-tag>

For a private organization repository configured with SSH access:

npm install git+ssh://[email protected]/ParamissionLab/phantom.git#<release-tag>

Replace <release-tag> with a published Git tag from the repository releases. Pin a release tag or full commit SHA instead of main so installs remain reproducible. Git installs run the package prepare script and compile the TypeScript build. The WASM binary is not built automatically for Git installs; build it explicitly when you need that backend.

Runtime Requirements

| Area | Requirement | | --------------------- | ------------------------------------------------------------------------------- | | Package format | ESM | | Node.js | >=22 for the supported development and CI environment | | TypeScript target | ES2022 | | TypeScript (dev) | 7.x — the toolchain moved to oxlint so nothing pins it below 7 | | Linter | oxlint (--type-aware), replacing ESLint and typescript-eslint | | Core image processing | Works without DOM APIs | | Browser encoding | Requires Canvas, OffscreenCanvas, or document canvas APIs | | Browser workers | Requires module workers | | Shared tile memory | Requires SharedArrayBuffer; cross-origin isolation is required in browsers | | WebGPU | Requires a browser/runtime with navigator.gpu | | AI background removal | Requires browser image APIs and @huggingface/transformers optional dependency | | WASM build (Zig) | Requires Zig 0.16.0; the kernel targets WASM SIMD (simd128) |

The core import does not initialize WebGPU, workers, WASM, or AI inference.

Quick Start

Use the default facade for everyday editing:

import phantom, { type RawRgbaImage } from "@paramission-lab/phantom";

const input: RawRgbaImage = {
  width: 2,
  height: 1,
  data: Uint8Array.from([10, 20, 30, 255, 200, 210, 220, 255]),
};

const output = await phantom
  .edit(input)
  .resize(512, 256)
  .filter("smoothEnhance")
  .run();

const plan = await phantom.edit(output).plan({ goal: "delivery" });
console.log(plan.encode.format, plan.tileSize);

Use named imports when direct functions are clearer:

import {
  applyFilter,
  createRawRgbaImage,
  resizeImage,
} from "@paramission-lab/phantom";

const image = createRawRgbaImage(
  { width: 800, height: 600 },
  { r: 255, g: 255, b: 255 },
);

const preview = resizeImage(image, 320, 240);
const enhanced = await applyFilter(preview, "unsharpMask");

Use Phantom with a browser canvas:

import phantom from "@paramission-lab/phantom";

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const output = await phantom.applyFilter(
  {
    width: imageData.width,
    height: imageData.height,
    data: new Uint8Array(imageData.data),
  },
  "sharpen3x3",
);

ctx.putImageData(
  new ImageData(
    new Uint8ClampedArray(output.data),
    output.width,
    output.height,
  ),
  0,
  0,
);

Core Concepts

RawRgbaImage

Most core APIs use this shape:

interface RawRgbaImage {
  readonly width: number;
  readonly height: number;
  readonly data: Uint8Array;
}

data must contain exactly width * height * 4 bytes in RGBA order. Phantom validates dimensions and buffer lengths and throws PhantomError for SDK validation failures.

Tiles and Overlap

Phantom processes large images as rectangular tiles. Convolution filters need neighboring pixels, so each tile can read a larger input rectangle and write only its non-overlapped output rectangle. This is how Phantom avoids tile-edge artifacts.

High-level helpers such as applyFilter() and applyFilters() choose safe overlap values automatically. Lower-level processing APIs expose tileSize and overlap when you need exact control.

CPU Baseline

The TypeScript CPU kernels are the correctness baseline. Worker, WebGPU, and WASM paths must match the CPU behavior for the same filter and tile region.

For the WASM kernel this is enforced, not assumed: test/kernel-parity.test.ts compares CPU and WASM output byte-for-byte across every filter and several tile geometries — interior tiles, corner tiles with clamped edges, full-width tiles, and a single-pixel output — and the suite runs as part of npm run ci.

Package Entry Points

| Import | Purpose | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | @paramission-lab/phantom | Core facade, raw RGBA utilities, filters, masks, planning, pipeline APIs, and re-exported optional helpers | | @paramission-lab/phantom/ai | Browser AI background-removal facade | | @paramission-lab/phantom/png | Canvas-free PNG encoder and decoder (works in Node) | | @paramission-lab/phantom/metadata | EXIF orientation and ICC profile reader | | @paramission-lab/phantom/cli | The phantom command-line entry point | | @paramission-lab/phantom/gpu | WebGPU compute, WebGPU renderer, WebGL renderer, and capability detection | | @paramission-lab/phantom/wasm | WASM loader (Zig-compiled) and kernel adapter types | | @paramission-lab/phantom/workers | TileWorkerPool and SharedTileBuffer | | @paramission-lab/phantom/worker | Short browser worker module path for TileWorkerPool | | @paramission-lab/phantom/workers/tile-worker | Long-form alias for the same worker module |

Prefer subpath imports for browser-only modules when you want bundlers to keep optional code separated.

Default API

The default export is the phantom facade:

import phantom from "@paramission-lab/phantom";

| Function | Description | | --------------------------------------------- | ------------------------------------------------------------- | | createImage(width, height, color?) | Allocate a raw RGBA image with an optional fill color | | edit(image) | Start a chainable edit pipeline | | cropImage(image, rect) | Crop into a new raw RGBA image | | resizeImage(image, width, height, options?) | Resize with bilinear by default or nearest when requested | | applyFilter(image, filter?, options?) | Apply one filter with safe overlap defaults | | applyFilters(image, filters, options?) | Apply multiple filters in order | | blurImage(image, radius, options?) | Gaussian blur with a caller-chosen radius | | medianImage(image, radius, options?) | Median denoise over a (2r+1)^2 window | | processImageBatch(images, options?) | Process several images with bounded concurrency | | applyMask(image, mask, options?) | Apply a provider-generated alpha mask | | replaceBackground(image, color) | Flatten transparent pixels onto a solid RGB color | | adjustImage(image, options) | Apply tone and color adjustments to a raw RGBA image | | applyCurves(image, options) | Apply tone curves, shared or per channel | | applyLevels(image, options) | Remap input/output ranges with a midtone gamma | | applySepia(image, options?) | Apply a classic sepia tone | | applyDuotone(image, options) | Map luminance onto a two-color ramp | | applyVignette(image, options?) | Darken or brighten toward the frame edges | | parseCubeLut(text) | Parse an Adobe .cube 3D LUT | | applyLut3d(image, lut, options?) | Apply a 3D LUT with trilinear interpolation | | watermarkImage(image, options) | Burn a text watermark onto a raw RGBA image | | analyzeImage(image) | Compute RGB/luminance histograms for an image | | autoAdjustImage(image) | Suggest brightness/contrast based on histogram analysis | | createAssetPlan(image, options?) | Create a processing and encoding recipe | | configureWasm(source) | Load WASM kernel and register it globally | | useWasm() | Auto-resolve and initialize phantom_kernel.wasm | | isWasmReady() | Check if the global WASM tile processor is registered | | convertImage(input, options?) | Convert browser image inputs through canvas encoding | | optimizeImage(input, options?) | Re-encode browser images with conservative defaults |

Edit Pipeline

phantom.edit(image) accepts a RawRgbaImage or Promise<RawRgbaImage> and returns a chainable pipeline.

| Method | Description | | --------------------------------- | --------------------------------------------------------------- | | crop(rect) | Crop with { x, y, width, height } | | resize(width, height, options?) | Resize with bilinear or nearest | | filter(filter?, options?) | Apply one filter, defaulting to smoothEnhance | | filters(filters, options?) | Apply multiple filters in order | | blur(radius, options?) | Gaussian blur; sigma defaults to radius / 2 | | median(radius, options?) | Median denoise, radius 1-8 | | mask(mask, options?) | Apply an alpha mask with refinement | | background(color) | Replace transparency with a solid color | | adjust(options) | Apply brightness, contrast, saturation, temperature, hue, gamma | | watermark(options) | Burn a text watermark into the image | | curves(options) | Apply tone curves, shared or per channel | | levels(options) | Remap input/output ranges with a midtone gamma | | sepia(options?) | Apply a sepia tone | | duotone(options) | Map luminance onto a two-color ramp | | vignette(options?) | Darken or brighten toward the frame edges | | lut(lut, options?) | Apply a parsed .cube 3D LUT | | plan(options?) | Resolve a PhantomAssetPlan for the current image | | run() | Resolve the edited RawRgbaImage |

Example:

const output = await phantom
  .edit(input)
  .crop({ x: 100, y: 80, width: 1200, height: 900 })
  .resize(600, 450, { method: "bilinear" })
  .filters(["smoothEnhance", "unsharpMask"], {
    tileSize: 512,
    onProgress: ({ percent }) => console.log(percent.toFixed(0)),
  })
  .adjust({ brightness: 10, contrast: 15, saturation: 5 })
  .watermark({ text: "CONFIDENTIAL", position: "bottom-right", opacity: 0.5 })
  .background({ r: 255, g: 255, b: 255 })
  .run();

Raw RGBA Utilities

import {
  cloneRawImage,
  createRawRgbaImage,
  cropRawImage,
  resizeRawImage,
} from "@paramission-lab/phantom";

| Function | Description | | --------------------------------------------- | ------------------------------------------------- | | createRawRgbaImage(dimensions, color?) | Allocate a transparent or solid-color RGBA buffer | | cloneRawImage(image) | Return a defensive copy | | cropRawImage(image, rect) | Copy a rectangular region | | resizeRawImage(image, dimensions, options?) | Resize with bilinear or nearest |

resizeImage(image, width, height, options?) is the compact facade signature for resizeRawImage().

Streaming Ingestion

Phantom provides a fixed-capacity byte ring buffer for low-overhead stream ingestion, allowing you to ingest large chunks of pixel data without dynamic heap growth or garbage collection spikes.

import {
  FixedByteRingBuffer,
  pipeChunksToBuffer,
} from "@paramission-lab/phantom";

FixedByteRingBuffer

Use FixedByteRingBuffer when you need a power-of-two aligned circular buffer for incoming streams:

// Allocate a 1MB circular buffer (it rounds up to the next power of two)
const ring = new FixedByteRingBuffer(1024 * 1024);

// Write bytes
const bytesWritten = ring.write(incomingUint8Array);

// Read bytes
const out = new Uint8Array(2048);
const bytesRead = ring.read(out);

// Or drain without copying: readableSlices() returns one view, or two when the
// data wraps past the end of the ring. The views alias live ring memory and are
// invalidated by the next write/read/consume/clear.
for (const slice of ring.readableSlices()) {
  decoder.push(slice);
  ring.consume(slice.length);
}

pipeChunksToBuffer

Use pipeChunksToBuffer to consume sync or async iterables of raw bytes and trigger a callback with a bounded memory footprint:

const totalBytes = await pipeChunksToBuffer(
  asyncChunksIterable,
  (chunk) => {
    // Process the chunk (e.g., feed to a decoder)
    console.log("Chunk received:", chunk.length);
  },
  64 * 1024 * 1024, // 64MB buffer capacity limit
);

Chunks accumulate in the ring and flush when it fills, plus once at the end of the stream, so onChunk receives larger batches than the producer emitted. Each chunk is a zero-copy view over the ring's storage and is only valid for the duration of that call — copy anything you need to keep.

Filters and Tile Processing

Supported Filters

import {
  getPixelFilterOverlap,
  getPixelFilterProfile,
  listPixelFilters,
} from "@paramission-lab/phantom";

| Filter | Label | Overlap | Notes | | --------------- | --------------- | ------- | -------------------------------- | | identity | Identity | 0 | Copy pixels | | invert | Invert | 0 | Invert RGB, preserve alpha | | grayscale | Grayscale | 0 | Fixed-point luminance | | smoothEnhance | Natural Enhance | 1 | Local contrast enhancement | | sharpen3x3 | Crisp Sharpen | 1 | 3x3 sharpen | | boxBlur3x3 | Soft Blur | 1 | 3x3 blur | | unsharpMask | Phantom Clarity | 1 | Delivery-oriented clarity filter |

Use listPixelFilters() to drive UI controls from metadata instead of hardcoding labels.

Radius-Parameterized Filters

Named filters have fixed kernels. When the size is the point, pass a spec:

import { blurImage, medianImage, processRawImage } from "@paramission-lab/phantom";

const soft = await blurImage(image, 8);              // sigma defaults to 4
const softer = await blurImage(image, 8, { sigma: 6 });
const denoised = await medianImage(image, 2);

// Or through any pipeline API:
await processRawImage(image, { filter: { kind: "gaussianBlur", radius: 12 } });
await processRawImage(image, { filter: { kind: "median", radius: 3 } });

| Spec | Radius | Notes | | ----------------------------------------------- | ------ | --------------------------------------------------------- | | { kind: "gaussianBlur", radius, sigma? } | 1-32 | Separable; sigma defaults to radius / 2 | | { kind: "median", radius } | 1-8 | Removes speckle without softening edges, unlike a blur |

The tile overlap defaults to the kernel radius, not the 1px default a named filter gets. That is what makes tiled output identical to untiled output; an explicit overlap smaller than the radius is rejected rather than quietly banding every tile edge.

PixelFilter remains one arm of the wider FilterSpec union, so every existing call site and every custom TileProcessor keeps working unchanged.

Both kernels run on the CPU and in WASM, held to byte-for-byte agreement by test/kernel-parity.test.ts. The Gaussian weights are quantized once in JavaScript and passed into the module rather than derived on both sides: two independent exp() implementations agree to within a float ULP, which is enough to quantize to different integers and put the backends one LSB apart.

High-Level Filtering

import { applyFilter, applyFilters } from "@paramission-lab/phantom";

const one = await applyFilter(input, "smoothEnhance", { tileSize: 512 });
const many = await applyFilters(input, ["smoothEnhance", "unsharpMask"]);

Options:

| Option | Description | | --------------- | ------------------------------------------------------------------------ | | tileSize | Tile edge length in pixels | | signal | Abort signal checked between tiles | | tileProcessor | Custom CPU, worker, GPU, WASM, or native tile backend | | onTile | Receives each completed tile; chained filters report each filter stage | | onProgress | Receives completed tile count, total tiles, percent, and tile descriptor |

For applyFilters() and processRawImagePipeline(), tileProcessor, signal, onTile, and onProgress apply to every filter stage. This keeps accelerated processing, progress UI, and cancellation consistent with a single-filter call.

Low-Level Processing

import {
  processRawImage,
  processRawImagePipeline,
  processRawImageWithStats,
} from "@paramission-lab/phantom";

const output = await processRawImage(input, {
  filter: "sharpen3x3",
  tileSize: 512,
  overlap: 1,
});

const { image, stats } = await processRawImageWithStats(input, {
  filter: "smoothEnhance",
  onProgress: ({ completedTiles, totalTiles }) => {
    console.log(`${completedTiles}/${totalTiles}`);
  },
});

const recipe = await processRawImagePipeline(
  input,
  [{ filter: "smoothEnhance" }, { filter: "unsharpMask" }],
  { tileSize: 512 },
);

processRawImagePipeline() requires at least one step. If you configure an overlap smaller than a filter requires, Phantom throws PhantomError.

Pass tileProcessor to route tile execution through another backend while keeping the same tile planner, source, sink, progress, and validation path:

import {
  createWasmTileProcessor,
  instantiateWasmBackend,
  processRawImage,
} from "@paramission-lab/phantom";

const wasmBytes = await fetch("/phantom_kernel.wasm").then((response) =>
  response.arrayBuffer(),
);
const wasmBackend = await instantiateWasmBackend(wasmBytes);

const output = await processRawImage(input, {
  filter: "smoothEnhance",
  overlap: 1,
  tileProcessor: createWasmTileProcessor(wasmBackend),
});

Custom processors implement TileProcessor. Phantom validates tile-source byte lengths, returned tile descriptors, and output byte lengths before writing to the sink, so backend failures surface as PhantomError instead of silent output corruption.

Tile Buffer Pool

To eliminate garbage collection spikes on large images with thousands of tiles, Phantom includes a bucketed TileBufferPool that recycles Uint8Array buffers by size bucket:

import { TileBufferPool } from "@paramission-lab/phantom";

const pool = new TileBufferPool({
  maxPerBucket: 8,
  // Ceiling on retained memory, so an idle pool cannot pin the heap.
  maxPooledBytes: 256 * 1024 * 1024,
});

// Acquire a buffer of at least 1MB
const buffer = pool.acquire(1024 * 1024);

// Always use subarray for the working range as pool buffers can be larger than requested
const view = buffer.subarray(0, 1024 * 1024);

// Release the buffer back to the pool once done
pool.release(buffer);

Buffers below 64 KiB are bucketed to the next power of two; at or above that, buckets sit on a 64 KiB grid so overshoot stays under 64 KiB rather than approaching 2x. Only whole buffers are pooled — releasing a subarray view is ignored, since re-issuing it would alias memory another owner still holds.

Custom Sources and Sinks

Use TileSource and TileSink when integrating your own decoder, storage layer, or encoder:

import {
  processTileSource,
  type TileSink,
  type TileSource,
} from "@paramission-lab/phantom";

const source: TileSource = {
  read(rect) {
    return readRgbaBytesFromDecoder(rect);
  },
};

const sink: TileSink = {
  write(rect, data) {
    writeRgbaBytesToEncoder(rect, data);
  },
};

await processTileSource({ width: 32000, height: 32000 }, source, sink, {
  filter: "smoothEnhance",
  tileSize: 512,
  overlap: 1,
});

Parallel Processing and Backends

Concurrency

concurrency keeps several tiles in flight:

await processRawImage(image, {
  filter: "smoothEnhance",
  tileProcessor: myAsyncProcessor,
  concurrency: 8,
});

It defaults to 1, deliberately. Above 1 tiles complete out of order, so a custom TileSink must tolerate interleaved writes to disjoint rectangles — and the built-in CPU kernels are synchronous, so they never yield and gain nothing from interleaving. Raise it for processors that actually wait: workers, GPU queues, network backends.

Progress callbacks still report monotonically; progress.tile identifies which tile finished. The first tile failure stops the remaining lanes and is rethrown once the work already in flight has settled.

Workers as a tile processor

import { TileWorkerPool, createWorkerTileProcessor } from "@paramission-lab/phantom/workers";

const pool = new TileWorkerPool(workerUrl, { concurrency: 8 });
try {
  await processRawImage(image, {
    filter: "sharpen3x3",
    tileProcessor: createWorkerTileProcessor(pool),
    concurrency: 8, // match the pool, or lanes sit idle
  });
} finally {
  pool.dispose();
}

Pair the pipeline's concurrency with the pool's. At the default of 1 the pipeline waits for each tile before dispatching the next, so eight workers would run one at a time.

WebGPU as a tile processor

import { WebGpuComputeBackend, createWebGpuTileProcessor } from "@paramission-lab/phantom/gpu";

const backend = await WebGpuComputeBackend.create();
await processRawImage(image, {
  filter: "sharpen3x3",
  tileProcessor: createWebGpuTileProcessor(backend),
  concurrency: 2, // overlap dispatch with readback
});

Each tile is handed to the shader as if it were a small image. The shader clamps at that image's edges, and because the planner expands every tile by the filter's overlap, interior samples never reach the clamp — so the result matches the untiled computation, exactly as it does on the CPU and WASM paths.

Batches

const results = await processImageBatch(images, {
  filter: "unsharpMask",
  imageConcurrency: 4,
  onImage: (index) => console.log(`finished ${index}`),
});

Results come back in input order regardless of completion order. imageConcurrency is the knob that bounds peak memory: each in-flight image holds its own input and output frame, so 4 concurrent 100 MP images is 3.2 GB, not 800 MB.

Filter Plugins

Register a kernel and it inherits everything the built-ins get — tile planning, overlap enforcement, worker dispatch, progress, and abort:

import { registerFilterPlugin, processRawImage } from "@paramission-lab/phantom";

registerFilterPlugin({
  name: "channel-swap",
  overlap: 0, // pixels of context this kernel reads outside each output pixel
  process(input, output, { inputRect, outputRect, options }) {
    const offsetX = outputRect.x - inputRect.x;
    const offsetY = outputRect.y - inputRect.y;
    let target = 0;
    for (let y = 0; y < outputRect.height; y += 1) {
      const row = ((offsetY + y) * inputRect.width + offsetX) * 4;
      for (let x = 0; x < outputRect.width; x += 1) {
        const source = row + x * 4;
        output[target] = input[source + 2]; // B -> R
        output[target + 1] = input[source + 1];
        output[target + 2] = input[source]; // R -> B
        output[target + 3] = input[source + 3];
        target += 4;
      }
    }
  },
});

await processRawImage(image, { filter: { kind: "custom", name: "channel-swap" } });

Declaring overlap honestly is what buys seam-free tiling: the planner expands every tile by that much, and a kernel that samples further will read clamped edges and band at tile boundaries.

The WASM and WebGPU tile processors run plugin kernels on the CPU rather than failing — a plugin is JavaScript and cannot be called from inside a compiled module, and installing a backend globally must not silently disable an extension point.

Masks and Background Replacement

import {
  applyAlphaMask,
  featherAlphaMask,
  fillTransparentWith,
} from "@paramission-lab/phantom";

AlphaMask is a one-channel mask:

interface AlphaMask {
  readonly width: number;
  readonly height: number;
  readonly data: Uint8Array;
}

Apply a segmentation mask from any provider:

const cutout = applyAlphaMask(input, mask, {
  threshold: 8,
  softness: 24,
  featherRadius: 2,
  edgeSensitivity: 48,
});

console.log(cutout.removedPixels, cutout.partialPixels);

The helper used by applyAlphaMask internally is featherAlphaMask().

Mask refinement behavior:

| Option | Default | Description | | ----------------- | ------- | -------------------------------------------- | | threshold | 4 | Discard mask noise below this alpha value | | softness | 12 | Width of the transition around the threshold | | featherRadius | 2 | Color-guided edge filter radius, capped at 3 | | edgeSensitivity | 48 | RGB distance used for edge-aware mask mixing |

Flatten transparent pixels onto a background:

const jpegReady = fillTransparentWith(cutout, {
  r: 255,
  g: 255,
  b: 255,
});

Image Adjustments, Watermarks, and Analysis

Phantom supports direct color adjustments, watermark overlays, and color histograms for image analysis.

import phantom, {
  adjustImage,
  watermarkImage,
  analyzeImage,
  autoAdjustImage,
} from "@paramission-lab/phantom";

Tone and Color Adjustments

You can adjust brightness, contrast, saturation, temperature, hue, and gamma. All operations are processed in a single fast pass using precomputed look-up tables (LUTs) with alpha preserved.

const adjusted = adjustImage(image, {
  brightness: 10, // -100 to +100
  contrast: 15, // -100 to +100
  saturation: -20, // -100 to +100 (-100 is grayscale)
  temperature: 5, // -100 (cool) to +100 (warm)
  hue: 45, // -180 to +180 degrees
  gamma: 1.2, // 0.1 to 5.0
});

Text Watermark

Burn text watermarks into the raw RGBA buffer using the browser Canvas API.

const result = watermarkImage(image, {
  text: "© 2026 Paramission Lab",
  font: "bold 24px Inter, sans-serif",
  color: "rgba(255, 255, 255, 0.8)",
  position: "bottom-right", // presets like 'center', 'top-left', 'bottom-right'
  margin: 24,
  opacity: 0.8,
  rotation: -15, // clockwise degrees rotation
});

// Access the result image
const watermarkedImage = result.image;

Histogram Analysis & Auto-Adjust

Compute color channel histograms or get recommended adjustments based on luminance levels:

// Get full RGB + luminance histogram arrays
const histogram = analyzeImage(image);
console.log(histogram.r, histogram.g, histogram.b, histogram.luminance);

// Get suggested brightness & contrast adjustments
const suggestions = autoAdjustImage(image);
console.log(
  "Suggested adjustments:",
  suggestions.brightness,
  suggestions.contrast,
);

Color Grading

Creative grading lives in its own module, separate from the corrective controls in adjustImage(). Every operation here is pointwise — a pixel's output depends only on its own value, and on its coordinates for applyVignette() — so none of them needs tile overlap and none goes through the tile planner.

import {
  applyCurves,
  applyDuotone,
  applyLevels,
  applyLut3d,
  applySepia,
  applyVignette,
  parseCubeLut,
} from "@paramission-lab/phantom";

Curves

Control points are { input, output } pairs on a 0-255 scale, in any order.

const graded = applyCurves(image, {
  // Applied to all three channels first.
  rgb: [
    { input: 0, output: 12 }, // lifted blacks
    { input: 128, output: 140 },
    { input: 255, output: 255 },
  ],
  // Then per channel, for split toning.
  b: [
    { input: 0, output: 20 },
    { input: 255, output: 240 },
  ],
});

Interpolation uses a monotone (Fritsch-Carlson) spline. A natural cubic overshoots between steep control points, which on a tone curve appears as banding and inverted contrast in the shadows; the monotone form cannot overshoot by construction, so a monotonic set of points always yields a monotonic curve.

Levels

const stretched = applyLevels(image, {
  inputBlack: 16,
  inputWhite: 235, // expand a video-range image to full range
  gamma: 1.1,
  r: { outputWhite: 250 }, // per-channel options override the shared ones
});

Values outside [inputBlack, inputWhite] clip rather than extrapolate.

Sepia, duotone, and vignette

const toned = applySepia(image, { amount: 0.7 });

const split = applyDuotone(image, {
  shadow: { r: 12, g: 24, b: 68 },
  highlight: { r: 250, g: 236, b: 198 },
  amount: 0.85,
});

const framed = applyVignette(image, {
  amount: 0.6, // negative brightens the edges instead
  radius: 0.55, // where the falloff starts, as a fraction of corner distance
  softness: 0.4,
  roundness: 1, // 1 elliptical, 0 rectangular
});

amount: 0 is a no-op for all three and returns a copy.

3D LUTs

const lut = parseCubeLut(await (await fetch("/luts/teal-orange.cube")).text());
const graded = applyLut3d(image, lut, { amount: 0.8 });

parseCubeLut() reads LUT_3D_SIZE, TITLE, DOMAIN_MIN, and DOMAIN_MAX, and validates that the data block length matches the declared size. A LUT_1D_SIZE file is a tone curve, not a 3D LUT, and is rejected with a pointer to applyCurves() rather than being misread.

Sampling is trilinear over the eight surrounding lattice points. Nearest- neighbour is visibly posterized at the 17- and 33-step sizes most LUTs ship at.

Image Conversion and Optimization

Browser image conversion uses host canvas encoders:

import {
  canEncodeImageFormat,
  convertImageFile,
  getImageFormatProfile,
  listImageFormats,
  optimizeImageFile,
} from "@paramission-lab/phantom";

const webp = await optimizeImageFile(file, {
  format: "webp",
  quality: 0.92,
});

const png = await convertImageFile(file, { format: "png" });

Recognized formats:

| Format | MIME type | Alpha | Browser encode | | -------------- | ------------ | ----- | -------------- | | png | image/png | Yes | Yes | | jpeg / jpg | image/jpeg | No | Yes | | webp | image/webp | Yes | Yes | | avif | image/avif | Yes | Yes | | bmp | image/bmp | No | No | | gif | image/gif | Yes | No | | tiff | image/tiff | Yes | No |

bmp, gif, and tiff can be identified by metadata helpers, but convertImageFile() and encodeRawImage() throw if the browser cannot encode the requested format.

Supported browser inputs:

  • Blob or File
  • URL string or URL
  • HTMLCanvasElement
  • OffscreenCanvas
  • ImageBitmap
  • ImageData
  • RawRgbaImage

For formats without alpha support, pass background to flatten transparency:

const jpeg = await convertImageFile(cutout, {
  format: "jpeg",
  quality: 0.9,
  background: { r: 255, g: 255, b: 255 },
});

optimizeImageFile() defaults to keepOriginalWhenSmaller: true for Blob inputs, so it returns the original blob when re-encoding would increase size.

AI Background Removal

The AI entry point is browser-oriented and lazy-loads @huggingface/transformers only when used:

import ai from "@paramission-lab/phantom/ai";

const cutout = await ai.removeBackground(imageCanvas, {
  onProgress: (progress) => console.log(progress.label, progress.percent),
});

One-call API:

import { aiRemoveBackground } from "@paramission-lab/phantom/ai";

const result = await aiRemoveBackground(imageCanvas, {
  backend: "auto",
  maskCutoff: 38,
  softness: 54,
  featherRadius: 2,
  subjectGuard: 70,
});

console.log(result.backend, result.model, result.removedPixels);

Reuse one loaded model across many images:

import { applyAlphaMask } from "@paramission-lab/phantom";
import { createAiRemover } from "@paramission-lab/phantom/ai";

const remover = createAiRemover();
await remover.preload();

try {
  const { mask } = await remover.createMask(imageCanvas);
  const cutout = applyAlphaMask(input, mask);
} finally {
  await remover.dispose();
}

Configuration:

| Option | Default | Description | | ----------------- | --------------------------- | --------------------------------------------------------- | | model | onnx-community/ormbg-ONNX | Transformers.js background-removal model | | backend | auto | auto, webgpu, or wasm | | webgpuDtype | fp16 | WebGPU precision: fp16 or fp32 | | wasmDtype | q8 | CPU/WASM fallback precision: q4, q8, or fp32 | | maskCutoff | 38 | Demo-style foreground cutoff | | subjectGuard | 70 | Demo-style guard percentage used to tune edge sensitivity | | threshold | derived from maskCutoff | Direct alpha-mask threshold override | | softness | 54 | Edge transition width | | featherRadius | 2 | Color-guided refinement radius | | edgeSensitivity | derived from subjectGuard | Direct edge sensitivity override | | onProgress | none | Model loading and inference progress callback |

Concurrent preload() and createMask() calls on the same BrowserBackgroundRemover share one model initialization promise. Call dispose() when the model is no longer needed.

The default model is Apache-2.0 licensed. Model weights are downloaded on first AI use and cached by the browser runtime when available. Review model licenses before selecting a different model.

Asset Planning

createAssetPlan() returns a production recipe for filters, tile size, memory estimates, and output encoding:

import phantom, { createAssetPlan } from "@paramission-lab/phantom";

const plan = createAssetPlan(input, {
  goal: "delivery",
  maxWorkerBytes: 32 * 1024 * 1024,
});

const processed = await phantom.applyFilters(input, plan.filters, {
  tileSize: plan.tileSize,
});

Goals:

| Goal | Default filters | Recommended format | | -------------------- | --------------- | -------------------------------------- | | delivery | smoothEnhance | jpeg without alpha, otherwise webp | | archive | none | png | | preview | smoothEnhance | webp | | transparent-cutout | unsharpMask | webp |

The plan also reports pixels, rgbaBytes, transparency, processing estimates, selected tileSize, required overlap, and encoder options.

For advanced use cases where you do not have the image allocated yet (e.g. you only know its dimensions), you can retrieve tiling and memory stats using getProcessingPlan:

import { getProcessingPlan } from "@paramission-lab/phantom";

const stats = getProcessingPlan(
  { width: 32000, height: 32000 },
  {
    tileSize: 2048,
    overlap: 1,
    filter: "smoothEnhance",
    workerLanes: 4, // Number of concurrent worker lanes
  },
);

console.log(stats.tileCount); // e.g. 256 tiles
console.log(stats.peakTileBytes); // Peak memory for one tile
console.log(stats.estimatedScratchBytes); // Total peak scratch memory across worker lanes
console.log(stats.memoryReductionRatio); // e.g. 150x reduction

Metadata and Orientation

import { readImageMetadata, applyOrientation } from "@paramission-lab/phantom";

const bytes = new Uint8Array(await file.arrayBuffer());
const metadata = readImageMetadata(bytes); // sniffs JPEG or PNG by signature

let image = await decodeSomehow(bytes);
if (metadata.orientation !== undefined && metadata.orientation !== 1) {
  image = applyOrientation(image, metadata.orientation);
}

Why this is necessary: every browser decode path — createImageBitmap, a canvas draw — hands back pixels with the EXIF orientation flag already discarded. A photo taken in portrait arrives on its side and nothing downstream can tell. Reading the tag off the original bytes is the only way to recover it.

readImageMetadata() returns orientation, header dimensions, the ICC profile, and the raw EXIF block, and never throws on truncated or unrecognized input — a partially written upload degrades to "no metadata" rather than failing the pipeline.

JPEG stores ICC uncompressed across one or more APP2 segments, which are reassembled. PNG stores it deflate-compressed in iCCP; those bytes are returned with iccCompressed: true rather than being inflated, since making the reader async for a value most callers pass straight to an encoder is a poor trade.

applyOrientation() implements all eight orientations, including the four that swap width and height.

Node.js and the PNG Codec

convertImage, optimizeImage, encodeRawImage, and watermarkImage all need a Canvas, so they are browser-only. The PNG codec is not:

import { encodePng, decodePng } from "@paramission-lab/phantom/png";
import { readFile, writeFile } from "node:fs/promises";

const image = await decodePng(new Uint8Array(await readFile("in.png")));
const out = await blurImage(image, 6);
await writeFile("out.png", await encodePng(out));

Pure TypeScript over CompressionStream, so the same code runs in Node, Deno, Bun, browsers, and workers with no native dependencies.

  • Encoding produces 8-bit RGBA. filter: "adaptive" (the default) tries all five PNG filters per scanline and keeps the smallest, the heuristic the PNG spec itself recommends; filter: "none" skips the search for speed at the cost of size.
  • Decoding handles grayscale, RGB, indexed (with tRNS), grayscale+alpha, and RGBA, at 8 or 16 bits. Interlaced (Adam7) files are rejected rather than mis-decoded — reading them as progressive produces garbage that looks like a corrupt file rather than an unsupported one.

Scope is deliberately narrow: this is a transport for raw RGBA, not a general-purpose image library. For JPEG, WebP, and AVIF use the browser entry points.

Command Line

npx phantom photo.png out.png --auto-orient --blur 3 --adjust brightness=8 --vignette 0.4
phantom <input.png> [output.png] [options]

  --filter <name>          Any named pixel filter
  --blur <radius>          Gaussian blur, radius 1-32
  --median <radius>        Median denoise, radius 1-8
  --resize <WxH>           e.g. --resize 1920x1080
  --crop <X,Y,WxH>         e.g. --crop 100,50,800x600
  --adjust <k=v,...>       brightness, contrast, saturation, temperature, hue, gamma
  --sepia [amount]         0-1, default 1
  --vignette [amount]      -1 to 1, default 0.5
  --lut <file.cube>        Apply an Adobe .cube 3D LUT
  --auto-orient            Rotate per the source EXIF orientation tag
  --tile-size <n>          Tile size in pixels
  --concurrency <n>        Tiles in flight
  --info                   Print metadata and exit
  --quiet                  Suppress the summary

Operations apply in the order given on the command line, so --crop ... --resize ... and --resize ... --crop ... do different things.

PNG only, for the same reason the codec is: no native dependencies. Exit codes are 0 success, 1 runtime failure, 2 usage error.

Workers

Use TileWorkerPool in browser apps that can run module workers:

import { TileWorkerPool } from "@paramission-lab/phantom/workers";

const workerUrl = new URL("@paramission-lab/phantom/worker", import.meta.url);
const pool = new TileWorkerPool(workerUrl, {
  concurrency: 4,
  // Reject a tile whose worker has wedged, and replace that worker.
  // 0 (the default) disables the timeout.
  taskTimeoutMs: 30_000,
  // Bound respawns so a worker script that throws on startup cannot loop.
  maxWorkerRestarts: 4,
});

try {
  const result = await pool.runTile(tilePayload, "smoothEnhance");
} finally {
  pool.dispose();
}

The numeric form — new TileWorkerPool(workerUrl, 4) — still sets concurrency.

Cancel individual tiles with an AbortSignal. A queued tile is dropped; a running tile is abandoned and its lane recycled, because a kernel already executing inside a worker cannot be interrupted:

const controller = new AbortController();
const tile = pool.runTile(tilePayload, "smoothEnhance", {
  signal: controller.signal,
});
controller.abort();

TileWorkerPool transfers tile Uint8Array buffers to workers. A view backed by a SharedArrayBuffer is passed by reference instead — shared memory is not transferable and listing it would throw DataCloneError.

If one worker fails, only tasks assigned to that worker are rejected; unrelated in-flight tasks still complete, and the worker is replaced while the restart budget lasts. Once every worker is gone the pool rejects immediately rather than queueing work whose promises would never settle.

pool.size reports surviving workers and pool.outstanding reports queued plus running tiles, which is what you want for producer-side backpressure.

Use SharedTileBuffer when the runtime supports shared memory:

import { SharedTileBuffer } from "@paramission-lab/phantom/workers";

const tileMemory = new SharedTileBuffer(512 * 512 * 4, {
  preferShared: true,
});

const tileBytes = tileMemory.view();
console.log(tileMemory.shared);

Set requireShared: true when falling back to ArrayBuffer would be incorrect for your workload.

GPU and Browser Capabilities

import { detectCapabilities } from "@paramission-lab/phantom/gpu";

const capabilities = detectCapabilities();
console.log(capabilities.backend);

detectCapabilities() returns:

| Field | Description | | --------------------- | --------------------------------------- | | backend | webgpu, wasm-simd, or cpu | | webgpu | Whether navigator.gpu is available | | sharedArrayBuffer | Whether SharedArrayBuffer exists | | crossOriginIsolated | Whether the browser context is isolated | | hardwareConcurrency | Reported worker concurrency or 1 |

The GPU package also exports WebGpuComputeBackend, WebGpuRgbaRenderer, and WebGlRgbaRenderer for browser integrations that need direct rendering or compute control.

WASM Backend

Build the TypeScript output and Zig-compiled WASM kernel:

npm run build
npm run build:wasm

Instantiate the backend:

import {
  createWasmTileProcessor,
  instantiateWasmBackend,
} from "@paramission-lab/phantom/wasm";

const bytes = await fetch("/phantom_kernel.wasm").then((response) =>
  response.arrayBuffer(),
);

const backend = await instantiateWasmBackend(bytes);
const output = backend.process(input, "grayscale");
const tileProcessor = createWasmTileProcessor(backend);

The WASM backend supports whole-image processing, tile processing, and alpha-mask application through the WasmKernelBackend interface. Use createWasmTileProcessor() when you want processRawImage(), processRawImagePipeline(), or processTileSource() to execute tiles through the compiled WASM kernel. The release package ships dist; the zig/ source tree is for repository development.

For environments that bundle phantom_kernel.wasm next to the compiled JS, use the zero-config useWasm() helper instead of manually resolving the path:

import { useWasm } from "@paramission-lab/phantom";

await useWasm();
// phantom_kernel.wasm resolved automatically relative to the module

For custom paths, use configureWasm() to register the WASM kernel as the global tile processor:

import { configureWasm } from "@paramission-lab/phantom";

// Pass a URL, path string, or BufferSource containing phantom_kernel.wasm
await configureWasm("/my-assets/phantom_kernel.wasm");

After calling useWasm() or configureWasm(), all subsequent high-level phantom calls (e.g. applyFilter, applyFilters, .run(), or processTileSource) that do not supply their own custom tileProcessor will route through the WASM kernel automatically. You can check if the WASM processor is currently registered using isWasmReady().

Error Handling

Use PhantomError for SDK validation and backend failures:

import { PhantomError, processRawImage } from "@paramission-lab/phantom";

try {
  await processRawImage(input, {
    filter: "sharpen3x3",
    overlap: 0,
  });
} catch (error) {
  if (error instanceof PhantomError) {
    console.error(error.message);
  } else {
    throw error;
  }
}

Common validation failures:

  • Invalid dimensions or rectangle bounds.
  • RGBA data length does not equal width * height * 4.
  • Unsupported filter or image format.
  • Convolution filter overlap is too small.
  • Browser canvas, fetch, worker, WebGPU, or shared-memory APIs are unavailable.

Architecture

| Layer | Responsibility | | ---------------------- | ----------------------------------------------------------------- | | Decoder or caller | Provides source pixels from browser, Node.js, or a custom decoder | | TileSource | Reads bounded rectangular RGBA regions | | Tile planner | Splits the image into overlap-safe tile descriptors | | TileProcessor | Executes one tile on CPU, WASM, or another backend | | CPU kernels | Provide deterministic filter behavior | | Worker pool | Runs transferable tile jobs off the browser main thread | | WASM backend (Zig) | Runs compiled kernels from phantom_kernel.wasm | | WebGPU compute backend | Accelerates compatible processing in WebGPU runtimes | | AI mask provider | Creates semantic alpha masks in browser apps | | TileSink | Writes processed tile output to storage or an encoder | | Renderer adapters | Upload RGBA data to WebGPU or WebGL previews |

Compressed image streaming is intentionally outside the core. Implement TileSource.read(rect) and TileSink.write(rect, data) to integrate a decoder or encoder without coupling Phantom to one codec.

Development

Requirements:

  • Node.js 22 or later
  • npm 10 or later
  • Zig 0.16.0 for npm run build:wasm and npm run ci

Install dependencies:

npm ci

Useful scripts:

| Command | Purpose | | ----------------------- | ------------------------------------------------------------------ | | npm test | Run Vitest tests | | npm run bench | Run Vitest performance benchmarks | | npm run typecheck | Run TypeScript strict checks | | npm run lint | Run oxlint, including its type-aware rules | | npm run lint:fix | Run oxlint and apply its auto-fixes | | npm run format | Run Prettier code formatting on the codebase | | npm run build | Emit TypeScript build artifacts to dist/ | | npm run build:wasm | Compile zig/src/phantom-kernel.zig to dist/phantom_kernel.wasm | | npm run build:wasm:baseline | Same, without WASM SIMD, for pre-2021 runtimes | | npm run demo:build | Build the demo app to demo-dist/ | | npm run demo:preview | Preview the built demo app locally | | npm run dev | Run the demo app locally in development mode | | npm run ci | Run typecheck, lint, tests, TypeScript build, and WASM build (Zig) | | npm run release:patch | Bump package patch version | | npm run release:minor | Bump package minor version | | npm run release:major | Bump package major version |

Full local verification:

npm run ci
npm run demo:build
npm pack --dry-run

Do not commit generated dist/, demo-dist/, model weights, caches, local environment files, or Zig build output.

See CONTRIBUTING.md for pull-request rules and SECURITY.md for private vulnerability reporting.

Release Process

The repository publishes to npm through .github/workflows/publish-npm.yml. The workflow runs on:

  • Pushes to tags matching v*.*.*.
  • Published GitHub Releases.
  • Manual workflow dispatch with a release tag input.

Before the first npm release, configure:

  1. An npm automation token with publish access.
  2. A GitHub Actions secret named NPM_TOKEN.
  3. A GitHub Environment named npm.
  4. Access to the npm organization scope @paramission-lab.

GitHub organizations and npm organizations are separate. The publish workflow expects package.json to use @paramission-lab/phantom; creating only the GitHub organization is not enough.

Release checklist:

npm ci
npm run ci
npm run demo:build
npm pack --dry-run
npm version patch
git push origin main --follow-tags

Pushing a tag that matches v<package.version> starts the publish workflow. The workflow checks out the tag, verifies the package name, verifies the tag equals v<package.version>, runs full validation, builds the demo, performs npm pack --dry-run, and publishes with npm provenance.

If publishing fails with E404 Scope not found, create the npm organization paramission-lab on npmjs.com or change the package scope to one the token can publish. Then create a new patch version and tag; do not move an already pushed release tag.

Operational Limits

  • Phantom can process very large targets as bounded tiles, but this does not mean every browser, decoder, canvas, or GPU can allocate a full 32K/64K frame.
  • Browser image conversion depends on host canvas encoder support.
  • WebGPU support and precision vary by browser, GPU, and driver.
  • SharedArrayBuffer in browsers requires cross-origin isolation headers.
  • AI background-removal quality depends on model choice, input content, backend, and mask-refinement settings.
  • For extreme-resolution AI cutouts, run inference on a bounded working image and apply/refine the resulting mask through tile-aware workflows instead of allocating a full-resolution neural-network tensor.

Project Documents

License

Apache 2.0