@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
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
- Installation
- Runtime requirements
- Quick start
- Core concepts
- Package entry points
- Default API
- Raw RGBA utilities
- Streaming ingestion
- Filters and tile processing
- Parallel processing and backends
- Filter plugins
- Masks and background replacement
- Image adjustments, watermarks, and analysis
- Color grading
- Image conversion and optimization
- AI background removal
- Asset planning
- Metadata and orientation
- Node.js and the PNG codec
- Command line
- Workers
- GPU and browser capabilities
- WASM backend
- Error handling
- Development
- Release process
- Operational limits
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
TileSourceandTileSinkcontracts 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/phantomThe 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:
BloborFile- URL string or
URL HTMLCanvasElementOffscreenCanvasImageBitmapImageDataRawRgbaImage
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 reductionMetadata 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.4phantom <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 summaryOperations 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:wasmInstantiate 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 moduleFor 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:wasmandnpm run ci
Install dependencies:
npm ciUseful 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-runDo 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:
- An npm automation token with publish access.
- A GitHub Actions secret named
NPM_TOKEN. - A GitHub Environment named
npm. - 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-tagsPushing 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.
SharedArrayBufferin 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.
