{"_id":"@a-company/atelier-core","_rev":"4-d812a3a8ee71fb1824042adc46552309","name":"@a-company/atelier-core","dist-tags":{"latest":"0.26.0"},"versions":{"0.25.1":{"name":"@a-company/atelier-core","version":"0.25.1","_id":"@a-company/atelier-core@0.25.1","maintainers":[{"name":"ascend42","email":"ascend@a-company.org"}],"dist":{"shasum":"a14056d7166da59a1fc87ae1553e7fa8b43635ad","tarball":"https://registry.npmjs.org/@a-company/atelier-core/-/atelier-core-0.25.1.tgz","fileCount":8,"integrity":"sha512-NqrR9kfsII9B+DWz8SV4lJfaVrVHvwQFrJECPfIQZXbjzVcBvDsSVYXZhY4edrX7r1bFuX37Ws2kjzj2kLjzeg==","signatures":[{"sig":"MEUCIQDpPwHLAEuI5vqP/Ejt2M0xxdJzMAQNjaJMc2G/a8C2kQIgJVz4CrRr+3HIB5T0f8Q2cu/Q2Lsid77EXyhqHd7aXmk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":247622},"main":"./dist/index.cjs","type":"module","_from":"file:a-company-atelier-core-0.25.1.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"scripts":{"test":"vitest run","build":"tsup","clean":"rm -rf dist","typecheck":"tsc --noEmit"},"_npmUser":{"name":"ascend42","email":"ascend@a-company.org"},"_resolved":"/private/var/folders/gq/nt2kdpsj39dc8vpq6cfzx8gw0000gn/T/6cd77e264626e368c78c586ef7f8f226/a-company-atelier-core-0.25.1.tgz","_integrity":"sha512-NqrR9kfsII9B+DWz8SV4lJfaVrVHvwQFrJECPfIQZXbjzVcBvDsSVYXZhY4edrX7r1bFuX37Ws2kjzj2kLjzeg==","_npmVersion":"11.7.0","description":"Animation engine — delta resolution, builder API, state machine","directories":{},"_nodeVersion":"24.12.0","dependencies":{"@a-company/atelier-math":"0.25.1","@a-company/atelier-types":"0.25.1","@a-company/atelier-schema":"0.25.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.4.0","vitest":"^3.0.0","typescript":"^5.7.0"},"_npmOperationalInternal":{"tmp":"tmp/atelier-core_0.25.1_1771887814363_0.642061091495743","host":"s3://npm-registry-packages-npm-production"}},"0.25.2":{"name":"@a-company/atelier-core","version":"0.25.2","_id":"@a-company/atelier-core@0.25.2","maintainers":[{"name":"ascend42","email":"ascend@a-company.org"}],"dist":{"shasum":"8c271fefd1552c59ec5fb22aa228df03d7c27477","tarball":"https://registry.npmjs.org/@a-company/atelier-core/-/atelier-core-0.25.2.tgz","fileCount":8,"integrity":"sha512-36URIcF9cDDPCYlqm4YAw4TUJYWxJPhQAt4/UtFGSVleaP6gBxax6c4LAFbg6kDb6ju2+2i5nUZtcCIa+mEQgA==","signatures":[{"sig":"MEYCIQCdZ/aNT5FVnihNjP7jd7T8VFSBPI44Q3obR+NDEY6PjAIhAPqw6zzaF4n2cZPjyYBeBz3dao/+zPY+z1B5zlkT4ZhX","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":248452},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"gitHead":"a014974918c3dd3907ef5e1ee3ef85bc3a227d31","scripts":{"test":"vitest run","build":"tsup","clean":"rm -rf dist","typecheck":"tsc --noEmit"},"_npmUser":{"name":"ascend42","email":"ascend@a-company.org"},"_npmVersion":"11.7.0","description":"Animation engine — delta resolution, builder API, state machine","directories":{},"_nodeVersion":"24.12.0","dependencies":{"@a-company/atelier-math":"workspace:*","@a-company/atelier-types":"workspace:*","@a-company/atelier-schema":"workspace:*"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.4.0","vitest":"^3.0.0","typescript":"^5.7.0"},"_npmOperationalInternal":{"tmp":"tmp/atelier-core_0.25.2_1772560089634_0.675469466600054","host":"s3://npm-registry-packages-npm-production"}},"0.25.3":{"name":"@a-company/atelier-core","version":"0.25.3","_id":"@a-company/atelier-core@0.25.3","maintainers":[{"name":"ascend42","email":"ascend@a-company.org"}],"dist":{"shasum":"c92044f2d720ba2fd00843e7d37be23503511512","tarball":"https://registry.npmjs.org/@a-company/atelier-core/-/atelier-core-0.25.3.tgz","fileCount":8,"integrity":"sha512-ZRuBggBonwio7D4uvJe4A0FBbjrf9Gid+C4hidxM6E5oRKxgqmJ2qcoczWRXyEr0sfQQtReQchR7Dg512ptQ0Q==","signatures":[{"sig":"MEQCIDaLGBj8G24M3IVBFgBchWs9zUz6hUx17c1CV9b+jEkYAiBbMmxyvkSF3XxYMf9gaNYbCz9QbRJZgIOkIhz/p64Cnw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":248436},"main":"./dist/index.cjs","type":"module","_from":"file:a-company-atelier-core-0.25.3.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"scripts":{"test":"vitest run","build":"tsup","clean":"rm -rf dist","typecheck":"tsc --noEmit"},"_npmUser":{"name":"ascend42","email":"ascend@a-company.org"},"_resolved":"/private/var/folders/gq/nt2kdpsj39dc8vpq6cfzx8gw0000gn/T/69a346731f1fd49f49bc5e462c0924e9/a-company-atelier-core-0.25.3.tgz","_integrity":"sha512-ZRuBggBonwio7D4uvJe4A0FBbjrf9Gid+C4hidxM6E5oRKxgqmJ2qcoczWRXyEr0sfQQtReQchR7Dg512ptQ0Q==","_npmVersion":"11.7.0","description":"Animation engine — delta resolution, builder API, state machine","directories":{},"_nodeVersion":"24.12.0","dependencies":{"@a-company/atelier-math":"0.25.1","@a-company/atelier-types":"0.25.3","@a-company/atelier-schema":"0.25.3"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.4.0","vitest":"^3.0.0","typescript":"^5.7.0"},"_npmOperationalInternal":{"tmp":"tmp/atelier-core_0.25.3_1772568034267_0.19350680826459699","host":"s3://npm-registry-packages-npm-production"}},"0.26.0":{"name":"@a-company/atelier-core","version":"0.26.0","publishConfig":{"access":"public"},"description":"Animation engine — delta resolution, builder API, state machine","type":"module","main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"}},"dependencies":{"@a-company/atelier-types":"0.26.0","@a-company/atelier-schema":"0.26.0","@a-company/atelier-math":"0.25.1"},"devDependencies":{"tsup":"^8.4.0","typescript":"^5.7.0","vitest":"^3.0.0"},"scripts":{"build":"tsup","typecheck":"tsc --noEmit","test":"vitest run","clean":"rm -rf dist"},"_id":"@a-company/atelier-core@0.26.0","_integrity":"sha512-ea7bigsNTaWWKMOq1cH3mV/gCE15l1fgZ/Q8aCbwaXz6Lv9IoITMa3MHP7k5yT0aKkFEAVlmmBSWdvNDPKt2uA==","_resolved":"/private/var/folders/gq/nt2kdpsj39dc8vpq6cfzx8gw0000gn/T/3b09d52c77f0b7c06c2cbd8668525a8e/a-company-atelier-core-0.26.0.tgz","_from":"file:a-company-atelier-core-0.26.0.tgz","_nodeVersion":"24.12.0","_npmVersion":"11.7.0","dist":{"integrity":"sha512-ea7bigsNTaWWKMOq1cH3mV/gCE15l1fgZ/Q8aCbwaXz6Lv9IoITMa3MHP7k5yT0aKkFEAVlmmBSWdvNDPKt2uA==","shasum":"1f6cdf2e02ea2d61d3ca0069580fb3194434a101","tarball":"https://registry.npmjs.org/@a-company/atelier-core/-/atelier-core-0.26.0.tgz","fileCount":8,"unpackedSize":251912,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIEef07sNZKPh2CJQDJuYRQ7Hf5v9oRKrcfvZKD/Uiy49AiBCBgl56AUx3B8EsYhfdoj+mFdbVnoyyfDP/8/yXtYelw=="}]},"_npmUser":{"name":"ascend42","email":"ascend@a-company.org"},"directories":{},"maintainers":[{"name":"ascend42","email":"ascend@a-company.org"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/atelier-core_0.26.0_1778200995363_0.06778940612605378"},"_hasShrinkwrap":false}},"time":{"created":"2026-02-23T23:03:34.285Z","modified":"2026-05-08T00:43:15.657Z","0.25.1":"2026-02-23T23:03:34.531Z","0.25.2":"2026-03-03T17:48:09.794Z","0.25.3":"2026-03-03T20:00:34.399Z","0.26.0":"2026-05-08T00:43:15.505Z"},"description":"Animation engine — delta resolution, builder API, state machine","maintainers":[{"name":"ascend42","email":"ascend@a-company.org"}],"readme":"---\ntitle: \"@atelier/core\"\nscope: Animation engine — frame resolver, DocumentBuilder, StateMachine, templates, presets\npackages: [\"@atelier/core\"]\nrelated: [\"docs/format-spec.md\", \"docs/architecture.md\", \"docs/builder-guide.md\", \"docs/state-machine-guide.md\", \"docs/rendering-pipeline.md\"]\n---\n\n# @atelier/core\n\nAnimation 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.\n\n## Package Info\n\n| Field | Value |\n|-------|-------|\n| **Name** | `@atelier/core` |\n| **Version** | `0.1.0` |\n| **Description** | Animation engine -- delta resolution, builder API, state machine |\n| **Source** | `packages/core/src/` |\n| **Build** | tsup (ESM + CJS + DTS, with sourcemaps) |\n| **Test** | vitest |\n\n### Dependencies\n\nAll workspace packages:\n\n- `@atelier/types` -- type definitions for documents, layers, deltas, easings\n- `@atelier/schema` -- Zod validation schemas\n- `@atelier/math` -- interpolation, easing curves (lerp, cubicBezier, spring, etc.)\n\n## Installation\n\n```bash\npnpm add @atelier/core\n```\n\nOr from the monorepo root:\n\n```bash\npnpm --filter @atelier/core build\n```\n\n## Exports\n\nEverything is exported from a single entry point:\n\n```typescript\nimport {\n  // Frame resolution\n  resolveFrame,\n  resolveDeltaValue,\n  resolvePropertyAtFrame,\n  isFrameInRange,\n  computeProgress,\n  interpolateValue,\n  resolveEasing,\n\n  // Validation\n  validateNoOverlap,\n  validateAllDeltas,\n  rangesOverlap,\n\n  // Builder\n  DocumentBuilder,\n  createDocument,\n\n  // Units\n  resolveUnit,\n  isPercentage,\n  parsePercentage,\n\n  // Presets\n  expandPreset,\n\n  // State machine\n  StateMachine,\n\n  // Templates\n  instantiateTemplate,\n  findTemplateVariables,\n} from \"@atelier/core\";\n\n// Types\nimport type {\n  ResolvedFrame,\n  ResolvedLayer,\n  OverlapError,\n  StateTransition,\n  PlaybackState,\n  TemplateBindings,\n  TemplateError,\n  TemplateResult,\n} from \"@atelier/core\";\n```\n\n---\n\n## Modules\n\n### 1. Frame Resolver\n\n**`src/resolver/frame-resolver.ts`** -- Main entry point for frame resolution.\n\nGiven an `AtelierDocument`, a state name, and a frame number, resolves every layer's animated properties at that instant.\n\n```typescript\nfunction resolveFrame(doc: AtelierDocument, stateName: string, frame: number): ResolvedFrame;\n```\n\n**Interfaces:**\n\n```typescript\ninterface ResolvedLayer {\n  id: string;\n  layer: Layer;                                              // original layer definition\n  computedProperties: Partial<Record<AnimatableProperty, unknown>>; // animated overrides\n}\n\ninterface ResolvedFrame {\n  frame: number;       // frame number that was resolved\n  stateName: string;   // state name that was resolved\n  layers: ResolvedLayer[];\n}\n```\n\n**Algorithm:**\n\n1. Look up the state by name (throws if not found).\n2. Group all deltas by `layerId`, then by `property` name.\n3. For each layer, for each animated property, call `resolvePropertyAtFrame()`.\n4. Return a `ResolvedFrame` containing all layers with their computed property values.\n\n**Example:**\n\n```typescript\nimport { resolveFrame } from \"@atelier/core\";\n\nconst result = resolveFrame(doc, \"intro\", 15);\n\nfor (const layer of result.layers) {\n  console.log(layer.id, layer.computedProperties);\n  // \"title\" { opacity: 0.75, y: 120 }\n}\n```\n\n---\n\n### 2. Delta Resolver\n\n**`src/resolver/delta-resolver.ts`** -- Per-property interpolation logic.\n\n```typescript\nfunction isFrameInRange(frame: number, range: FrameRange): boolean;\nfunction computeProgress(frame: number, range: FrameRange): number;\nfunction resolveDeltaValue(delta: Delta, frame: number): unknown | undefined;\nfunction interpolateValue(from: unknown, to: unknown, t: number): unknown;\nfunction resolvePropertyAtFrame(deltas: Delta[], frame: number): unknown | undefined;\n```\n\n**Key behaviors:**\n\n| Scenario | Result |\n|----------|--------|\n| Frame is within a delta's range | Interpolated value using eased progress |\n| Frame is AFTER all deltas | **Hold** the `to` value of the most recently completed delta |\n| Frame is BEFORE all deltas | `undefined` (layer defaults apply) |\n| Instantaneous delta (start === end) | Progress = 1, returns `to` value |\n\n**Interpolation rules:**\n\n- **Numbers**: Linearly interpolated via `lerp(from, to, t)`.\n- **Strings** (hex colors, labels, etc.): Snap to `to` at `t >= 1`, otherwise hold `from`.\n- **Other types**: Snap at `t >= 1`.\n\n**Value hold behavior:**\n\nAfter 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.\n\n```typescript\nimport { resolvePropertyAtFrame } from \"@atelier/core\";\n\n// Two sequential deltas for opacity on the same layer:\n// Delta A: frames [0, 10], from: 0, to: 1\n// Delta B: frames [20, 30], from: 1, to: 0\n\nresolvePropertyAtFrame(deltas, 5);   // 0.5  (mid-interpolation of A)\nresolvePropertyAtFrame(deltas, 10);  // 1    (end of A)\nresolvePropertyAtFrame(deltas, 15);  // 1    (hold A's `to` value)\nresolvePropertyAtFrame(deltas, 25);  // 0.5  (mid-interpolation of B)\nresolvePropertyAtFrame(deltas, 35);  // 0    (hold B's `to` value)\n```\n\n---\n\n### 3. Easing Resolver\n\n**`src/resolver/easing-resolver.ts`** -- Bridges `Easing` type definitions to executable math functions.\n\n```typescript\nfunction resolveEasing(easing: Easing | undefined): (t: number) => number;\n```\n\n**Supported easings:**\n\n| Easing Type | Source |\n|-------------|--------|\n| `undefined` / omitted | `linear` |\n| `\"ease-in\"` | `easeIn` preset |\n| `\"ease-out\"` | `easeOut` preset |\n| `\"ease-in-out\"` | `easeInOut` preset |\n| `{ type: \"linear\" }` | `linear` |\n| `{ type: \"cubic-bezier\", x1, y1, x2, y2 }` | `cubicBezier(x1, y1, x2, y2)` |\n| `{ type: \"spring\", mass, stiffness, damping, velocity }` | `spring({ ... })` |\n| `{ type: \"step\", steps, position }` | `step(steps, position)` |\n\nAll underlying functions are provided by `@atelier/math`.\n\n---\n\n### 4. Document Builder\n\n**`src/builder/document-builder.ts`** -- Fluent API for constructing valid `AtelierDocument` objects.\n\n```typescript\nclass DocumentBuilder {\n  constructor(name: string, canvas: Canvas);\n\n  description(desc: string): this;\n  tags(...tags: string[]): this;\n  variable(id: string, variable: Variable): this;\n  asset(id: string, asset: Asset): this;\n  preset(id: string, preset: Preset): this;\n\n  addLayer(layer: Layer): this;\n  addState(name: string, state: Omit<State, \"deltas\"> & { deltas?: Delta[] }): this;\n  addDelta(stateName: string, delta: Delta): this;\n\n  build(): AtelierDocument;\n}\n\nfunction createDocument(name: string, canvas: Canvas): DocumentBuilder;\n```\n\n**Validations performed on every mutation:**\n\n| Method | Validation |\n|--------|-----------|\n| `addLayer()` | Rejects duplicate layer IDs |\n| `addState()` | Rejects duplicate state names |\n| `addDelta()` | Checks that the referenced layer exists |\n| `addDelta()` | Runs `validateNoOverlap()` against existing deltas |\n\n`build()` returns a deep clone (via `JSON.parse(JSON.stringify(...))`) so the builder's internal state is not shared.\n\n**Example:**\n\n```typescript\nimport { createDocument } from \"@atelier/core\";\n\nconst doc = createDocument(\"fade-in\", { width: 1920, height: 1080, fps: 30 })\n  .description(\"Simple fade-in animation\")\n  .tags(\"intro\", \"fade\")\n  .addLayer({ id: \"bg\", type: \"fill\", fill: \"#000000\" })\n  .addLayer({ id: \"title\", type: \"text\", text: \"Hello\", x: 960, y: 540 })\n  .addState(\"main\", { duration: 60 })\n  .addDelta(\"main\", {\n    id: \"title-fade\",\n    layer: \"title\",\n    property: \"opacity\",\n    range: [0, 30],\n    from: 0,\n    to: 1,\n    easing: \"ease-out\",\n  })\n  .addDelta(\"main\", {\n    id: \"title-slide\",\n    layer: \"title\",\n    property: \"y\",\n    range: [0, 30],\n    from: 560,\n    to: 540,\n    easing: \"ease-out\",\n  })\n  .build();\n```\n\n---\n\n### 5. Overlap Validator\n\n**`src/validation/overlap-validator.ts`** -- Ensures no two deltas animate the same property on the same layer during overlapping frame ranges.\n\n```typescript\ninterface OverlapError {\n  layerId: string;\n  property: string;\n  existingRange: FrameRange;\n  newRange: FrameRange;\n  message: string;\n}\n\nfunction rangesOverlap(a: FrameRange, b: FrameRange): boolean;\nfunction validateNoOverlap(existing: Delta[], newDelta: Delta): OverlapError | null;\nfunction validateAllDeltas(deltas: Delta[]): OverlapError[];\n```\n\n**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).\n\n- `validateNoOverlap()` checks a single new delta against an existing array (used by `DocumentBuilder.addDelta()`).\n- `validateAllDeltas()` checks all pairs in an array, returning every overlap found (useful for batch validation of imported documents).\n\n---\n\n### 6. Unit Resolver\n\n**`src/units/resolve-units.ts`** -- Converts `UnitValue` (number or percentage string) to pixel values.\n\n```typescript\nfunction isPercentage(value: UnitValue): value is `${number}%`;\nfunction parsePercentage(value: `${number}%`): number;\nfunction resolveUnit(value: UnitValue, reference: number): number;\n```\n\n**Behavior:**\n\n- Numeric values pass through unchanged.\n- Percentage strings (e.g. `\"50%\"`) are resolved relative to the reference dimension.\n\n```typescript\nimport { resolveUnit } from \"@atelier/core\";\n\nresolveUnit(100, 1920);    // 100    (pixel value, unchanged)\nresolveUnit(\"50%\", 1920);  // 960    (50% of 1920)\nresolveUnit(\"100%\", 1080); // 1080   (100% of 1080)\n```\n\n---\n\n### 7. Preset Resolver\n\n**`src/presets/preset-resolver.ts`** -- Expands reusable preset definitions into concrete deltas.\n\n```typescript\nfunction expandPreset(\n  preset: Preset,\n  layerId: string,\n  startFrame: number,\n  duration: number,\n): Delta[];\n```\n\nPresets contain delta templates with relative offsets. `expandPreset()` converts them to absolute frame ranges by:\n\n1. If the preset delta has an `offset` tuple, the range becomes `[startFrame + offset[0], startFrame + offset[1]]`.\n2. If no offset is specified, the range spans the full duration: `[startFrame, startFrame + duration]`.\n\nEach generated delta gets an auto-generated ID in the format `preset-{layerId}-{index}`.\n\n```typescript\nimport { expandPreset } from \"@atelier/core\";\n\nconst fadePreset = {\n  name: \"fade-in\",\n  deltas: [\n    { property: \"opacity\", from: 0, to: 1, offset: [0, 30] },\n  ],\n};\n\nconst deltas = expandPreset(fadePreset, \"title\", 10, 60);\n// [{ id: \"preset-title-0\", layer: \"title\", property: \"opacity\",\n//    range: [10, 40], from: 0, to: 1 }]\n```\n\n---\n\n### 8. State Machine\n\n**`src/state/state-machine.ts`** -- Frame-by-frame playback controller with multi-state transitions.\n\n```typescript\ninterface StateTransition {\n  from: string;\n  to: string;\n  at: number;  // frame at which the transition occurred\n}\n\ninterface PlaybackState {\n  stateName: string;\n  frame: number;\n  resolved: ResolvedFrame;\n  isComplete: boolean;\n}\n\nclass StateMachine {\n  constructor(doc: AtelierDocument, initialState?: string);\n\n  // Accessors\n  get state(): string;\n  get frame(): number;\n  get stateNames(): string[];\n  get duration(): number;\n  get isComplete(): boolean;\n  get history(): ReadonlyArray<StateTransition>;\n\n  // Playback\n  tick(): PlaybackState;\n  transition(stateName: string, startFrame?: number): void;\n  seek(frame: number): void;\n  reset(stateName?: string): void;\n\n  // Query\n  resolveAt(stateName: string, frame: number): ResolvedFrame;\n\n  // Batch\n  playThrough(onFrame: (state: PlaybackState) => void): void;\n}\n```\n\n**Behavior details:**\n\n- **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.\n- **`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).\n- **`transition()`**: Records the transition in `history`, switches to the target state, and resets the frame counter (or sets it to the provided `startFrame`).\n- **`seek()`**: Jumps to a frame, clamped to `[0, duration - 1]`.\n- **`reset()`**: Resets the frame to 0. Optionally switches to a different state.\n- **`resolveAt()`**: Resolves a frame without advancing the internal counter -- useful for previews and scrubbing.\n- **`playThrough()`**: Resets to frame 0, then calls the callback on every frame from 0 through `duration - 1`.\n\n```typescript\nimport { StateMachine } from \"@atelier/core\";\n\nconst machine = new StateMachine(doc, \"intro\");\n\n// Frame-by-frame playback\nwhile (!machine.isComplete) {\n  const { frame, resolved } = machine.tick();\n  render(resolved);\n}\n\n// Transition to next state\nmachine.transition(\"main\");\n\n// Play entire state with callback\nmachine.playThrough(({ frame, resolved, isComplete }) => {\n  render(resolved);\n  if (isComplete) console.log(\"Done with\", machine.state);\n});\n```\n\n---\n\n### 9. Template Resolver\n\n**`src/templates/template-resolver.ts`** -- Instantiates parameterized document templates into concrete documents.\n\n```typescript\ninterface TemplateBindings {\n  [variableName: string]: unknown;\n}\n\ninterface TemplateError {\n  variable: string;\n  message: string;\n}\n\ntype TemplateResult =\n  | { success: true; document: AtelierDocument }\n  | { success: false; errors: TemplateError[] };\n\nfunction instantiateTemplate(template: AtelierDocument, bindings: TemplateBindings): TemplateResult;\nfunction findTemplateVariables(doc: AtelierDocument): string[];\n```\n\n**`instantiateTemplate()` algorithm:**\n\n1. **Validate required variables** -- any variable without a `default` must have a binding. Errors are collected, not thrown.\n2. **Reject unknown bindings** -- binding keys that do not match any declared variable produce errors.\n3. **Merge with defaults** -- bindings override defaults; defaults fill in where bindings are absent.\n4. **Deep clone and substitute** -- walks the entire document tree, replacing `{{variableName}}` patterns.\n5. **Type preservation** -- if an entire string value is exactly `{{var}}`, the raw bound value is returned (a number stays a number, not a string).\n6. **Remove variables section** -- the output document has no `variables` key (it is no longer a template).\n\n**`findTemplateVariables()`** scans the entire document for `{{variableName}}` patterns and returns a deduplicated list of variable names found.\n\n```typescript\nimport { instantiateTemplate, findTemplateVariables } from \"@atelier/core\";\n\n// Discover what a template needs\nconst vars = findTemplateVariables(templateDoc);\n// [\"brandColor\", \"headline\", \"duration\"]\n\n// Instantiate with bindings\nconst result = instantiateTemplate(templateDoc, {\n  brandColor: \"#ff6600\",\n  headline: \"Launch Day\",\n  duration: 90,\n});\n\nif (result.success) {\n  const doc = result.document;\n  // doc has no `variables` section, all {{var}} patterns are resolved\n} else {\n  for (const err of result.errors) {\n    console.error(`${err.variable}: ${err.message}`);\n  }\n}\n```\n\n---\n\n## Usage Examples\n\n### Complete workflow: Build, Resolve, Play\n\n```typescript\nimport {\n  createDocument,\n  resolveFrame,\n  StateMachine,\n  type PlaybackState,\n} from \"@atelier/core\";\n\n// 1. Build a document\nconst doc = createDocument(\"demo\", { width: 1920, height: 1080, fps: 30 })\n  .description(\"Two-state animation demo\")\n  .addLayer({ id: \"bg\", type: \"fill\", fill: \"#1a1a2e\" })\n  .addLayer({ id: \"circle\", type: \"shape\", shape: \"circle\", x: 960, y: 540, width: 100, height: 100 })\n  .addLayer({ id: \"label\", type: \"text\", text: \"Hello\", x: 960, y: 700, opacity: 0 })\n\n  // State 1: intro (60 frames = 2 seconds at 30fps)\n  .addState(\"intro\", { duration: 60 })\n  .addDelta(\"intro\", {\n    id: \"circle-scale\",\n    layer: \"circle\",\n    property: \"width\",\n    range: [0, 30],\n    from: 0,\n    to: 100,\n    easing: \"ease-out\",\n  })\n  .addDelta(\"intro\", {\n    id: \"label-fade\",\n    layer: \"label\",\n    property: \"opacity\",\n    range: [20, 50],\n    from: 0,\n    to: 1,\n    easing: \"ease-in-out\",\n  })\n\n  // State 2: outro (45 frames = 1.5 seconds)\n  .addState(\"outro\", { duration: 45 })\n  .addDelta(\"outro\", {\n    id: \"circle-shrink\",\n    layer: \"circle\",\n    property: \"width\",\n    range: [0, 30],\n    from: 100,\n    to: 0,\n    easing: \"ease-in\",\n  })\n  .addDelta(\"outro\", {\n    id: \"label-out\",\n    layer: \"label\",\n    property: \"opacity\",\n    range: [0, 20],\n    from: 1,\n    to: 0,\n  })\n  .build();\n\n// 2. Resolve a single frame\nconst snapshot = resolveFrame(doc, \"intro\", 25);\nfor (const layer of snapshot.layers) {\n  console.log(`${layer.id}:`, layer.computedProperties);\n}\n// circle: { width: 83.33 }   (eased)\n// label:  { opacity: 0.25 }  (5 frames into its 30-frame range)\n\n// 3. Multi-state playback with StateMachine\nconst machine = new StateMachine(doc, \"intro\");\n\nconst allFrames: PlaybackState[] = [];\n\n// Play through intro\nmachine.playThrough((state) => {\n  allFrames.push(state);\n});\n\n// Transition to outro\nmachine.transition(\"outro\");\n\n// Play through outro\nmachine.playThrough((state) => {\n  allFrames.push(state);\n});\n\nconsole.log(`Total frames rendered: ${allFrames.length}`);\nconsole.log(`Transitions: ${machine.history.length}`);\n// Transitions: 1 (intro -> outro)\n```\n\n### Template workflow\n\n```typescript\nimport {\n  createDocument,\n  instantiateTemplate,\n  findTemplateVariables,\n} from \"@atelier/core\";\n\n// Build a template with variables\nconst template = createDocument(\"branded-intro\", { width: 1920, height: 1080, fps: 30 })\n  .variable(\"brandColor\", { type: \"string\", description: \"Primary brand color\" })\n  .variable(\"headline\", { type: \"string\", description: \"Main headline text\" })\n  .variable(\"animDuration\", { type: \"number\", default: 30, description: \"Fade duration in frames\" })\n  .addLayer({ id: \"bg\", type: \"fill\", fill: \"{{brandColor}}\" })\n  .addLayer({ id: \"title\", type: \"text\", text: \"{{headline}}\", x: 960, y: 540, opacity: 0 })\n  .addState(\"main\", { duration: 60 })\n  .addDelta(\"main\", {\n    id: \"title-fade\",\n    layer: \"title\",\n    property: \"opacity\",\n    range: [0, 30],  // would use {{animDuration}} in a real scenario\n    from: 0,\n    to: 1,\n  })\n  .build();\n\n// Discover variables\nconst vars = findTemplateVariables(template);\n// [\"brandColor\", \"headline\"]  (animDuration has a default, but also shows up in scan)\n\n// Instantiate\nconst result = instantiateTemplate(template, {\n  brandColor: \"#e94560\",\n  headline: \"Welcome\",\n});\n\nif (result.success) {\n  // result.document is a concrete AtelierDocument with no variables section\n  console.log(result.document.layers[0].fill); // \"#e94560\"\n}\n```\n\n### Preset expansion\n\n```typescript\nimport { createDocument, expandPreset } from \"@atelier/core\";\n\nconst fadeInPreset = {\n  name: \"fade-in\",\n  deltas: [\n    { property: \"opacity\", from: 0, to: 1, offset: [0, 20] as [number, number] },\n    { property: \"y\", from: 20, to: 0, offset: [0, 15] as [number, number] },\n  ],\n};\n\n// Expand preset into deltas starting at frame 10\nconst deltas = expandPreset(fadeInPreset, \"title\", 10, 30);\n// [\n//   { id: \"preset-title-0\", layer: \"title\", property: \"opacity\", range: [10, 30], from: 0, to: 1 },\n//   { id: \"preset-title-1\", layer: \"title\", property: \"y\", range: [10, 25], from: 20, to: 0 },\n// ]\n\n// Feed expanded deltas into a builder\nconst builder = createDocument(\"preset-demo\", { width: 1920, height: 1080, fps: 30 })\n  .addLayer({ id: \"title\", type: \"text\", text: \"Hi\", x: 960, y: 540 })\n  .addState(\"main\", { duration: 60 });\n\nfor (const delta of deltas) {\n  builder.addDelta(\"main\", delta);\n}\n\nconst doc = builder.build();\n```\n\n## Architecture\n\n```\n@atelier/core\n  src/\n    index.ts                          -- barrel exports\n    resolver/\n      frame-resolver.ts               -- resolveFrame (main entry)\n      delta-resolver.ts               -- per-property interpolation + hold logic\n      easing-resolver.ts              -- Easing type -> math function\n    validation/\n      overlap-validator.ts            -- delta overlap detection\n    builder/\n      document-builder.ts             -- fluent DocumentBuilder + createDocument\n    units/\n      resolve-units.ts                -- UnitValue -> pixel conversion\n    presets/\n      preset-resolver.ts              -- Preset -> Delta[] expansion\n    state/\n      state-machine.ts                -- StateMachine playback controller\n    templates/\n      template-resolver.ts            -- template instantiation + variable scanning\n```\n\n## Scripts\n\n```bash\npnpm --filter @atelier/core build      # Build with tsup (ESM + CJS + DTS)\npnpm --filter @atelier/core test       # Run tests with vitest\npnpm --filter @atelier/core typecheck  # Type-check without emitting\npnpm --filter @atelier/core clean      # Remove dist/\n```\n\n## License\n\nSee the repository root for license information.\n","readmeFilename":"README.md"}