From ab3cdd4fefd607ebd9d59bfb7c4818e6824b859a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 16:44:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(animate):=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20N-keyframe=20tracks=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B8=D0=BC=20owner/lifecycle=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Базовая keyframe-грамматика поля (Framer/GSAP/anime — everyday-форма по калибровке) в полном ./animate одним lifecycle с прежними контролами: animate('.dot', { x: [0, 120, -40, 0], opacity: [0, 1, 1, 0] }, { duration: 800, times: [0, 0.25, 0.75, 1], ease: [easeOut, linear, easeIn], }) Контракт (#205): - AnimatePropValue: destination | пара [from, to] | кортеж ≥3 (все стопы явные; snapshot до валидации — hostile getters читаются ровно один раз); - options.times: конечные, неубывающие, 0→1; дубликаты легальны — right-biased скачок нулевой ширины; без times offsets равномерные; - options.ease: scalar (применяется К КАЖДОМУ сегменту — для 2-стопового трека совпадает с глобальным) либо массив длины N−1; - при times/ease[] все каналы вызова несут одну authored-топологию N (скрытых эвристик нет) — иначе LM168/LM169 (новые коды каталога); - трек + явная spring — синхронный LM136; трек без опций получает tween-режим по умолчанию (keyframe-грамматика — keyframe-движку); - reduced-motion публикует последний стоп без rAF/WAAPI-резервации; - residual-transform сохраняется; C¹-подхват при перехвате трека: числовые каналы наследуют value+velocity (производная в пространстве значения тем же шагом, что tween-дериватив); CSS-треки — C⁰ (та же лестница деградации, что var()/смешанные AST). Архитектура: один pure-модуль animate/track.ts (right-biased просмотр с переиспользуемым out-scratch — ноль аллокаций в кадре) владеет всей топологией; числовой и CSS-пути делят ОДИН просмотр; кодеки значений не копируются (interpolateParsed/buildTransform как были). 2-стоповые пути бит-в-бит не тронуты (глобальный ease, прежние формулы производной). Приёмка: 21 тест — differential N=3/4/11 против независимого наивного семплера (равномерные/authored/дублированные offsets, числовой и цветовой кодек), zero-width right-bias, multi-target+stagger+onComplete один раз, seek/pause/cancel/reduced, pickup-vs-rest доказательство C¹, residual transform, hostile sparse/getter-массивы, матрицы LM168/LM169/LM136/LM138/ LM141. Полный сьют 3870, Chromium-conformance 48/48. Размер: потолки фасада переставлены ОТ ФАКТА решением владельца (хронология в scripts/size-gate.mjs): one-liner 12000 → 12760 (факт 12694; ~0.7 KB gz — цена everyday-грамматики, дешёвые шейвы сняты до подъёма, дедупа в графе фасада не существует), animate+compositor 12494 → 13340 (факт 13275), новый consumer-ратчет 'animate-keyframes (N-track)' 12725/12760. nano байт-в-байт 1011 B gz. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nxb7WrgKwSSTEJYiEz2zYf --- docs/errors.md | 2 + src/animate/channels.ts | 76 ++++- src/animate/index.ts | 104 ++++++- src/animate/main-unit.ts | 63 +++- src/animate/track.ts | 101 +++++++ src/errors.ts | 2 +- test/animate-keyframes-tracks.test.ts | 420 ++++++++++++++++++++++++++ 7 files changed, 744 insertions(+), 24 deletions(-) create mode 100644 src/animate/track.ts create mode 100644 test/animate-keyframes-tracks.test.ts diff --git a/docs/errors.md b/docs/errors.md index 4584cd37..43c51950 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -184,3 +184,5 @@ | `LM170` | reserved | Код зарезервирован параллельной spring-работой | Не использовать вне назначенного контракта | reserved | | `LM171` | reserved | Код зарезервирован параллельной spring-работой | Не использовать вне назначенного контракта | reserved | | `LM172` | compositor value error | Некорректный абсолютный бюджет ошибки реконструкции | Передать положительный конечный maxValueError в единицах numeric from/to | active | +| `LM173` | animate times | Некорректные times либо несовпадение authored-топологии | Передать неубывающие конечные times от нуля до единицы длиной в число стопов каждого канала | active | +| `LM174` | animate ease segments | Длина ease-массива не равна числу сегментов топологии | Передать по одной функции изинга на каждый сегмент | active | diff --git a/src/animate/channels.ts b/src/animate/channels.ts index 96ce498e..b4dd805f 100644 --- a/src/animate/channels.ts +++ b/src/animate/channels.ts @@ -17,6 +17,7 @@ import { MotionParamError } from '../errors.js'; import { interpolateColor } from '../value/color.js'; import type { ValueAST } from '../value/parse.js'; import { tryParseValue } from '../value/parse.js'; +import { trackProgressAt, type TrackAt } from './track.js'; import { interpolateUnit, type ParsedRelative, @@ -56,9 +57,13 @@ export interface NumericChannelSpec { readonly _kind: 'num'; readonly _key: string; readonly _group: GroupKey; - /** Явный from из пары [from, to]; undefined — резолв из реестра/стиля. */ + /** Явный from из пары/кортежа; undefined — резолв из реестра/стиля. */ readonly _explicitFrom: number | undefined; readonly _to: number; + /** N-keyframe трек (#205): все стопы (кортеж ≥3); undefined — pair/destination. */ + readonly _stops?: readonly number[] | undefined; + /** Offsets трека: authored times либо равномерная сетка (фасад заполняет). */ + _offsets?: readonly number[] | undefined; } /** CSS-канал (цвет/юниты через ./value): физика в прогресс-пространстве [0..1]. */ @@ -68,6 +73,9 @@ export interface CssChannelSpec { readonly _group: GroupKey; readonly _explicitFrom: ValueAST | undefined; readonly _to: ValueAST; + /** N-keyframe трек (#205): все стопы (кортеж ≥3); undefined — pair/destination. */ + readonly _stops?: readonly ValueAST[] | undefined; + _offsets?: readonly number[] | undefined; } export type ChannelSpec = NumericChannelSpec | CssChannelSpec; @@ -108,35 +116,51 @@ export function parseProps(props: Record): ChannelSpec[] { if (key === 'transform') { throw new MotionParamError('LM140'); } - const pair = Array.isArray(raw) ? raw : undefined; - if (pair && pair.length !== 2) { + // Snapshot массива ДО валидации (#205): hostile getters/мутации длины не + // могут изменить набор между проверкой и привязкой (та же дисциплина, что + // collectBoundedArrayLike для целей). + const tuple = Array.isArray(raw) ? [...(raw as unknown[])] : undefined; + if (tuple && tuple.length < 2) { throw new MotionParamError('LM141'); } + // Кортеж ≥3 — N-keyframe трек (#205); ровно 2 — прежняя пара [from, to]. + const stops = tuple && tuple.length > 2 ? tuple : undefined; if (isTransformKey(key) || key === 'opacity') { const group: GroupKey = key === 'opacity' ? 'opacity' : 'transform'; - const explicitFrom = pair ? requireFinite(pair[0]) : undefined; - const to = requireFinite(pair ? pair[1] : raw); + const numericStops = stops?.map(requireFinite); + const explicitFrom = numericStops !== undefined + ? numericStops[0]! + : tuple ? requireFinite(tuple[0]) : undefined; + const to = numericStops !== undefined + ? numericStops[numericStops.length - 1]! + : requireFinite(tuple ? tuple[1] : raw); // Full-движок хранит scale как две независимые физические оси. Равные // значения всё равно сериализуются в компактный scale(N), зато переход // uniform↔axial не меняет представление: обе позиции и pickup-скорость // перехватываемого канала остаются явными. if (key === 'scale') { if (!keys.includes('scaleX')) { - specs.push({ _kind: 'num', _key: 'scaleX', _group: group, _explicitFrom: explicitFrom, _to: to }); + specs.push({ _kind: 'num', _key: 'scaleX', _group: group, _explicitFrom: explicitFrom, _to: to, _stops: numericStops }); } if (!keys.includes('scaleY')) { - specs.push({ _kind: 'num', _key: 'scaleY', _group: group, _explicitFrom: explicitFrom, _to: to }); + specs.push({ _kind: 'num', _key: 'scaleY', _group: group, _explicitFrom: explicitFrom, _to: to, _stops: numericStops }); } } else { - specs.push({ _kind: 'num', _key: key, _group: group, _explicitFrom: explicitFrom, _to: to }); + specs.push({ _kind: 'num', _key: key, _group: group, _explicitFrom: explicitFrom, _to: to, _stops: numericStops }); } } else { + const astStops = stops?.map(parseCssValue); specs.push({ _kind: 'css', _key: key, _group: camelToKebab(key), - _explicitFrom: pair ? parseCssValue(pair[0]) : undefined, - _to: parseCssValue(pair ? pair[1] : raw), + _explicitFrom: astStops !== undefined + ? astStops[0]! + : tuple ? parseCssValue(tuple[0]) : undefined, + _to: astStops !== undefined + ? astStops[astStops.length - 1]! + : parseCssValue(tuple ? tuple[1] : raw), + _stops: astStops, }); } } @@ -154,6 +178,9 @@ export interface NumericChannel { readonly _solverTo: number; /** Нормализованная скорость в представимом effect-space канала. */ readonly _v0: number; + /** N-keyframe трек (#205): стопы + offsets; undefined — 2-стоповый путь. */ + readonly _stops?: readonly number[] | undefined; + readonly _offsets?: readonly number[] | undefined; _value: number; _velocity: number; /** Последнее состояние, которое успешно прошло host write. */ @@ -166,6 +193,9 @@ export interface CssChannel { readonly _key: string; readonly _fromAst: ValueAST; readonly _toAst: ValueAST; + /** N-keyframe трек (#205): AST-стопы + offsets; undefined — 2-стоповый путь. */ + readonly _stopsAst?: readonly ValueAST[] | undefined; + readonly _offsets?: readonly number[] | undefined; /** * Стартовая скорость прогресса (прогресс/с). Явная пара [from, to] — 0 * (покой, канон числовых каналов); перехват живого рана — проекция ṗ̂ @@ -203,6 +233,8 @@ function numericChannel( from: number, to: number, velocity: number, + stops?: readonly number[], + offsets?: readonly number[], ): NumericChannel { const range = to - from; const representableRange = Math.max( @@ -224,6 +256,8 @@ function numericChannel( // Seed принадлежит effect-space: IEEE-rounded current может не сохранять // алгебраическую связь с исходным progress, особенно на соседних huge f64. _v0: normalizeV0(velocity, solverTo - from), + _stops: stops, + _offsets: offsets, _value: from, _velocity: velocity, _renderedValue: from, @@ -476,6 +510,22 @@ export function cssAt(ch: CssChannel, p: number): string | number { return interpolateParsed(ch._fromAst, ch._toAst, p); } +/** + * Значение CSS-трека при глобальном k (сырое время/длительность, #205): + * выбор сегмента и easing — общий pure-модуль track.ts, интерполяция — + * тот же interpolateParsed, что у 2-стопового пути (кодек не копируется). + * `at` — переиспользуемый scratch вызывающего (ноль аллокаций на кадр). + */ +export function cssTrackAt( + ch: CssChannel, + k: number, + easeFor: (segment: number) => ((u: number) => number) | undefined, + at: TrackAt, +): string | number { + trackProgressAt(ch._offsets!, k, easeFor, at); + return interpolateParsed(ch._stopsAst![at._segment]!, ch._stopsAst![at._segment + 1]!, at._progress); +} + /** * SSOT сериализации узкой numeric-поверхности. Вызов допустим только после * доказанной topology: transform содержит ровно `x` без residual-каналов, @@ -540,7 +590,9 @@ export function bindGroup( from = Number.isFinite(read) ? read : 1; // opacity: дефолт браузера } } - numeric.push(numericChannel(spec._key, from, spec._to, velocity)); + numeric.push( + numericChannel(spec._key, from, spec._to, velocity, spec._stops, spec._offsets), + ); } else { let fromAst: ValueAST; let v0 = 0; @@ -560,6 +612,8 @@ export function bindGroup( _key: spec._key, _fromAst: fromAst, _toAst: spec._to, + _stopsAst: spec._stops, + _offsets: spec._offsets, _v0: v0, _dpdt: v0, // производная на старте = засеянная (перехват до кадров — C¹) _css: initialCss, diff --git a/src/animate/index.ts b/src/animate/index.ts index 51eb2c55..d0a7899d 100644 --- a/src/animate/index.ts +++ b/src/animate/index.ts @@ -76,6 +76,7 @@ import { type RequestFrameFn, } from './main-unit.js'; import { surfaceBatchFor, type SurfaceBatch } from './surface-batch.js'; +import { uniformOffsets } from './track.js'; import { collectBoundedArrayLike, requireAnimateOptions, @@ -94,11 +95,15 @@ export type AnimateTarget = | ArrayLike | readonly AnimatableElement[]; -/** Значение канала: цель или пара [from, to] (явный from отключает подхват). */ +/** + * Значение канала: цель, пара [from, to] (явный from отключает подхват) или + * N-keyframe кортеж длины ≥3 (#205): `x: [0, 120, -40, 0]` — все стопы явные, + * offsets равномерные либо options.times, изинг per-segment (options.ease). + */ export type AnimatePropValue = | number | string - | readonly [number | string, number | string]; + | readonly (number | string)[]; /** Каналы движения: transform-шортхенды, opacity, любые CSS-свойства. */ export type AnimateProps = Record; @@ -109,8 +114,18 @@ export interface AnimateOptions { readonly spring?: SpringParams | undefined; /** Длительность tween (мс). Задана → режим tween (дефолт ease: standard). */ readonly duration?: number | undefined; - /** Изинг tween t∈[0,1]→прогресс. Задан без duration → duration.base. */ - readonly ease?: ((t: number) => number) | undefined; + /** + * Изинг tween t∈[0,1]→прогресс. Задан без duration → duration.base. + * Массив (#205) — per-segment изинги N-keyframe вызова (длина N−1; все + * каналы вызова обязаны иметь одну authored-топологию N). + */ + readonly ease?: ((t: number) => number) | readonly ((t: number) => number)[] | undefined; + /** + * Offsets N-keyframe вызова (#205): длина N, конечные, неубывающие, + * times[0]=0, times[N−1]=1; дубликаты легальны (right-biased скачок). + * Без times offsets равномерные. Требует authored-топологию N у всех каналов. + */ + readonly times?: readonly number[] | undefined; /** Задержка старта (мс, ≥ 0) — всем целям. */ readonly delay?: number | undefined; /** Каскад для многих целей: число = gap (мс) или конфиг ./stagger. */ @@ -208,7 +223,10 @@ function resolveMode(options: AnimateOptions): MotionMode { const durationInput = options.duration; const easeInput = options.ease; const hasSpring = input !== undefined; - const hasTween = durationInput !== undefined || easeInput !== undefined; + // times — грамматика keyframe-движка (#205): участвует в выборе режима, + // поэтому spring+times конфликтует тем же LM136, что spring+duration. + const hasTween = + durationInput !== undefined || easeInput !== undefined || options.times !== undefined; if (hasSpring && hasTween) { throw new MotionParamError('LM136'); } @@ -217,6 +235,21 @@ function resolveMode(options: AnimateOptions): MotionMode { if (!Number.isFinite(durationMs) || durationMs <= 0) { throw new MotionParamError('LM137'); } + if (Array.isArray(easeInput)) { + // Snapshot массива изингов (#205): длина фиксируется до топологии, + // элементы обязаны быть функциями (тот же LM138, что scalar). + const eases = [...(easeInput as readonly unknown[])]; + if (eases.length === 0) throw new MotionParamError('LM174'); + for (const segmentEase of eases) { + if (typeof segmentEase !== 'function') throw new MotionParamError('LM138'); + } + return { + _type: 'tween', + _durationMs: durationMs, + _ease: eases[0] as (t: number) => number, + _eases: eases as ((t: number) => number)[], + }; + } const ease = easeInput ?? STANDARD_EASING; if (typeof ease !== 'function') { throw new MotionParamError('LM138'); @@ -243,6 +276,64 @@ function resolveDelay(input: number | undefined): number { return delay; } +/** + * Контракт N-keyframe вызова (#205): валидация times (конечные, неубывающие, + * 0 → 1, дубликаты легальны), единая authored-топология N при times/ease[] + * (скрытых эвристик нет), синхронный отказ трек+явная пружина (LM136) и + * наполнение offsets треков (times либо равномерная сетка канала). Треки без + * явного режима получают tween с дефолтными длительностью и изингом — + * keyframe-грамматика принадлежит keyframe-движку. + */ +function resolveTracks( + specs: readonly ChannelSpec[], + mode: MotionMode, + options: AnimateOptions, +): MotionMode { + const timesInput = options.times; + let times: number[] | undefined; + if (timesInput !== undefined) { + if (!Array.isArray(timesInput)) throw new MotionParamError('LM173'); + times = [...(timesInput as readonly unknown[])] as number[]; + // Один проход: NaN/нечисло/убывание ловит !(t >= previous), диапазон — t > 1; + // цепочка от previous=0 гарантирует неотрицательность, края — точные 0 и 1. + let previous = 0; + for (const offset of times) { + if (typeof offset !== 'number' || !(offset >= previous) || offset > 1) { + throw new MotionParamError('LM173'); + } + previous = offset; + } + if (times.length < 2 || times[0] !== 0 || previous !== 1) { + throw new MotionParamError('LM173'); + } + } + const eases = mode._type === 'tween' ? mode._eases : undefined; + let hasTracks = false; + for (const spec of specs) if (spec._stops !== undefined) hasTracks = true; + if (!hasTracks && times === undefined && eases === undefined) return mode; + if (mode._type === 'spring') { + // options.spring задан явно (иначе times/eases уже выбрали бы tween). + if (options.spring !== undefined) throw new MotionParamError('LM136'); + mode = { _type: 'tween', _durationMs: DEFAULT_DURATION_MS, _ease: STANDARD_EASING }; + } + const topology = times !== undefined + ? times.length + : eases !== undefined ? eases.length + 1 : 0; + if (topology !== 0 && eases !== undefined && eases.length !== topology - 1) { + throw new MotionParamError('LM174'); + } + for (const spec of specs) { + const stops = spec._stops; + if (topology !== 0 && (stops?.length ?? (spec._explicitFrom !== undefined ? 2 : 1)) !== topology) { + // Код по источнику топологии: authored times либо длина ease-массива. + if (times !== undefined) throw new MotionParamError('LM173'); + throw new MotionParamError('LM174'); + } + if (stops !== undefined) spec._offsets = times ?? uniformOffsets(stops.length); + } + return mode; +} + // ─── Резолв целей (в момент вызова — SSR-safe импорт) ──────────────────────── function isElementLike(t: unknown): t is AnimatableElement { @@ -358,11 +449,12 @@ export function animate( // 1. Options — первая граница: остальные входы могут быть hostile getters. options = requireAnimateOptions(options); // Остальная валидация — вся ДО побочных эффектов (ноль записей при броске). - const mode = resolveMode(options); + let mode = resolveMode(options); const baseDelay = resolveDelay(options.delay); const staggerInput = options.stagger; if (typeof staggerInput === 'number') resolveDelay(staggerInput); const specs = parseProps(requireAnimateProps(props)); + mode = resolveTracks(specs, mode, options); const els = resolveTargets(target); let targetDelays: number[] | undefined; if (staggerInput !== undefined) { diff --git a/src/animate/main-unit.ts b/src/animate/main-unit.ts index c1b762b7..be7234c4 100644 --- a/src/animate/main-unit.ts +++ b/src/animate/main-unit.ts @@ -3,6 +3,7 @@ import { scaleSerializedVelocity } from '../compositor/sample.js'; import { CONVERGENCE_THRESHOLD, FIXED_DT_S, MAX_FRAMES } from '../internal/constants.js'; import { finiteOrZero } from '../internal/finite.js'; +import { sampleNumericTrack, type SegmentEase, type TrackAt } from './track.js'; import { readSpringFromBasisUnchecked, sampleSpringFromBasisUnchecked, @@ -14,10 +15,12 @@ import { RANGE_EPSILON, channelAt, cssAt, + cssTrackAt, type AnimatableElement, type BoundGroup, type ChannelSnapshot, type CssChannel, + type NumericChannel, type GroupKey, type GroupOwner, type GroupRecord, @@ -26,7 +29,13 @@ import { SurfaceBatch, type SurfaceUnit } from './surface-batch.js'; export type MotionMode = | { readonly _type: 'spring'; readonly _spring: SpringParams } - | { readonly _type: 'tween'; readonly _durationMs: number; readonly _ease: (t: number) => number }; + | { + readonly _type: 'tween'; + readonly _durationMs: number; + readonly _ease: (t: number) => number; + /** Per-segment изинги N-keyframe вызова (#205); undefined — scalar. */ + readonly _eases?: readonly ((t: number) => number)[] | undefined; + }; export type { RequestFrameFn }; @@ -71,11 +80,19 @@ export class MainUnit implements GroupOwner, SurfaceUnit { */ private _writing = false; private readonly _snap = { value: 0, velocity: 0 }; + /** Изинг сегмента трека (#205): eases[i] либо scalar; один замкнутый объект. */ + private readonly _easeFor: (segment: number) => SegmentEase | undefined; + /** Переиспользуемый scratch просмотра трека (ноль аллокаций на кадр). */ + private readonly _trackAt: TrackAt = { _segment: 0, _progress: 0 }; constructor(options: MainUnitOptions) { this._o = options; this._paused = options._startPaused === true; this._phaseMs = -options._delayMs; + const mode = options._mode; + this._easeFor = mode._type === 'tween' + ? (segment) => mode._eases?.[segment] ?? mode._ease + : () => undefined; try { options._batch._add(this, this._paused); } catch (error) { @@ -95,8 +112,12 @@ export class MainUnit implements GroupOwner, SurfaceUnit { const writing = this._writing; let velocity = this._active ? (writing ? channel._velocity : channel._renderedVelocity) : 0; if (this._active && o._mode._type === 'tween') { - const sampled = (channel._to - channel._from) * - this._tweenDerivative(this._liveTweenK()); + // Трек (#205): производная в пространстве значения через семплер; + // 2-стоповый путь — прежняя формула (to−from)·ease′ бит-в-бит. + const sampled = channel._stops !== undefined + ? this._trackDerivative(channel, this._liveTweenK()) + : (channel._to - channel._from) * + this._tweenDerivative(this._liveTweenK()); velocity = finiteOrZero(sampled); } return { _value: writing ? channel._value : channel._renderedValue, _velocity: velocity }; @@ -110,10 +131,13 @@ export class MainUnit implements GroupOwner, SurfaceUnit { const channel = this._o!._bound._css; if (channel === undefined || channel._key !== key) return undefined; const writing = this._writing; + // CSS-трек (#205): значение непрерывно (C⁰), скорость покоя — та же + // лестница деградации, что var()/смешанные AST в projectCssV0; полный C¹ + // остаётся контрактом числовых каналов и 2-стопового CSS. const dpdt = !this._active ? 0 : this._o!._mode._type === 'tween' - ? this._tweenDerivative(this._liveTweenK()) + ? channel._stopsAst !== undefined ? 0 : this._tweenDerivative(this._liveTweenK()) : writing ? channel._dpdt : channel._renderedDpdt; return { ...channel, _dpdt: dpdt, _css: writing ? channel._css : channel._renderedCss }; } @@ -234,9 +258,18 @@ export class MainUnit implements GroupOwner, SurfaceUnit { this._tweenK = k; this._tweenDpdt = NaN; for (const channel of bound._numeric) { - channel._value = channelAt(channel, progress); + // N-keyframe трек (#205) семплируется по сырому k (изинг per-segment); + // 2-стоповый канал сохраняет прежний глобально-eased путь бит-в-бит. + channel._value = channel._stops !== undefined + ? sampleNumericTrack(channel._stops, channel._offsets!, k, this._easeFor, this._trackAt) + : channelAt(channel, progress); + } + const css = bound._css; + if (css !== undefined) { + css._css = css._stopsAst !== undefined + ? cssTrackAt(css, k, this._easeFor, this._trackAt) + : cssAt(css, progress); } - if (bound._css !== undefined) bound._css._css = cssAt(bound._css, progress); return false; } @@ -285,6 +318,24 @@ export class MainUnit implements GroupOwner, SurfaceUnit { return converged; } + /** + * Производная трека в пространстве значения (units/s, #205): численный + * дифференциал семплера тем же шагом, что _tweenDerivative. Right-biased + * скачок нулевой ширины даёт конечную секущую — finiteOrZero страхует край. + */ + private _trackDerivative(channel: NumericChannel, k: number): number { + const mode = this._o!._mode; + if (mode._type !== 'tween') return 0; + const k0 = k > EASE_DERIV_H ? k - EASE_DERIV_H : 0; + const k1 = k + EASE_DERIV_H < 1 ? k + EASE_DERIV_H : 1; + const stops = channel._stops!; + const offsets = channel._offsets!; + const raw = ((sampleNumericTrack(stops, offsets, k1, this._easeFor, this._trackAt) - + sampleNumericTrack(stops, offsets, k0, this._easeFor, this._trackAt)) * 1000) / + ((k1 - k0) * mode._durationMs); + return finiteOrZero(raw); + } + private _tweenDerivative(k = this._tweenK): number { if (k === this._tweenK && !Number.isNaN(this._tweenDpdt)) return this._tweenDpdt; const mode = this._o!._mode; diff --git a/src/animate/track.ts b/src/animate/track.ts new file mode 100644 index 00000000..a90c5916 --- /dev/null +++ b/src/animate/track.ts @@ -0,0 +1,101 @@ +/** + * animate/track.ts — чистый IR/семплер N-keyframe трека фасада (#205). + * + * Один маленький pure-модуль владеет ВСЕЙ топологией трека: offsets + * (равномерные либо authored times), right-biased выбор сегмента на + * дубликатах (скачок нулевой ширины) и per-segment easing. Числовые и + * CSS-каналы используют ОДИН просмотр (trackProgressAt) — коды значений + * остаются в channels.ts (transform/value SSOT не копируются). + * + * Законы (#205): + * - без times offsets равномерные по числу стопов канала; + * - duplicate offsets разрешены; на самом offset выигрывает ПОЗДНИЙ + * сегмент (right-bias) — скачок нулевой ширины; + * - scalar ease применяется К КАЖДОМУ сегменту (для 2-стопового трека + * это совпадает с глобальным ease — согласованность с pair-путём); + * - эндпоинты точны: k≤0 → первый стоп, k≥1 → последний стоп. + * + * Инварианты: zero-DOM, детерминизм; ноль аллокаций в семпле — результат + * пишется в переиспользуемый out-набор вызывающего (канон MutableSpringBasis). + */ + +/** Прогресс-функция сегмента (u∈[0,1] → прогресс; не-конечное → u). */ +export type SegmentEase = (u: number) => number; + +/** Переиспользуемый результат просмотра трека (аллоцируется вызывающим). */ +export interface TrackAt { + _segment: number; + _progress: number; +} + +/** Равномерные offsets для count стопов: 0, 1/(n−1), …, 1. */ +export function uniformOffsets(count: number): number[] { + const offsets = new Array(count); + const last = count - 1; + for (let i = 0; i < count; i++) offsets[i] = i / last; + offsets[last] = 1; + return offsets; +} + +/** + * Right-biased сегмент + eased-прогресс при глобальном k (сырое время / + * длительность). Сегмент — наибольший i с offsets[i] ≤ k (поиск с конца, + * дубликат отдаёт поздний сегмент); нулевая ширина → прогресс 1 (right-bias). + * Не-конечный выход ease деградирует к линейному u (канон tween-ветки). + */ +export function trackProgressAt( + offsets: readonly number[], + k: number, + easeFor: (segment: number) => SegmentEase | undefined, + out: TrackAt, +): void { + const last = offsets.length - 2; + if (k <= 0) { + out._segment = 0; + out._progress = 0; + return; + } + if (k >= 1) { + out._segment = last; + out._progress = 1; + return; + } + let segment = 0; + for (let i = last; i > 0; i--) { + if (k >= offsets[i]!) { + segment = i; + break; + } + } + const start = offsets[segment]!; + const span = offsets[segment + 1]! - start; + let progress = span > 0 ? Math.min(1, Math.max(0, (k - start) / span)) : 1; + const ease = easeFor(segment); + if (ease !== undefined) { + const eased = ease(progress); + if (Number.isFinite(eased)) progress = eased; + } + out._segment = segment; + out._progress = progress; +} + +/** + * Числовое значение трека при k. Взвешенная форма сегмента зеркалит channelAt: + * эндпоинты сегмента точны, переполнение деградирует к позднему стопу. + */ +export function sampleNumericTrack( + stops: readonly number[], + offsets: readonly number[], + k: number, + easeFor: (segment: number) => SegmentEase | undefined, + at: TrackAt, +): number { + trackProgressAt(offsets, k, easeFor, at); + const from = stops[at._segment]!; + const to = stops[at._segment + 1]!; + const progress = at._progress; + if (progress === 1) return to; + if (progress === 0 || from === to) return from; + const value = (1 - progress) * from + progress * to; + return Number.isFinite(value) ? value : to; +} diff --git a/src/errors.ts b/src/errors.ts index f17d7464..4717c2af 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,7 +14,7 @@ export type MotionParamErrorCode = `LM${Digit}${Digit}${Digit}`; const MOTION_PARAM_ERROR_CODE = /^LM\d{3}$/; /** Последний непрерывный код каталога; contract-тест сверяет его с docs/errors.md. */ -export const LAST_MOTION_PARAM_ERROR_CODE: MotionParamErrorCode = 'LM172'; +export const LAST_MOTION_PARAM_ERROR_CODE: MotionParamErrorCode = 'LM174'; /** Thrown when caller-supplied physics parameters are invalid (invariant 2). */ export class MotionParamError extends Error { diff --git a/test/animate-keyframes-tracks.test.ts b/test/animate-keyframes-tracks.test.ts new file mode 100644 index 00000000..bf600b1a --- /dev/null +++ b/test/animate-keyframes-tracks.test.ts @@ -0,0 +1,420 @@ +/** + * test/animate-keyframes-tracks.test.ts — доказательная программа #205: + * N-keyframe tracks в полном ./animate с общим owner/lifecycle. + * + * Классы (RED-гейты issue): + * D. Differential N=3/4/11 против НЕЗАВИСИМОГО наивного семплера + * (неравномерные/дублированные offsets, точные эндпоинты, числовой и + * CSS кодек). + * L. Lifecycle: multi-target + stagger, seek/pause/play/cancel/reduced, + * один onComplete, естественное завершение в последний стоп. + * C. Interruption/C¹: перехват трека в полёте наследует скорость + * (числовой канал); zero-width скачок right-biased; residual transform + * сохраняется. + * H. Hostile: sparse/mutating массивы — snapshot и fail-fast до записей. + * V. Валидация times/ease[] (LM173/LM174/LM138), трек+пружина → LM136, + * дефолтный режим трека — tween. + */ + +import { describe, expect, it } from 'vitest'; +import { animate } from '../src/animate/index.js'; +import { MotionParamError } from '../src/errors.js'; +import { fakeEl, makeClock } from './animate-facade-helpers.js'; + +/** Независимый right-biased семплер (структурно другой скан, чем production). */ +function naiveTrack( + stops: readonly number[], + offsets: readonly number[], + k: number, + ease: (u: number) => number = (u) => u, +): number { + if (k <= 0) return stops[0]!; + if (k >= 1) return stops[stops.length - 1]!; + let segment = 0; + for (let i = 0; i <= offsets.length - 2; i++) { + if (k >= offsets[i]!) segment = i; + } + const span = offsets[segment + 1]! - offsets[segment]!; + const u = span > 0 ? Math.min(1, Math.max(0, (k - offsets[segment]!) / span)) : 1; + return stops[segment]! + (stops[segment + 1]! - stops[segment]!) * ease(u); +} + +const uniform = (n: number): number[] => + Array.from({ length: n }, (_, i) => i / (n - 1)); + +/** Значение opacity из журнала записей (единственная числовая поверхность). */ +const opacityWrites = (writes: readonly { prop: string; value: string }[]): number[] => + writes.filter((w) => w.prop === 'opacity').map((w) => Number(w.value)); + +const noReduce = () => ({ matches: false }); + +// ─── D. Differential против независимого семплера ──────────────────────────── + +describe('#205/D дифференциал трека против независимого семплера', () => { + it.each([ + [3, [0, 1, 0.25]], + [4, [0, 1, -0.5, 0.75]], + [11, [0, 0.1, 0.9, 0.2, 0.8, 0.3, 0.7, 0.4, 0.6, 0.5, 1]], + ] as const)('N=%d: равномерные offsets, линейный изинг', (_n, stops) => { + const clock = makeClock(); + const target = fakeEl(); + const durationMs = 800; + animate(target.el, { opacity: [...stops] }, { + duration: durationMs, + ease: (u) => u, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + const values = opacityWrites(target.writes); + expect(values.length).toBeGreaterThan(10); + const offsets = uniform(stops.length); + for (let i = 0; i < values.length - 1; i++) { + const k = (16 * i) / durationMs; + expect( + Math.abs(values[i]! - naiveTrack(stops, offsets, k)), + `k=${k}`, + ).toBeLessThanOrEqual(1e-9); + } + // Естественный финал — точно последний стоп. + expect(values[values.length - 1]).toBe(stops[stops.length - 1]); + }); + + it('authored times: неравномерные offsets + per-segment изинги', () => { + const clock = makeClock(); + const target = fakeEl(); + const stops = [0, 1, 0.5]; + const times = [0, 0.2, 1]; + const easeIn = (u: number): number => u * u; + const easeOut = (u: number): number => 1 - (1 - u) * (1 - u); + animate(target.el, { opacity: stops }, { + duration: 500, + times, + ease: [easeIn, easeOut], + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + const values = opacityWrites(target.writes); + for (let i = 0; i < values.length - 1; i++) { + const k = (16 * i) / 500; + const segment = k >= 0.2 ? 1 : 0; + const expected = naiveTrack(stops, times, k, segment === 0 ? easeIn : easeOut); + expect(Math.abs(values[i]! - expected), `k=${k}`).toBeLessThanOrEqual(1e-9); + } + }); + + it('CSS-кодек: цветовой трек интерполируется по сегментам и оседает в финале', () => { + const clock = makeClock(); + const target = fakeEl(); + animate(target.el, { + backgroundColor: ['rgb(0, 0, 0)', 'rgb(200, 100, 0)', 'rgb(0, 0, 250)'], + }, { + duration: 400, + ease: (u) => u, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + const writes = target.writes.filter((w) => w.prop === 'background-color'); + expect(writes.length).toBeGreaterThan(5); + // Середина первого сегмента (k=0.25 → u=0.5): между чёрным и оранжевым. + const mid = writes[Math.round((0.25 * 400) / 16)]!.value; + const channels = /rgb\((\d+), (\d+), (\d+)\)/.exec(mid); + expect(channels).not.toBeNull(); + expect(Number(channels![1])).toBeGreaterThan(50); + expect(Number(channels![1])).toBeLessThan(200); + // Финал — точно последний стоп. + expect(writes[writes.length - 1]!.value).toBe('rgb(0, 0, 250)'); + }); + + it('zero-width скачок right-biased: на дубликате offset виден поздний стоп', () => { + const clock = makeClock(); + const target = fakeEl(); + animate(target.el, { opacity: [0, 0.1, 0.2, 0.3] }, { + duration: 320, // k-шаг 16/320 = 0.05: k=0.5 попадает на дубликат ровно + times: [0, 0.5, 0.5, 1], + ease: (u) => u, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + const values = opacityWrites(target.writes); + const beforeJump = values[Math.round((0.45 * 320) / 16)]!; + const atJump = values[Math.round((0.5 * 320) / 16)]!; + expect(beforeJump).toBeLessThan(0.1 + 1e-9); // сегмент 0 → приближение к 0.1 + expect(atJump).toBeGreaterThanOrEqual(0.2 - 1e-9); // right-bias: стоп 0.2 уже виден + }); +}); + +// ─── L. Lifecycle ──────────────────────────────────────────────────────────── + +describe('#205/L lifecycle трека', () => { + it('multi-target + stagger: оба оседают в последний стоп, onComplete один раз', async () => { + const clock = makeClock(); + const a = fakeEl(); + const b = fakeEl(); + let completions = 0; + const controls = animate([a.el, b.el], { opacity: [0, 1, 0.5] }, { + duration: 200, + stagger: 48, + onComplete: () => { completions++; }, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + await controls.finished; + expect(completions).toBe(1); + expect(opacityWrites(a.writes).at(-1)).toBe(0.5); + expect(opacityWrites(b.writes).at(-1)).toBe(0.5); + // Каскад: вторая цель стартует позже (меньше кадров при том же drain). + expect(opacityWrites(b.writes).length).toBeLessThan(opacityWrites(a.writes).length + 1); + }); + + it('seek позиционирует трек по своему k; pause замораживает', () => { + const clock = makeClock(); + const target = fakeEl(); + const controls = animate(target.el, { opacity: [0, 1, 0.5] }, { + duration: 400, + ease: (u) => u, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + controls.pause(); + controls.seek(100); // k=0.25 → сегмент 0, u=0.5 → 0.5 + const values = opacityWrites(target.writes); + expect(Math.abs(values[values.length - 1]! - 0.5)).toBeLessThanOrEqual(1e-9); + controls.seek(300); // k=0.75 → сегмент 1, u=0.5 → 0.75 + expect(Math.abs(opacityWrites(target.writes).at(-1)! - 0.75)).toBeLessThanOrEqual(1e-9); + }); + + it('cancel сохраняет текущую позу; reduced публикует последний стоп без кадров', async () => { + const clock = makeClock(); + const target = fakeEl(); + const controls = animate(target.el, { opacity: [0, 1, 0.5] }, { + duration: 400, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.step(16); + clock.step(16); + controls.cancel(); + const frames = opacityWrites(target.writes).length; + clock.drain(16); + expect(opacityWrites(target.writes).length).toBe(frames); // кадры остановлены + await controls.finished; + + const reducedTarget = fakeEl(); + animate(reducedTarget.el, { opacity: [0, 1, 0.5] }, { + duration: 400, + requestFrame: clock.requestFrame, + matchMedia: () => ({ matches: true }), + }); + const reducedValues = opacityWrites(reducedTarget.writes); + expect(reducedValues).toEqual([0.5]); // мгновенный финал, ноль кадров + }); +}); + +// ─── C. Interruption / C¹ / композиция ─────────────────────────────────────── + +describe('#205/C прерывание и композиция', () => { + it('перехват трека наследует скорость: pickup-старт обгоняет rest-старт', () => { + const spring = { mass: 1, stiffness: 120, damping: 14 }; + const run = (explicitFrom: boolean): number => { + const clock = makeClock(); + const target = fakeEl(); + animate(target.el, { opacity: [0, 1, 0.5] }, { + duration: 400, + ease: (u) => u, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + // До k≈0.3 (сегмент 0, восходящий, скорость > 0). + for (let i = 0; i < 8; i++) clock.step(16); + const position = opacityWrites(target.writes).at(-1)!; + animate(target.el, explicitFrom ? { opacity: [position, 1] } : { opacity: 1 }, { + spring, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.step(16); + clock.step(16); + return opacityWrites(target.writes).at(-1)!; + }; + const withPickup = run(false); // подхват value+velocity (C¹) + const fromRest = run(true); // явная пара отключает подхват (v0=0) + // Унаследованная положительная скорость двигает pickup-путь заметно дальше. + expect(withPickup).toBeGreaterThan(fromRest + 1e-4); + }); + + it('residual transform: трек x не сбрасывает прежний rotate', async () => { + const clock = makeClock(); + const target = fakeEl(); + const first = animate(target.el, { rotate: 45 }, { + duration: 100, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + await first.finished; + animate(target.el, { x: [0, 20, 10] }, { + duration: 200, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.step(16); + clock.step(16); + const lastTransform = target.writes.filter((w) => w.prop === 'transform').at(-1)!; + expect(lastTransform.value).toContain('rotate(45deg)'); + expect(lastTransform.value).toContain('translateX('); + }); +}); + +// ─── H. Hostile ────────────────────────────────────────────────────────────── + +describe('#205/H hostile-массивы', () => { + it('sparse-кортеж → LM142 до каких-либо записей', () => { + const clock = makeClock(); + const target = fakeEl(); + const sparse: (number | undefined)[] = [0, undefined, 1]; + let caught: unknown; + try { + animate(target.el, { opacity: sparse as never }, { + duration: 100, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(MotionParamError); + expect((caught as MotionParamError).code).toBe('LM142'); + expect(target.writes).toEqual([]); + }); + + it('stateful getter читается ровно один раз (snapshot до валидации)', () => { + const clock = makeClock(); + const target = fakeEl(); + let reads = 0; + const hostile = [0, 0, 1]; + Object.defineProperty(hostile, 1, { + get() { reads++; return 0.5; }, + }); + animate(target.el, { opacity: hostile }, { + duration: 100, + ease: (u) => u, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + expect(reads).toBe(1); + clock.drain(16); + expect(opacityWrites(target.writes).at(-1)).toBe(1); + }); +}); + +// ─── V. Валидация ──────────────────────────────────────────────────────────── + +describe('#205/V контракт times/ease[]/режима', () => { + const el = () => fakeEl().el; + const throwsCode = (code: string, fn: () => unknown): void => { + let caught: unknown; + try { fn(); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(MotionParamError); + expect((caught as MotionParamError).code).toBe(code); + }; + + it('трек без options легален: дефолтный tween-режим', async () => { + const clock = makeClock(); + const target = fakeEl(); + const controls = animate(target.el, { opacity: [0, 1, 0.25] }, { + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + await controls.finished; + expect(opacityWrites(target.writes).at(-1)).toBe(0.25); + }); + + it('трек + явная пружина → LM136 синхронно', () => { + throwsCode('LM136', () => animate(el(), { opacity: [0, 1, 0.5] }, { + spring: { mass: 1, stiffness: 170, damping: 26 }, + })); + }); + + it('times + пружина → LM136 (times — грамматика keyframe-движка)', () => { + throwsCode('LM136', () => animate(el(), { opacity: [0, 1] }, { + spring: { mass: 1, stiffness: 170, damping: 26 }, + times: [0, 1], + })); + }); + + it('матрица некорректных times → LM173', () => { + for (const times of [ + 5 as never, // не массив + [0], // длина < 2 + [0.1, 1], // первый ≠ 0 + [0, 0.9], // последний ≠ 1 + [0, Number.NaN, 1], // не конечное + [0, 0.7, 0.3, 1], // убывание + [0, 2, 1] as never, // вне [0,1] и убывание к последнему + ]) { + throwsCode('LM173', () => animate(el(), { opacity: [0, 1, 0.5] }, { + duration: 100, + times: times as never, + })); + } + }); + + it('несовпадение топологии с times → LM173 (пары/дестинации включительно)', () => { + throwsCode('LM173', () => animate(el(), { opacity: [0, 1] }, { + duration: 100, + times: [0, 0.5, 1], + })); + throwsCode('LM173', () => animate(el(), { opacity: 1 }, { + duration: 100, + times: [0, 1], + })); + throwsCode('LM173', () => animate(el(), { x: [0, 10, 0], opacity: [0, 1] }, { + duration: 100, + times: [0, 0.5, 1], + })); + }); + + it('ease[]: пустой → LM174; не-функция → LM138; длина ≠ N−1 → LM174', () => { + throwsCode('LM174', () => animate(el(), { opacity: [0, 1, 0.5] }, { + duration: 100, + ease: [] as never, + })); + throwsCode('LM138', () => animate(el(), { opacity: [0, 1, 0.5] }, { + duration: 100, + ease: [(u: number) => u, 'linear'] as never, + })); + throwsCode('LM174', () => animate(el(), { opacity: [0, 1, 0.5] }, { + duration: 100, + ease: [(u: number) => u], + })); + }); + + it('кортеж длины 1 → LM141; пары остаются легальными', () => { + throwsCode('LM141', () => animate(el(), { opacity: [1] as never }, { duration: 100 })); + const clock = makeClock(); + expect(() => animate(el(), { opacity: [0, 1] }, { + duration: 100, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + })).not.toThrow(); + }); + + it('смешение пар и треков БЕЗ times легально (каждый канал со своей сеткой)', async () => { + const clock = makeClock(); + const target = fakeEl(); + const controls = animate(target.el, { opacity: [0, 1], x: [0, 20, 10] }, { + duration: 200, + requestFrame: clock.requestFrame, + matchMedia: noReduce, + }); + clock.drain(16); + await controls.finished; + expect(opacityWrites(target.writes).at(-1)).toBe(1); + expect(target.writes.filter((w) => w.prop === 'transform').at(-1)!.value) + .toContain('translateX(10px)'); + }); +});