From 9247a324e4711ab4f485bf7c3c55d2f0a177fd6e Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:31:07 +0000 Subject: [PATCH] Easing: take the CSS curves from an's shared easing contract (#23) The TypeScript port reads its named curves from @thorwhalen/an-contract (easing.json: css_hyphenated_curves plus the CSS 'ease' import alias), inlined at build time so the published package has no runtime JSON import. Tests in both languages assert burns' easings against the contract's sample values (agreement to a few 1e-12: the contract's solver stops at 1e-12). The Python table stays in burns (no dependency on an) and is pinned to the contract by the test. Golden vectors unchanged. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- burns/easing.py | 3 ++ tests/test_an_contract.py | 79 +++++++++++++++++++++++++++++++++++++ ts/package.json | 1 + ts/pnpm-lock.yaml | 8 ++++ ts/src/easing.ts | 44 ++++++++++++++++++--- ts/test/an-contract.test.ts | 50 +++++++++++++++++++++++ ts/tsup.config.ts | 3 ++ 8 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 tests/test_an_contract.py create mode 100644 ts/test/an-contract.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6188d96..100daeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## Part of the `an` framework -burns is part of the `an` structured-animation framework (https://github.com/thorwhalen/an) and stays its own package. The direction and the open question on where the shared vocabulary lives are in https://github.com/thorwhalen/an/issues/257; this repo's issues are #23 (take the CSS easing curves from `an`'s `an/data/timing/easing.json` contract) and #24 (a state-driven Engine for `an` shots, and shared camera moves). In short: +burns is part of the `an` structured-animation framework (https://github.com/thorwhalen/an) and stays its own package. The direction and the open question on where the shared vocabulary lives are in https://github.com/thorwhalen/an/issues/257; the easing curves are the CSS entries of `an`'s easing contract (#23: the TypeScript port reads them from the npm package `@thorwhalen/an-contract`, and both languages' tests assert the contract's sample values), and this repo's open issue is #24 (a state-driven Engine for `an` shots, and shared camera moves). In short: - A camera move is a move of a view through a parameter space. burns realises it as a **crop over pixels**: `push_in` shrinks the crop, so resolution is lost. `an`'s stage engine instead re-renders the scene into the rectangle (a zoom with no resolution loss), and `previz` (https://github.com/thorwhalen/previz, TypeScript) moves an n-dimensional parameter vector (an orbit camera, a chart's axis domain). Same names, same meaning, per-engine lowering. - burns' eight camera moves are meant to become entries in `an`'s vocabulary, next to `an`'s nine, in one table. Until that table exists, a change to a move's name or magnitude here should be checked against `an`'s moves (`push_in` exists in both, with different magnitudes today). diff --git a/burns/easing.py b/burns/easing.py index 0889859..574fd64 100644 --- a/burns/easing.py +++ b/burns/easing.py @@ -26,6 +26,9 @@ # Canonical CSS cubic-bezier control points (verbatim from CSS Timing # Functions Level 1). Each maps a named easing to its (x1, y1, x2, y2). +# These are the CSS entries of `an`'s shared easing contract (`easing.json`, +# on npm as `@thorwhalen/an-contract`), which the TypeScript port reads +# directly; `tests/test_an_contract.py` pins this table and its values to it. CSS_BEZIERS: dict[str, tuple[float, float, float, float]] = { "linear": (0.0, 0.0, 1.0, 1.0), "ease": (0.25, 0.1, 0.25, 1.0), diff --git a/tests/test_an_contract.py b/tests/test_an_contract.py new file mode 100644 index 0000000..bc1b654 --- /dev/null +++ b/tests/test_an_contract.py @@ -0,0 +1,79 @@ +"""Easing conformance against ``an``'s shared easing contract (burns#23). + +``an`` publishes every named easing, with sample values, as +``an/data/timing/easing.json`` (on npm as ``@thorwhalen/an-contract``, an ADR +0006). burns' named easings are its CSS curves: the TypeScript port reads them +from the npm package, and this test pins the Python table and solver to the +same file. + +burns does not depend on ``an``, so the file is found where it already is: the +``an`` package if it is installed, else the npm package under ``ts/`` once +``pnpm install`` has run there; the test skips when neither is present. The +cross-language golden vectors (``test_golden.py``) tie the Python to the +TypeScript, which asserts the contract in every CI run. +""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +from burns.easing import CSS_BEZIERS, parse_easing + +# The contract's CSS solver stops at |x(s) - t| < 1e-12; burns bisects to +# ~2^-60, so the two agree to a few 1e-12, never exactly. +TOLERANCE = 1e-11 + +_NPM_CONTRACT = ( + Path(__file__).parent.parent + / "ts" + / "node_modules" + / "@thorwhalen" + / "an-contract" + / "easing.json" +) + + +def _contract_path(): + """The first easing contract file found, or ``None``.""" + spec = importlib.util.find_spec("an") # locates, does not import, ``an`` + if spec is not None and spec.submodule_search_locations: + for root in spec.submodule_search_locations: + candidate = Path(root) / "data" / "timing" / "easing.json" + if candidate.is_file(): + return candidate + return _NPM_CONTRACT if _NPM_CONTRACT.is_file() else None + + +@pytest.fixture(scope="module") +def contract(): + path = _contract_path() + if path is None: + pytest.skip("an's easing contract is not installed (an, or ts/node_modules)") + return json.loads(path.read_text()) + + +def test_table_is_the_contracts_css_curves(contract): + for name, points in contract["css_hyphenated_curves"].items(): + assert CSS_BEZIERS[name] == tuple(points), name + for name, spec in contract["css_import_aliases"].items(): + assert parse_easing(spec)(0.3) == parse_easing(name)(0.3), name + + +def test_easings_match_the_contracts_samples(contract): + served = [ + e + for e in contract["entries"] + if e["name"] in CSS_BEZIERS and (e["family"] == "css" or e["name"] == "linear") + ] + assert sorted(e["name"] for e in served) == [ + "ease-in", + "ease-in-out", + "ease-out", + "linear", + ] + for entry in served: + ease = parse_easing(entry["name"]) + for u, expected in zip(contract["sample_u"], entry["samples"]): + assert abs(ease(u) - expected) < TOLERANCE, (entry["name"], u) diff --git a/ts/package.json b/ts/package.json index c51e9fd..6b99376 100644 --- a/ts/package.json +++ b/ts/package.json @@ -55,6 +55,7 @@ ], "devDependencies": { "@playwright/test": "^1.49.0", + "@thorwhalen/an-contract": "^1.8.0", "@types/dom-webcodecs": "^0.1.11", "@types/node": "^25.9.1", "tsup": "^8.0.0", diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index f4e04f6..cfebed4 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: '@playwright/test': specifier: ^1.49.0 version: 1.60.0 + '@thorwhalen/an-contract': + specifier: ^1.8.0 + version: 1.8.0 '@types/dom-webcodecs': specifier: ^0.1.11 version: 0.1.18 @@ -675,6 +678,9 @@ packages: cpu: [x64] os: [win32] + '@thorwhalen/an-contract@1.8.0': + resolution: {integrity: sha512-4+fU/0b7aDyIGsv+EWpsXH7D0XAvF2FhYsLpEMmQG3NhlypUgYMUrFFyEmrQfqsXiPXvbCXMHuBX2rErQ0rMhQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1488,6 +1494,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@thorwhalen/an-contract@1.8.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/ts/src/easing.ts b/ts/src/easing.ts index 620b46d..a0617c9 100644 --- a/ts/src/easing.ts +++ b/ts/src/easing.ts @@ -8,24 +8,56 @@ * understood verbatim by CSS, Remotion, and GSAP. The cinematic default is * `"ease-in-out"`. * + * The named curves are not defined here: they are the CSS entries of `an`'s + * shared easing contract (`@thorwhalen/an-contract`, `easing.json`), its + * `css_hyphenated_curves` plus its `css_import_aliases` (CSS `ease`), bundled + * at build time. `test/an-contract.test.ts` asserts the curves against the + * contract's sample values. + * * The cubic-bezier solver uses the **same 60-iteration bisection** as the * Python reference, so the eased values are reproducible to ~2^-60 and the * golden vectors match across languages. */ +import { + css_hyphenated_curves as CONTRACT_CSS_CURVES, + css_import_aliases as CONTRACT_CSS_ALIASES, +} from '@thorwhalen/an-contract/easing.json'; + export type EasingFn = (t: number) => number; export type EasingLike = | string | EasingFn | readonly [number, number, number, number]; -/** Canonical CSS cubic-bezier control points (verbatim from CSS Timing L1). */ -export const CSS_BEZIERS: Record = { +type Bezier = [number, number, number, number]; + +function asBezier(points: readonly number[]): Bezier { + if (points.length !== 4) { + throw new Error(`easing contract: expected 4 control values, got ${points}`); + } + return [...points] as Bezier; +} + +/** + * CSS cubic-bezier control points by name: `linear`, plus every CSS curve of + * `an`'s easing contract (`ease-in`, `ease-out`, `ease-in-out`, and CSS `ease` + * through the contract's import alias). + */ +export const CSS_BEZIERS: Record = { linear: [0, 0, 1, 1], - ease: [0.25, 0.1, 0.25, 1], - 'ease-in': [0.42, 0, 1, 1], - 'ease-out': [0, 0, 0.58, 1], - 'ease-in-out': [0.42, 0, 0.58, 1], + ...Object.fromEntries( + Object.entries(CONTRACT_CSS_ALIASES).map(([name, spec]) => [ + name, + parseBezierArgs(spec), + ]), + ), + ...Object.fromEntries( + Object.entries(CONTRACT_CSS_CURVES).map(([name, points]) => [ + name, + asBezier(points), + ]), + ), }; export const DFLT_EASING = 'ease-in-out'; diff --git a/ts/test/an-contract.test.ts b/ts/test/an-contract.test.ts new file mode 100644 index 0000000..7e02e02 --- /dev/null +++ b/ts/test/an-contract.test.ts @@ -0,0 +1,50 @@ +/** + * Easing conformance against `an`'s shared contract (`@thorwhalen/an-contract`, + * `easing.json`; burns#23, an ADR 0006). + * + * burns' named easings ARE the contract's CSS curves (`src/easing.ts` reads + * them from the package). This test pins the other half: that burns' solver + * evaluates them to the contract's sample values. The contract's CSS solver + * stops at |x(s) - t| < 1e-12 while burns bisects to ~2^-60, so the two agree + * to a few 1e-12, never exactly; `TOLERANCE` leaves room for that and nothing + * more. + */ + +import { describe, expect, it } from 'vitest'; +import contract from '@thorwhalen/an-contract/easing.json'; +import { CSS_BEZIERS, parseEasing } from '../src/easing'; + +const TOLERANCE = 1e-11; + +/** The contract entries burns serves: `linear` plus every CSS cubic-bezier. */ +const served = contract.entries.filter( + (e) => e.name in CSS_BEZIERS && (e.family === 'css' || e.name === 'linear'), +); + +describe('easing curves come from the an contract', () => { + it('serves every CSS cubic-bezier curve of the contract', () => { + for (const [name, points] of Object.entries(contract.css_hyphenated_curves)) { + expect(CSS_BEZIERS[name]).toEqual(points); + } + }); + + it("reads CSS 'ease' through the contract's import alias", () => { + expect(contract.css_import_aliases.ease).toBe('cubic-bezier(0.25, 0.1, 0.25, 1)'); + expect(CSS_BEZIERS.ease).toEqual([0.25, 0.1, 0.25, 1]); + }); + + it('checks at least linear and the three CSS curves', () => { + expect(served.map((e) => e.name).sort()).toEqual( + ['ease-in', 'ease-in-out', 'ease-out', 'linear'], + ); + }); + + for (const entry of served) { + it(`${entry.name} matches the contract's samples`, () => { + const ease = parseEasing(entry.name); + contract.sample_u.forEach((u, i) => { + expect(Math.abs(ease(u) - entry.samples[i]!)).toBeLessThan(TOLERANCE); + }); + }); + } +}); diff --git a/ts/tsup.config.ts b/ts/tsup.config.ts index 62d0fc0..a800368 100644 --- a/ts/tsup.config.ts +++ b/ts/tsup.config.ts @@ -11,4 +11,7 @@ export default defineConfig({ clean: true, sourcemap: true, treeshake: true, + // The easing table is read from `an`'s contract at build time and inlined, + // so the published package carries no runtime JSON import. + noExternal: ['@thorwhalen/an-contract'], });