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

@a-company/atelier-core

v0.25.3

Published

Animation engine — delta resolution, builder API, state machine

Downloads

335

Readme


title: "@atelier/core" scope: Animation engine — frame resolver, DocumentBuilder, StateMachine, templates, presets packages: ["@atelier/core"] related: ["docs/format-spec.md", "docs/architecture.md", "docs/builder-guide.md", "docs/state-machine-guide.md", "docs/rendering-pipeline.md"]

@atelier/core

Animation engine for the Atelier document format. Resolves frames from declarative animation documents, provides a fluent builder API for constructing documents, and includes a state machine for multi-state playback.

Package Info

| Field | Value | |-------|-------| | Name | @atelier/core | | Version | 0.1.0 | | Description | Animation engine -- delta resolution, builder API, state machine | | Source | packages/core/src/ | | Build | tsup (ESM + CJS + DTS, with sourcemaps) | | Test | vitest |

Dependencies

All workspace packages:

  • @atelier/types -- type definitions for documents, layers, deltas, easings
  • @atelier/schema -- Zod validation schemas
  • @atelier/math -- interpolation, easing curves (lerp, cubicBezier, spring, etc.)

Installation

pnpm add @atelier/core

Or from the monorepo root:

pnpm --filter @atelier/core build

Exports

Everything is exported from a single entry point:

import {
  // Frame resolution
  resolveFrame,
  resolveDeltaValue,
  resolvePropertyAtFrame,
  isFrameInRange,
  computeProgress,
  interpolateValue,
  resolveEasing,

  // Validation
  validateNoOverlap,
  validateAllDeltas,
  rangesOverlap,

  // Builder
  DocumentBuilder,
  createDocument,

  // Units
  resolveUnit,
  isPercentage,
  parsePercentage,

  // Presets
  expandPreset,

  // State machine
  StateMachine,

  // Templates
  instantiateTemplate,
  findTemplateVariables,
} from "@atelier/core";

// Types
import type {
  ResolvedFrame,
  ResolvedLayer,
  OverlapError,
  StateTransition,
  PlaybackState,
  TemplateBindings,
  TemplateError,
  TemplateResult,
} from "@atelier/core";

Modules

1. Frame Resolver

src/resolver/frame-resolver.ts -- Main entry point for frame resolution.

Given an AtelierDocument, a state name, and a frame number, resolves every layer's animated properties at that instant.

function resolveFrame(doc: AtelierDocument, stateName: string, frame: number): ResolvedFrame;

Interfaces:

interface ResolvedLayer {
  id: string;
  layer: Layer;                                              // original layer definition
  computedProperties: Partial<Record<AnimatableProperty, unknown>>; // animated overrides
}

interface ResolvedFrame {
  frame: number;       // frame number that was resolved
  stateName: string;   // state name that was resolved
  layers: ResolvedLayer[];
}

Algorithm:

  1. Look up the state by name (throws if not found).
  2. Group all deltas by layerId, then by property name.
  3. For each layer, for each animated property, call resolvePropertyAtFrame().
  4. Return a ResolvedFrame containing all layers with their computed property values.

Example:

import { resolveFrame } from "@atelier/core";

const result = resolveFrame(doc, "intro", 15);

for (const layer of result.layers) {
  console.log(layer.id, layer.computedProperties);
  // "title" { opacity: 0.75, y: 120 }
}

2. Delta Resolver

src/resolver/delta-resolver.ts -- Per-property interpolation logic.

function isFrameInRange(frame: number, range: FrameRange): boolean;
function computeProgress(frame: number, range: FrameRange): number;
function resolveDeltaValue(delta: Delta, frame: number): unknown | undefined;
function interpolateValue(from: unknown, to: unknown, t: number): unknown;
function resolvePropertyAtFrame(deltas: Delta[], frame: number): unknown | undefined;

Key behaviors:

| Scenario | Result | |----------|--------| | Frame is within a delta's range | Interpolated value using eased progress | | Frame is AFTER all deltas | Hold the to value of the most recently completed delta | | Frame is BEFORE all deltas | undefined (layer defaults apply) | | Instantaneous delta (start === end) | Progress = 1, returns to value |

Interpolation rules:

  • Numbers: Linearly interpolated via lerp(from, to, t).
  • Strings (hex colors, labels, etc.): Snap to to at t >= 1, otherwise hold from.
  • Other types: Snap at t >= 1.

Value hold behavior:

After a delta's range ends, the property retains its to value rather than reverting. If multiple deltas have completed, the one with the latest end frame wins.

import { resolvePropertyAtFrame } from "@atelier/core";

// Two sequential deltas for opacity on the same layer:
// Delta A: frames [0, 10], from: 0, to: 1
// Delta B: frames [20, 30], from: 1, to: 0

resolvePropertyAtFrame(deltas, 5);   // 0.5  (mid-interpolation of A)
resolvePropertyAtFrame(deltas, 10);  // 1    (end of A)
resolvePropertyAtFrame(deltas, 15);  // 1    (hold A's `to` value)
resolvePropertyAtFrame(deltas, 25);  // 0.5  (mid-interpolation of B)
resolvePropertyAtFrame(deltas, 35);  // 0    (hold B's `to` value)

3. Easing Resolver

src/resolver/easing-resolver.ts -- Bridges Easing type definitions to executable math functions.

function resolveEasing(easing: Easing | undefined): (t: number) => number;

Supported easings:

| Easing Type | Source | |-------------|--------| | undefined / omitted | linear | | "ease-in" | easeIn preset | | "ease-out" | easeOut preset | | "ease-in-out" | easeInOut preset | | { type: "linear" } | linear | | { type: "cubic-bezier", x1, y1, x2, y2 } | cubicBezier(x1, y1, x2, y2) | | { type: "spring", mass, stiffness, damping, velocity } | spring({ ... }) | | { type: "step", steps, position } | step(steps, position) |

All underlying functions are provided by @atelier/math.


4. Document Builder

src/builder/document-builder.ts -- Fluent API for constructing valid AtelierDocument objects.

class DocumentBuilder {
  constructor(name: string, canvas: Canvas);

  description(desc: string): this;
  tags(...tags: string[]): this;
  variable(id: string, variable: Variable): this;
  asset(id: string, asset: Asset): this;
  preset(id: string, preset: Preset): this;

  addLayer(layer: Layer): this;
  addState(name: string, state: Omit<State, "deltas"> & { deltas?: Delta[] }): this;
  addDelta(stateName: string, delta: Delta): this;

  build(): AtelierDocument;
}

function createDocument(name: string, canvas: Canvas): DocumentBuilder;

Validations performed on every mutation:

| Method | Validation | |--------|-----------| | addLayer() | Rejects duplicate layer IDs | | addState() | Rejects duplicate state names | | addDelta() | Checks that the referenced layer exists | | addDelta() | Runs validateNoOverlap() against existing deltas |

build() returns a deep clone (via JSON.parse(JSON.stringify(...))) so the builder's internal state is not shared.

Example:

import { createDocument } from "@atelier/core";

const doc = createDocument("fade-in", { width: 1920, height: 1080, fps: 30 })
  .description("Simple fade-in animation")
  .tags("intro", "fade")
  .addLayer({ id: "bg", type: "fill", fill: "#000000" })
  .addLayer({ id: "title", type: "text", text: "Hello", x: 960, y: 540 })
  .addState("main", { duration: 60 })
  .addDelta("main", {
    id: "title-fade",
    layer: "title",
    property: "opacity",
    range: [0, 30],
    from: 0,
    to: 1,
    easing: "ease-out",
  })
  .addDelta("main", {
    id: "title-slide",
    layer: "title",
    property: "y",
    range: [0, 30],
    from: 560,
    to: 540,
    easing: "ease-out",
  })
  .build();

5. Overlap Validator

src/validation/overlap-validator.ts -- Ensures no two deltas animate the same property on the same layer during overlapping frame ranges.

interface OverlapError {
  layerId: string;
  property: string;
  existingRange: FrameRange;
  newRange: FrameRange;
  message: string;
}

function rangesOverlap(a: FrameRange, b: FrameRange): boolean;
function validateNoOverlap(existing: Delta[], newDelta: Delta): OverlapError | null;
function validateAllDeltas(deltas: Delta[]): OverlapError[];

Overlap rule: Two deltas conflict when they share the same layer and property AND their frame ranges intersect. The intersection check is a[0] <= b[1] && b[0] <= a[1] (inclusive on both ends).

  • validateNoOverlap() checks a single new delta against an existing array (used by DocumentBuilder.addDelta()).
  • validateAllDeltas() checks all pairs in an array, returning every overlap found (useful for batch validation of imported documents).

6. Unit Resolver

src/units/resolve-units.ts -- Converts UnitValue (number or percentage string) to pixel values.

function isPercentage(value: UnitValue): value is `${number}%`;
function parsePercentage(value: `${number}%`): number;
function resolveUnit(value: UnitValue, reference: number): number;

Behavior:

  • Numeric values pass through unchanged.
  • Percentage strings (e.g. "50%") are resolved relative to the reference dimension.
import { resolveUnit } from "@atelier/core";

resolveUnit(100, 1920);    // 100    (pixel value, unchanged)
resolveUnit("50%", 1920);  // 960    (50% of 1920)
resolveUnit("100%", 1080); // 1080   (100% of 1080)

7. Preset Resolver

src/presets/preset-resolver.ts -- Expands reusable preset definitions into concrete deltas.

function expandPreset(
  preset: Preset,
  layerId: string,
  startFrame: number,
  duration: number,
): Delta[];

Presets contain delta templates with relative offsets. expandPreset() converts them to absolute frame ranges by:

  1. If the preset delta has an offset tuple, the range becomes [startFrame + offset[0], startFrame + offset[1]].
  2. If no offset is specified, the range spans the full duration: [startFrame, startFrame + duration].

Each generated delta gets an auto-generated ID in the format preset-{layerId}-{index}.

import { expandPreset } from "@atelier/core";

const fadePreset = {
  name: "fade-in",
  deltas: [
    { property: "opacity", from: 0, to: 1, offset: [0, 30] },
  ],
};

const deltas = expandPreset(fadePreset, "title", 10, 60);
// [{ id: "preset-title-0", layer: "title", property: "opacity",
//    range: [10, 40], from: 0, to: 1 }]

8. State Machine

src/state/state-machine.ts -- Frame-by-frame playback controller with multi-state transitions.

interface StateTransition {
  from: string;
  to: string;
  at: number;  // frame at which the transition occurred
}

interface PlaybackState {
  stateName: string;
  frame: number;
  resolved: ResolvedFrame;
  isComplete: boolean;
}

class StateMachine {
  constructor(doc: AtelierDocument, initialState?: string);

  // Accessors
  get state(): string;
  get frame(): number;
  get stateNames(): string[];
  get duration(): number;
  get isComplete(): boolean;
  get history(): ReadonlyArray<StateTransition>;

  // Playback
  tick(): PlaybackState;
  transition(stateName: string, startFrame?: number): void;
  seek(frame: number): void;
  reset(stateName?: string): void;

  // Query
  resolveAt(stateName: string, frame: number): ResolvedFrame;

  // Batch
  playThrough(onFrame: (state: PlaybackState) => void): void;
}

Behavior details:

  • Constructor: Defaults to the first state in the document if initialState is omitted. Throws if the document has no states or the requested state does not exist.
  • tick(): Returns the resolved frame at the current position, then advances the internal frame counter. Clamps at duration - 1 (does not advance past the last frame).
  • transition(): Records the transition in history, switches to the target state, and resets the frame counter (or sets it to the provided startFrame).
  • seek(): Jumps to a frame, clamped to [0, duration - 1].
  • reset(): Resets the frame to 0. Optionally switches to a different state.
  • resolveAt(): Resolves a frame without advancing the internal counter -- useful for previews and scrubbing.
  • playThrough(): Resets to frame 0, then calls the callback on every frame from 0 through duration - 1.
import { StateMachine } from "@atelier/core";

const machine = new StateMachine(doc, "intro");

// Frame-by-frame playback
while (!machine.isComplete) {
  const { frame, resolved } = machine.tick();
  render(resolved);
}

// Transition to next state
machine.transition("main");

// Play entire state with callback
machine.playThrough(({ frame, resolved, isComplete }) => {
  render(resolved);
  if (isComplete) console.log("Done with", machine.state);
});

9. Template Resolver

src/templates/template-resolver.ts -- Instantiates parameterized document templates into concrete documents.

interface TemplateBindings {
  [variableName: string]: unknown;
}

interface TemplateError {
  variable: string;
  message: string;
}

type TemplateResult =
  | { success: true; document: AtelierDocument }
  | { success: false; errors: TemplateError[] };

function instantiateTemplate(template: AtelierDocument, bindings: TemplateBindings): TemplateResult;
function findTemplateVariables(doc: AtelierDocument): string[];

instantiateTemplate() algorithm:

  1. Validate required variables -- any variable without a default must have a binding. Errors are collected, not thrown.
  2. Reject unknown bindings -- binding keys that do not match any declared variable produce errors.
  3. Merge with defaults -- bindings override defaults; defaults fill in where bindings are absent.
  4. Deep clone and substitute -- walks the entire document tree, replacing {{variableName}} patterns.
  5. Type preservation -- if an entire string value is exactly {{var}}, the raw bound value is returned (a number stays a number, not a string).
  6. Remove variables section -- the output document has no variables key (it is no longer a template).

findTemplateVariables() scans the entire document for {{variableName}} patterns and returns a deduplicated list of variable names found.

import { instantiateTemplate, findTemplateVariables } from "@atelier/core";

// Discover what a template needs
const vars = findTemplateVariables(templateDoc);
// ["brandColor", "headline", "duration"]

// Instantiate with bindings
const result = instantiateTemplate(templateDoc, {
  brandColor: "#ff6600",
  headline: "Launch Day",
  duration: 90,
});

if (result.success) {
  const doc = result.document;
  // doc has no `variables` section, all {{var}} patterns are resolved
} else {
  for (const err of result.errors) {
    console.error(`${err.variable}: ${err.message}`);
  }
}

Usage Examples

Complete workflow: Build, Resolve, Play

import {
  createDocument,
  resolveFrame,
  StateMachine,
  type PlaybackState,
} from "@atelier/core";

// 1. Build a document
const doc = createDocument("demo", { width: 1920, height: 1080, fps: 30 })
  .description("Two-state animation demo")
  .addLayer({ id: "bg", type: "fill", fill: "#1a1a2e" })
  .addLayer({ id: "circle", type: "shape", shape: "circle", x: 960, y: 540, width: 100, height: 100 })
  .addLayer({ id: "label", type: "text", text: "Hello", x: 960, y: 700, opacity: 0 })

  // State 1: intro (60 frames = 2 seconds at 30fps)
  .addState("intro", { duration: 60 })
  .addDelta("intro", {
    id: "circle-scale",
    layer: "circle",
    property: "width",
    range: [0, 30],
    from: 0,
    to: 100,
    easing: "ease-out",
  })
  .addDelta("intro", {
    id: "label-fade",
    layer: "label",
    property: "opacity",
    range: [20, 50],
    from: 0,
    to: 1,
    easing: "ease-in-out",
  })

  // State 2: outro (45 frames = 1.5 seconds)
  .addState("outro", { duration: 45 })
  .addDelta("outro", {
    id: "circle-shrink",
    layer: "circle",
    property: "width",
    range: [0, 30],
    from: 100,
    to: 0,
    easing: "ease-in",
  })
  .addDelta("outro", {
    id: "label-out",
    layer: "label",
    property: "opacity",
    range: [0, 20],
    from: 1,
    to: 0,
  })
  .build();

// 2. Resolve a single frame
const snapshot = resolveFrame(doc, "intro", 25);
for (const layer of snapshot.layers) {
  console.log(`${layer.id}:`, layer.computedProperties);
}
// circle: { width: 83.33 }   (eased)
// label:  { opacity: 0.25 }  (5 frames into its 30-frame range)

// 3. Multi-state playback with StateMachine
const machine = new StateMachine(doc, "intro");

const allFrames: PlaybackState[] = [];

// Play through intro
machine.playThrough((state) => {
  allFrames.push(state);
});

// Transition to outro
machine.transition("outro");

// Play through outro
machine.playThrough((state) => {
  allFrames.push(state);
});

console.log(`Total frames rendered: ${allFrames.length}`);
console.log(`Transitions: ${machine.history.length}`);
// Transitions: 1 (intro -> outro)

Template workflow

import {
  createDocument,
  instantiateTemplate,
  findTemplateVariables,
} from "@atelier/core";

// Build a template with variables
const template = createDocument("branded-intro", { width: 1920, height: 1080, fps: 30 })
  .variable("brandColor", { type: "string", description: "Primary brand color" })
  .variable("headline", { type: "string", description: "Main headline text" })
  .variable("animDuration", { type: "number", default: 30, description: "Fade duration in frames" })
  .addLayer({ id: "bg", type: "fill", fill: "{{brandColor}}" })
  .addLayer({ id: "title", type: "text", text: "{{headline}}", x: 960, y: 540, opacity: 0 })
  .addState("main", { duration: 60 })
  .addDelta("main", {
    id: "title-fade",
    layer: "title",
    property: "opacity",
    range: [0, 30],  // would use {{animDuration}} in a real scenario
    from: 0,
    to: 1,
  })
  .build();

// Discover variables
const vars = findTemplateVariables(template);
// ["brandColor", "headline"]  (animDuration has a default, but also shows up in scan)

// Instantiate
const result = instantiateTemplate(template, {
  brandColor: "#e94560",
  headline: "Welcome",
});

if (result.success) {
  // result.document is a concrete AtelierDocument with no variables section
  console.log(result.document.layers[0].fill); // "#e94560"
}

Preset expansion

import { createDocument, expandPreset } from "@atelier/core";

const fadeInPreset = {
  name: "fade-in",
  deltas: [
    { property: "opacity", from: 0, to: 1, offset: [0, 20] as [number, number] },
    { property: "y", from: 20, to: 0, offset: [0, 15] as [number, number] },
  ],
};

// Expand preset into deltas starting at frame 10
const deltas = expandPreset(fadeInPreset, "title", 10, 30);
// [
//   { id: "preset-title-0", layer: "title", property: "opacity", range: [10, 30], from: 0, to: 1 },
//   { id: "preset-title-1", layer: "title", property: "y", range: [10, 25], from: 20, to: 0 },
// ]

// Feed expanded deltas into a builder
const builder = createDocument("preset-demo", { width: 1920, height: 1080, fps: 30 })
  .addLayer({ id: "title", type: "text", text: "Hi", x: 960, y: 540 })
  .addState("main", { duration: 60 });

for (const delta of deltas) {
  builder.addDelta("main", delta);
}

const doc = builder.build();

Architecture

@atelier/core
  src/
    index.ts                          -- barrel exports
    resolver/
      frame-resolver.ts               -- resolveFrame (main entry)
      delta-resolver.ts               -- per-property interpolation + hold logic
      easing-resolver.ts              -- Easing type -> math function
    validation/
      overlap-validator.ts            -- delta overlap detection
    builder/
      document-builder.ts             -- fluent DocumentBuilder + createDocument
    units/
      resolve-units.ts                -- UnitValue -> pixel conversion
    presets/
      preset-resolver.ts              -- Preset -> Delta[] expansion
    state/
      state-machine.ts                -- StateMachine playback controller
    templates/
      template-resolver.ts            -- template instantiation + variable scanning

Scripts

pnpm --filter @atelier/core build      # Build with tsup (ESM + CJS + DTS)
pnpm --filter @atelier/core test       # Run tests with vitest
pnpm --filter @atelier/core typecheck  # Type-check without emitting
pnpm --filter @atelier/core clean      # Remove dist/

License

See the repository root for license information.