faceless-photolib

@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-model

Usage

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

ExportDescription
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.
DocumentOpThe discriminated op-set: addLayer, addAdjustment, removeLayer, reorder, setBlendMode, setOpacity, setFill, transform, clip, setMask, group.
InsertPositionWhere 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.

applyOpfunction
applyOp(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`.

createDocumentfunction
createDocument(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.

enumerateForCompositefunction
enumerateForComposite(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.

findLayerfunction
findLayer(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.

InsertPositioninterface
interface InsertPosition

Where to insert a layer relative to the stack/group (index 0 = bottom).

DocumentOptype
type DocumentOp

The named document op-set as a discriminated union keyed on `op` (engine-api spec: `applyCommand`). Each op is validated then applied by immer.

On this page