@faceless-photolib/document-model
Immutable document model and immer-based op-set over the ordered layer stack (insert/remove/reorder, blend/opacity/transform edits, groups, clipping, masks, locks) for faceless-photolib.
The immutable document model for faceless-photolib — a headless, color-managed, GPU-accelerated image-editing engine.
A Document holds an ordered, nestable layer stack (image / adjustment / group layers). Every edit
goes through a single validated, named op-set and is applied with immer,
so each operation returns a new, auto-frozen Document — source values are never mutated and layer
ids are preserved across reorders and moves. Operations that would violate an invariant fail loudly
with a typed Result (not-found, invalid-request, rejected) rather than silently clamping or
no-op'ing. The op vocabulary doubles as the engine's command surface.
Install
pnpm add @faceless-photolib/document-modelUsage
import {
createDocument,
applyOp,
findLayer,
enumerateForComposite,
type DocumentOp,
} from "@faceless-photolib/document-model";
import { DocumentIdSchema, LayerIdSchema } from "@faceless-photolib/schemas";
// Create an empty 1920×1080 document with the engine's D3 defaults
// (ACEScg connection space, linear-light blending, ACES 1.x output).
const created = createDocument(DocumentIdSchema.parse("doc-1"), 1920, 1080);
if (created.kind !== "ok") throw new Error("invalid document defaults");
let doc = created.value;
// Apply a validated op. `applyOp` returns a Result<Document>; narrow on `kind`.
const layerId = LayerIdSchema.parse("layer-bg");
const op: DocumentOp = { op: "setOpacity", layerId, opacity: 0.5 };
const result = applyOp(doc, op);
switch (result.kind) {
case "ok":
doc = result.value; // a new, frozen Document — `doc` above is untouched
break;
case "not-found":
case "invalid-request": // out-of-range opacity is rejected, never clamped
case "rejected": // e.g. a layer lock blocked the edit
console.warn("op refused:", result);
break;
}
// Look up a single layer anywhere in the (possibly nested) stack.
const found = findLayer(doc, layerId);
// Flatten to visible, paint-ordered (bottom→top) layers for compositing:
// pass-through groups are spliced in; isolated groups stay a single unit.
const paintOrder = enumerateForComposite(doc);API
| Export | Description |
|---|---|
createDocument(id, width, height) | Builds an empty, schema-validated Document with the engine's default settings; returns a Result<Document>. |
applyOp(doc, op) | Validates and applies one DocumentOp, returning a new Result<Document> (immer-frozen on success). |
findLayer(doc, layerId) | Locates a layer by id anywhere in the nested stack; returns Result<Layer> (not-found when absent). |
enumerateForComposite(doc) | Flattens the stack to visible, paint-ordered layers — pass-through groups inlined, isolated groups kept as one unit. |
DocumentOp | The discriminated op-set: addLayer, addAdjustment, removeLayer, reorder, setBlendMode, setOpacity, setFill, transform, clip, setMask, group. |
InsertPosition | Where a layer lands — a root/group parent plus a 0-based index (0 = bottom). |
All operations are pure: they never mutate the input Document, and layer identity is preserved across
reorders and moves. Result, Document, Layer, and related types come from
@faceless-photolib/schemas.
License
MIT
API reference
6 public exports · 6 documented · generated from source.
applyOpfunctionapplyOp(doc: { schemaVersion: 1; id: string & $brand<"DocumentId">; name: string; size: { width: number; height: number; }; settings: { connectionSpace: string & $brand<"ColorSpaceId">; blendSpace: "linear-light" | "perceptual"; outputTransform: "aces-1.x" | "aces-2.0"; dissolveSeed: number; }; layers: readonly Layer[]; }, op: DocumentOp): Result<...>Apply one validated op to a document, returning a new document (immer). A locked-mutation violation returns `rejected("locked", …)` naming the lock; an op whose value would violate the schema returns `invalid-request`; a missing target layer returns `not-found`.
createDocumentfunctioncreateDocument(id: string & $brand<"DocumentId">, width: number, height: number): Result<{ schemaVersion: 1; id: string & $brand<"DocumentId">; name: string; size: { width: number; height: number; }; settings: { ...; }; layers: readonly Layer[]; }>Create an empty document with the given id, size, and default settings. The defaults follow D3 (ACEScg connection space, ACES 1.x output) and the blend-space open question is resolved here to `linear-light` (physically correct; documented in design.md). The constructed value is `safeParse`-d so a future default that violates the schema fails loud rather than silently.
enumerateForCompositefunctionenumerateForComposite(doc: { schemaVersion: 1; id: string & $brand<"DocumentId">; name: string; size: { width: number; height: number; }; settings: { connectionSpace: string & $brand<"ColorSpaceId">; blendSpace: "linear-light" | "perceptual"; outputTransform: "aces-1.x" | "aces-2.0"; dissolveSeed: number; }; layers: readonly Layer[]; }): readonly Layer[]Enumerate the flattened, visible-contributing layers in paint order (bottom→top). Invisible layers contribute nothing (an invisible group skips its entire subtree). A visible `pass-through` group is spliced in — its children blend against the backdrop as though the group boundary were absent (D5) — so the flat list carries them directly. A visible `isolated` group is pushed as a single opaque unit (its children composite against a transparent backdrop before the group blends down), because the `ReadonlyArray<Layer>` return type cannot otherwise carry that compositing boundary.
findLayerfunctionfindLayer(doc: { schemaVersion: 1; id: string & $brand<"DocumentId">; name: string; size: { width: number; height: number; }; settings: { connectionSpace: string & $brand<"ColorSpaceId">; blendSpace: "linear-light" | "perceptual"; outputTransform: "aces-1.x" | "aces-2.0"; dissolveSeed: number; }; layers: readonly Layer[]; }, layerId: string & $brand<...>): Result<...>Locate a layer by id anywhere in the (possibly nested) stack.
InsertPositioninterfaceinterface InsertPositionWhere to insert a layer relative to the stack/group (index 0 = bottom).
DocumentOptypetype DocumentOpThe named document op-set as a discriminated union keyed on `op` (engine-api spec: `applyCommand`). Each op is validated then applied by immer.
@faceless-photolib/interaction
Headless crop / resize / rotate / straighten / free-transform interaction controllers — pure state-machine reducers that emit committed transforms and crop rects, replicating Photoshop and Lightroom crop+transform controls without any UI.
@faceless-photolib/image-codec
Node image decode/encode (PNG/JPEG/WebP/AVIF via sharp) and an SVG-backed TextRasterizer — the ImageCodec / AssetStore boundary adapters for the faceless-photolib engine.