Appearance
Concepts
This page describes the architecture of Lumina JS. These concepts affect how you trade preview speed against output quality and how you integrate Lumina JS with a bundler.
Non-destructive Editing
Lumina JS uses a non-destructive editing model. The editor keeps the original image data unchanged and applies each operation on top of the original, so you can undo operations one at a time or reset the whole edit.
Lightroom and Capture One use the same model.
Preview vs Apply
Every adjustment has two methods:
preview*()— renders the adjustment on a scaled-down copy of the image without recording it in history. Use this while the user is dragging a slider to show real-time feedback. The Client API cancels pending preview operations when you call an apply method.apply*()— commits the adjustment to the editing pipeline and records it in the undo/redo history. Use this when the user releases a slider or confirms a value.
typescript
// While the user drags the slider
const { imageData } = await editor.previewExposure(slider.value);
// When the user releases the slider
await editor.applyExposure(slider.value);Splitting preview from apply keeps the UI responsive. Previews render fast and leave history unchanged. Apply calls record the edit in history.
Processing Pipeline
Lumina JS applies operations in a fixed order, whatever order you call them in, so the same settings produce the same result:
| Order | Operation | Domain |
|---|---|---|
| 1 | Temperature / Tint | Linear light |
| 2 | Exposure | Linear light |
| 3 | Highlights / Shadows / Midtones | Linear light |
| 4 | Brightness | Linear light |
| 5 | Contrast | Linear light |
| 6 | Color Grading | Linear light |
| — | Linear → Perceptual conversion | — |
| 7 | Tonal Curve | Perceptual (sRGB) |
| 8 | Saturation | Perceptual (sRGB) |
Operations 1–6 run in 32-bit linear HDR space and preserve the full dynamic range of the sensor data. The pipeline applies the perceptual conversion (gamma curve) once, before the tonal curve and saturation steps, which operate in perceptual space.
Preview Sizing
The editor scales preview images down for performance. The previewSize config option (default: 1024) sets the maximum dimension in pixels. previewQuality (default: 100) sets the JPEG compression quality of previews.
exportImage() processes at the original image dimensions, regardless of preview settings.
Undo / Redo
The two APIs handle undo/redo differently:
Client API — Linear History
The Client API maintains a single chronological undo/redo stack. undo() and redo() step through operations in the order you applied them, regardless of type. Applying a new operation clears the redo entries (standard branching behavior).
typescript
await editor.undo(); // undoes the most recent operation
await editor.redo(); // re-applies the most recently undone operationCore API — Per-operation Stacks
The Core API maintains independent undo/redo stacks for each operation type (depth: 20 per type). You must specify which operation to undo or redo.
typescript
import { Shared } from '@luminaphoto/lumina-js';
editor.undoOperation(Shared.NodeType.Exposure);
editor.redoOperation(Shared.NodeType.Exposure);Client API vs Core API
Both APIs share the same underlying WASM engine and produce identical results. They differ in threading model and async behavior:
| Client API | Core API | |
|---|---|---|
| Threading | Web Worker (off main thread) | Main thread or your own worker |
| Async model | All methods return Promises | loadImage is async; operations are sync |
| Preview cancellation | Automatic (stale previews cancelled) | Manual |
| Undo/redo | Linear history | Per-operation stacks |
| Best for | Browser UIs | Batch pipelines, custom workers, Node.js |
Migrating from Client to Core
The operation methods have the same names and parameters. The main differences are:
typescript
// Client API (async)
const { imageData } = await editor.previewExposure(1.2);
await editor.applyExposure(1.2);
const { data } = await editor.exportImage('jpeg', 95);
// Core API (sync, except loadImage and initialize)
const { imageData } = editor.previewExposure(1.2);
editor.applyExposure(1.2);
const { data } = editor.exportImage('jpeg', 95);Drop the await for operations and export, handle undo/redo per-operation-type, and call editor.dispose() synchronously.
WASM Loading
Lumina JS runs on a WebAssembly module (lumina-js.wasm). The API you use determines how Lumina JS locates and loads the .wasm file.
Client API — Automatic
The Client API runs WASM inside a dedicated Web Worker. You provide the workerPath when creating the editor, and the Worker resolves the .wasm file from its own script location without extra configuration.
typescript
import { Client } from '@luminaphoto/lumina-js';
const editor = new Client.Editor({
licenseKey: dependentKeyFromServer,
workerPath: '/lumina-js/client/worker.js',
});The Worker script and .wasm file are both under dist/ in the published package. Copy the entire dist/ contents to a public path and point workerPath at the worker script.
Core API and Licensing — configureWasm
The Core API (Core.Editor) and the Licensing module (Licensing.createDependentKey) load WASM on the calling thread. By default, the module uses import.meta.url to locate the .wasm file relative to the module source.
import.meta.url resolves correctly when the runtime loads the module files unbundled (e.g. in a Web Worker or Node.js). If your bundler (Vite, webpack, Rollup) inlines the Lumina modules into a single bundle, import.meta.url points to the bundle file instead of the WASM directory, and the WASM module fails to load.
In that case, call Core.configureWasm() before any WASM-dependent call:
typescript
import { Core } from '@luminaphoto/lumina-js';
Core.configureWasm({
locateFile: (path, prefix) => {
if (path.endsWith('.wasm') || path.endsWith('.data')) {
return `/lumina-js/core/wasm/${path}`;
}
return prefix + path;
},
});Call configureWasm() once, before Core.Editor.initialize() or Licensing.createDependentKey(). After the WASM module loads, you cannot change the configuration.
Vite
Vite does not inline ES module imports by default, so import.meta.url resolves the WASM path and you do not need configureWasm. Serve the WASM files from a public path:
typescript
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
exclude: ['@luminaphoto/lumina-js'],
},
});Webpack / Rollup
When a bundler inlines modules, import.meta.url stops resolving to the WASM directory. Use configureWasm to provide an explicit path, as shown above. The path should point to the directory containing lumina-js.wasm.
Node.js
In Node.js, import.meta.url resolves to a file:// URL pointing to the module source, so Lumina JS finds the .wasm file without any configuration.
Memory Management
Both editors allocate native WASM memory for image data. Call dispose() when you finish with the editor:
typescript
// Client API
await editor.dispose();
// Core API
editor.dispose();After you call dispose(), you cannot reuse the editor instance. Create a new editor instead.