Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions burns/easing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
79 changes: 79 additions & 0 deletions tests/test_an_contract.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions ts/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 38 additions & 6 deletions ts/src/easing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, [number, number, number, number]> = {
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<string, Bezier> = {
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';
Expand Down
50 changes: 50 additions & 0 deletions ts/test/an-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
});
3 changes: 3 additions & 0 deletions ts/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
Loading