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'], });