import { isKindWith } from '../properties/kinds'; import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema'; import { shaderOf } from '../properties/shaders'; import { type AnimationAsset, type AnimationKey, type AnimationTrack, type Asset, type EasingCurve, type EasingDirection, isPrivateVariable, type KeyInterpolation, } from './Asset'; import type { AssetType } from './AssetTypes'; import { type CurveLookup, curveValue, NO_CURVES } from './curve'; import { treeOrder } from './hierarchy'; import { instanceChildId, resolveObjects } from './instance'; import { findAsset, type Project } from './Project'; import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties'; import { objectName, type SceneObject } from './SceneObject'; import { mixColor } from '../util/color'; /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const; export type Playback = (typeof PLAYBACKS)[number]; /** What an animation can be built over: something placed, or a whole scene. */ export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene']; /** * Position lives on the object, not in its property bag, so the timeline offers it by name. `z` is * the draw order, which is sorted on every render, so a keyed `z` moves the object in front or behind. */ const POSITION_KEYS = ['x', 'y', 'z'] as const; function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] { return (POSITION_KEYS as readonly string[]).includes(key); } export interface PlayheadAt { /** Frames since the start, fractional: a value between two keys is blended at this point. */ frame: number; /** Only the two `once` modes ever finish. */ finished: boolean; /** On ping-pong's way back: a value that jumps holds the key the playhead passed last – the one above. */ reverse: boolean; } /** Slack for `time * fps`: six ticks of 1/60 s sum to 0.09999999999999999, which at 10 fps is frame 1. */ const STEP_EPSILON = 1e-6; export function playbackOf(asset: AnimationAsset): Playback { const value = textOf(asset.props.playback ?? 'loop'); return (PLAYBACKS as readonly string[]).includes(value) ? (value as Playback) : 'loop'; } export function fpsOf(asset: AnimationAsset): number { const fps = Number(asset.props.fps); return Number.isFinite(fps) && fps > 0 ? fps : 0; } /** The frame of the last key over all rows; -1 for an animation without keys. */ function lastKeyFrame(asset: AnimationAsset): number { let last = -1; for (const track of asset.tracks) { const key = track.keys[track.keys.length - 1]; if (key && key.frame > last) last = key.frame; } return last; } /** * How many frames one pass lasts: the last key's frame is a frame of its own, so keys on 0…9 are * ten frames – at 10 fps one second. A pause at the end is a last key that repeats the value. */ export function frameCount(asset: AnimationAsset): number { return lastKeyFrame(asset) + 1; } export function animationDuration(asset: AnimationAsset): number { const fps = fpsOf(asset); return fps > 0 ? frameCount(asset) / fps : 0; } /** Where the playhead stands `time` seconds after the start, for a pass of `count` frames. */ export function playheadAt(count: number, fps: number, playback: Playback, time: number): PlayheadAt { if (count <= 0) return { frame: 0, finished: playback === 'once' || playback === 'once-reset', reverse: false }; if (!Number.isFinite(fps) || fps <= 0 || !(time > 0)) return { frame: 0, finished: false, reverse: false }; const at = time * fps + STEP_EPSILON; switch (playback) { case 'loop': return { frame: at % count, finished: false, reverse: false }; case 'once': case 'once-reset': return at >= count ? { frame: count, finished: true, reverse: false } : { frame: at, finished: false, reverse: false }; case 'pingpong': { // Turns on the last key, so neither end is shown twice: keys on 0…9 play 0…9…1 in 18 frames (#112). const last = count - 1; if (last <= 0) return { frame: 0, finished: false, reverse: false }; const phase = at % (2 * last); return phase < last ? { frame: phase, finished: false, reverse: false } : { frame: 2 * last - phase, finished: false, reverse: true }; } } } export function animationPlayheadAt(asset: AnimationAsset, time: number): PlayheadAt { return playheadAt(frameCount(asset), fpsOf(asset), playbackOf(asset), time); } /** Only these blend between keys; every other kind holds its value until the next key. */ export function blends(kind: string): boolean { return kind === 'number' || kind === 'color'; } export type EasingShape = 'linear' | 'step' | EasingCurve | 'curve'; /** The inspector's first easing field, in the order it offers them; `linear` is what an absent field means. */ export const EASING_SHAPES: readonly { value: EasingShape; label: string }[] = [ { value: 'linear', label: 'Linear' }, { value: 'step', label: 'Step' }, { value: 'sine', label: 'Sine' }, { value: 'quad', label: 'Quad' }, { value: 'cubic', label: 'Cubic' }, { value: 'quart', label: 'Quart' }, { value: 'quint', label: 'Quint' }, { value: 'expo', label: 'Expo' }, { value: 'circ', label: 'Circ' }, { value: 'back', label: 'Back' }, { value: 'elastic', label: 'Elastic' }, { value: 'bounce', label: 'Bounce' }, { value: 'curve', label: 'Curve' }, ]; /** The second field, for a curve only. */ export const EASING_DIRECTIONS: readonly { value: EasingDirection; label: string }[] = [ { value: 'in', label: 'In' }, { value: 'out', label: 'Out' }, { value: 'in-out', label: 'In-out' }, ]; const BACK = 1.70158; const ELASTIC = (2 * Math.PI) / 3; function bounceOut(t: number): number { const n = 7.5625; const d = 2.75; if (t < 1 / d) return n * t * t; if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75; if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375; return n * (t -= 2.625 / d) * t + 0.984375; } /** Each curve's `in` form (Penner's); `out` is it mirrored, `in-out` both halves. Back and elastic leave 0…1. */ const EASE_IN: Record number> = { sine: (t) => 1 - Math.cos((t * Math.PI) / 2), quad: (t) => t * t, cubic: (t) => t ** 3, quart: (t) => t ** 4, quint: (t) => t ** 5, expo: (t) => (t <= 0 ? 0 : 2 ** (10 * t - 10)), circ: (t) => 1 - Math.sqrt(1 - t * t), back: (t) => (BACK + 1) * t ** 3 - BACK * t * t, elastic: (t) => (t <= 0 ? 0 : t >= 1 ? 1 : -(2 ** (10 * t - 10)) * Math.sin((10 * t - 10.75) * ELASTIC)), bounce: (t) => 1 - bounceOut(1 - t), }; function isCurve(value: string): value is EasingCurve { return Object.hasOwn(EASE_IN, value); } /** * A stored interpolation split into the inspector's fields; unknown text reads as `linear`. `curve` * is the id of the Curve asset (#247), `''` while none is picked. */ export function easingOf(interpolation: KeyInterpolation): { shape: EasingShape; direction?: EasingDirection; curve?: string; } { if (interpolation === 'linear' || interpolation === 'step') return { shape: interpolation }; if (interpolation.startsWith(CURVE_PREFIX)) return { shape: 'curve', curve: interpolation.slice(CURVE_PREFIX.length) }; const match = /^([a-z]+)-(in|out|in-out)$/.exec(interpolation); if (!match) return { shape: 'linear' }; const curve = match[1]!; const direction = match[2] as EasingDirection; if (curve === 'ease') return { shape: 'quad', direction }; return isCurve(curve) && curve !== 'quad' ? { shape: curve, direction } : { shape: 'linear' }; } const CURVE_PREFIX = 'curve:'; /** The stored form of the fields; a curve without a direction eases both ways. */ export function easingValue( shape: EasingShape, direction: EasingDirection = 'in-out', curve = '', ): KeyInterpolation { if (shape === 'linear' || shape === 'step') return shape; if (shape === 'curve') return `${CURVE_PREFIX}${curve}`; return shape === 'quad' ? `ease-${direction}` : `${shape}-${direction}`; } export function isKeyInterpolation(value: unknown): value is KeyInterpolation { if (typeof value !== 'string') return false; const { shape, direction, curve } = easingOf(value as KeyInterpolation); return easingValue(shape, direction, curve) === value; } export function interpolationOf(key: AnimationKey): KeyInterpolation { return key.interpolation ?? 'linear'; } /** * How far along the way to the next key a value is, `t` the share of the time passed. Quad, the eases * of old, reaches 0.25 at half the time going in, 0.75 going out, and 0.125 at a quarter going both. * A Curve asset that is gone, or none picked, blends linearly. */ export function easedShare( interpolation: KeyInterpolation, t: number, curves: CurveLookup = NO_CURVES, ): number { const { shape, direction, curve } = easingOf(interpolation); if (shape === 'linear') return t; if (shape === 'step') return 0; if (shape === 'curve') { const points = curves(curve ?? ''); return points ? curveValue(points, t) : t; } const easeIn = EASE_IN[shape]; switch (direction ?? 'in-out') { case 'in': return easeIn(t); case 'out': return 1 - easeIn(1 - t); case 'in-out': return t < 0.5 ? easeIn(2 * t) / 2 : 1 - easeIn(2 - 2 * t) / 2; } } /** The value between two keys, `t` from 0 (at `a`) to 1 (at `b`). */ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t: number): PropertyValue { if (kind === 'number') { const from = Number(a); const to = Number(b); return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a; } if (kind === 'color') return mixColor(colorOf(a, 0), colorOf(b, 0), t); return a; } /** * A row's value at a (fractional) frame: the first value before the first key, the last after the * last one. Between two keys the earlier one's interpolation decides the way. A value that jumps – * one that cannot blend, or a `step` key – holds until the next key in the direction of play: played * in `reverse`, that is the key above. `undefined` only for a row without keys. `curves` finds the * Curve assets a key may blend along. */ export function trackValueAt( keys: readonly AnimationKey[], kind: string, frame: number, reverse = false, curves: CurveLookup = NO_CURVES, ): PropertyValue | undefined { const first = keys[0]; if (!first) return undefined; if (frame <= first.frame) return first.value; for (let i = 1; i < keys.length; i++) { const next = keys[i]!; if (frame >= next.frame) continue; const prev = keys[i - 1]!; const interpolation = interpolationOf(prev); if (!blends(kind) || interpolation === 'step') return reverse && frame > prev.frame ? next.value : prev.value; const t = (frame - prev.frame) / (next.frame - prev.frame); return blendValue(kind, prev.value, next.value, easedShare(interpolation, t, curves)); } return keys[keys.length - 1]!.value; } /** * What a row writes at a frame: its value, or for a relative number row that value added to `base`, * what the target held when the animation started. Shared by the runtime player and the tab's * preview, so both show the same picture. A number is held to the property's `min`/`max`, which a * back or elastic curve overshoots. `undefined` for a row without keys. */ export function trackWriteAt( track: AnimationTrack, def: RowDef, frame: number, base: PropertyValue | undefined, reverse = false, curves: CurveLookup = NO_CURVES, ): PropertyValue | undefined { const value = trackValueAt(track.keys, def.kind, frame, reverse, curves); if (value === undefined) return undefined; if (def.kind !== 'number') return value; const offset = track.relative && typeof base === 'number'; return heldTo(def, offset ? base + Number(value) : value); } /** What `trackWriteAt` needs of a property; a `PropertyDef` has it. */ export interface RowDef { kind: string; min?: number; max?: number; } function heldTo(def: RowDef, value: PropertyValue): PropertyValue { if (typeof value !== 'number') return value; if (def.min !== undefined && value < def.min) return def.min; if (def.max !== undefined && value > def.max) return def.max; return value; } /** A row's property on an asset of that type; undefined for one the timeline cannot animate. */ export function animatedDef(type: AssetType, key: string): PropertyDef | undefined { return animatableDefs(type).find((d) => d.key === key); } /** The property `key` is on an asset holding `bag` – see `animatableDefsOf`. */ export function animatedDefOf( project: Project, asset: Asset, bag: PropertyBag, key: string, ): PropertyDef | undefined { return animatableDefsOf(project, asset, bag).find((d) => d.key === key); } function hasPosition(type: AssetType): boolean { return type !== 'scene' && type !== 'behavior'; } /** Whether a row sets the object's position rather than a value in its bag – never a behavior's `x`. */ export function isPositionRow(type: AssetType, key: string): key is (typeof POSITION_KEYS)[number] { return hasPosition(type) && isPositionKey(key); } function isFlat(def: PropertyDef): boolean { return def.kind !== 'list' && def.kind !== 'struct'; } /** * The rows the timeline offers for an asset type: position, then the schema's flat properties. A * list or struct has no single value to key. A scene is placed nowhere and a behavior drawn nowhere, * so neither offers a position – a behavior's variable `x` is the variable. */ export function animatableDefs(type: AssetType): PropertyDef[] { const own = schemaFor(type).filter(isFlat); if (!hasPosition(type)) return own; const position: PropertyDef[] = POSITION_KEYS.map((key) => ({ key, label: key.toUpperCase(), kind: 'number', default: 0, })); return [...position, ...own]; } /** * The rows one asset offers when it holds `bag`: its type's, then what the asset itself declares (#104) * – a behavior's variables, a sprite's parameters of the shader the bag names. Which shader that is * can differ per object, and at runtime per frame, hence the bag. A declared row never shadows a row * of the type. */ export function animatableDefsOf( project: Project, asset: Asset, bag: PropertyBag = asset.props, ): PropertyDef[] { const own = animatableDefs(asset.type); const declared = asset.type === 'behavior' ? asset.variables : asset.type === 'sprite' || asset.type === 'text' ? shaderParamRows(project, shaderOf(bag)) : []; const taken = new Set(own.map((d) => d.key)); return [...own, ...declared.filter((d) => isFlat(d) && !taken.has(d.key))]; } /** * The id of the one object a placed root stands on – the preview's holder, and the instance the * targets inside a container are resolved below. */ export const PLACED_ROOT_ID = 'preview-root'; /** The root placed at the origin, its content following the template like any placed container's. */ export function placedRoot(root: Asset): SceneObject { return { id: PLACED_ROOT_ID, assetId: root.id, name: root.name, position: { x: 0, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {}, }; } /** One object a row may aim at below the root, under the id the row stores. */ export interface AnimationTarget { id: string; object: SceneObject; depth: number; } /** * Everything a row may aim at below the root, in tree order – into placed containers, too (#109). * A scene's rows name its objects by the ids the running scene gives them, which a baked instance * keeps. A container's rows name a path of template ids, what the runtime appends to whichever * object the animation plays on. A sprite or text has nothing below it. */ export function animationTargets(project: Project, root: Asset | undefined): AnimationTarget[] { if (root?.type === 'scene') return treeOrder({ objects: resolveObjects(project, root) }).map(({ object, depth }) => ({ id: object.id, object, depth, })); if (root?.type !== 'container') return []; const prefix = instanceChildId(PLACED_ROOT_ID, ''); return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) }) .filter(({ object }) => object.id.startsWith(prefix)) .map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 })); } /** What a row aims at: the asset, the object placing it (none for the root itself) and the bag it holds. */ interface Aimed { asset: Asset; object?: SceneObject; bag: PropertyBag; } function aimedAt(project: Project, animation: AnimationAsset, target: string): Aimed | undefined { const root = findAsset(project, animation.root); if (target === '') return root && { asset: root, bag: root.props }; const object = animationTargets(project, root).find((t) => t.id === target)?.object; const asset = object && findAsset(project, object.assetId); return object && asset && { asset, object, bag: { ...asset.props, ...object.overrides } }; } /** The asset a row aims at: the root itself, or what the object inside it places. Undefined once gone. */ export function targetAssetOf( project: Project, animation: AnimationAsset, target: string, ): Asset | undefined { return aimedAt(project, animation, target)?.asset; } /** The rows the object a row may aim at offers; undefined once that object is gone. */ export function targetDefs( project: Project, animation: AnimationAsset, target: string, ): PropertyDef[] | undefined { const aimed = aimedAt(project, animation, target); return aimed && animatableDefsOf(project, aimed.asset, aimed.bag); } /** * The property a row sets. Undefined when its object is gone – or has lost the property: a behavior * taken off, a variable renamed, a shader swapped for one without the parameter all read as missing, * and the keys stay. */ export function trackDef( project: Project, animation: AnimationAsset, track: AnimationTrack, ): PropertyDef | undefined { return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property); } /** * The rows the dialog offers for the object `target` names: what it can animate, less a behavior's * private variables (#270) and what another row animates already. A row on a private variable keeps * playing – only a new one is not offered. `changing` is the row being aimed elsewhere: its keys * stay, so only properties of its kind are offered, and its own property is, private or not. */ export function offeredDefs( project: Project, animation: AnimationAsset, target: string, changing: AnimationTrack | null = null, ): PropertyDef[] { const aimed = aimedAt(project, animation, target); if (!aimed) return []; const kind = changing ? (trackDef(project, animation, changing)?.kind ?? null) : null; return animatableDefsOf(project, aimed.asset, aimed.bag).filter((def) => { if (kind !== null && def.kind !== kind) return false; const own = changing?.target === target && changing.property === def.key; if (isPrivateVariable(aimed.asset, def.key) && !own) return false; return !animation.tracks.some( (t) => t.id !== changing?.id && t.target === target && t.property === def.key, ); }); } /** How the timeline names what a row aims at: the object (or the root itself) and the property. */ export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string { const aimed = aimedAt(project, animation, track.target); const who = !aimed ? '(missing)' : aimed.object ? objectName(aimed.object, aimed.asset) : aimed.asset.name; const def = aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property); return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`; } /** Whether a value names something in the project – what deleting that something has to count. */ export function referencesAny( track: AnimationTrack, kind: string | undefined, ids: ReadonlySet, ): boolean { if (!kind || !isKindWith('reference', kind)) return false; return track.keys.some((k) => typeof k.value === 'string' && ids.has(k.value)); } /** * One animation playing on one object. It lives on the object, off its property bag, like the * emit counter: what the world writes back must not grow an override per frame. `bases` is what * each row's target held when the first tick after the start reached it – the zero of a relative * row and what `once-reset` puts back – keyed ` `. */ export interface AnimationPlayer { animationId: string; time: number; playing: boolean; /** Start order across the whole run: of two players setting one property, the later start wins. */ order: number; bases?: Record; blend?: AnimationBlend; /** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */ blendOut?: number; fade?: AnimationFade; } /** * A crossfade into a player that just took over (#106): over `seconds`, whatever the animation's frame * rate, a row that blends mixes from what the property held into the player's value. `from` is taken * on the first tick of the blend; a property an earlier player writes in the same tick is mixed from * that value instead, live. */ export interface AnimationBlend { seconds: number; elapsed: number; from?: Record; } /** * A player on its way out (#106): over `seconds` a row that blends mixes from the player's value back * to what lies below it – what an earlier player writes in the same tick, live, or else the value the * row found at the start. Everything else goes back at once. Then the player is gone. `ended` marks * the fade of a `once-reset` pass whose frames ran out: playing it again starts it over. */ export interface AnimationFade { seconds: number; elapsed: number; ended?: boolean; } /** How far a blend or a fade has come, 0…1. */ export function blendShare(blend: { seconds: number; elapsed: number }): number { if (!(blend.seconds > 0)) return 1; return Math.min(1, blend.elapsed / blend.seconds); } interface PlayerHolder { animations?: AnimationPlayer[]; } let startCount = 0; /** * Starts the animation on the object, or lets a paused one run on. One already running keeps its * frame, so a graph may ask for it every frame without holding it at its first frame – but it counts * as started last again: turning back to a walk that still runs has to show that walk. * `blendSeconds` crossfades into it – but only when it takes over: asked for again while it already * leads on the object, it keeps the blend it has, so a graph calling every frame does not restart it. * One fading out turns round: it blends back in from the value it has reached, without a jump – or, * when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of * a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in * from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row * does not add up. */ export function playAnimation( holder: PlayerHolder, animationId: string, blendSeconds = 0, blendOutSeconds = 0, restart = false, ): void { const players = (holder.animations ??= []); const known = players.find((p) => p.animationId === animationId); const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined; const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined; if (known) { const leads = players.every((p) => p.order <= known.order); const fade = known.fade; known.playing = true; known.order = ++startCount; known.blendOut = blendOut; delete known.fade; if (restart || fade?.ended) { known.time = 0; known.blend = blend; } else if (fade && blend) { // Mixed back from the starting values it stands where the fade left it: `share` = 1 - fade share. blend.elapsed = blendSeconds * (1 - blendShare(fade)); known.blend = { ...blend, ...(known.bases && { from: { ...known.bases } }) }; } else if (fade || !leads) known.blend = blend; return; } players.push({ animationId, time: 0, playing: true, order: ++startCount, ...(blend && { blend }), ...(blendOut && { blendOut }), }); } /** * Ends it where it stands – the values stay; `''` ends every animation on the object. With * `blendSeconds` it fades out instead, back to what lies below it (see `AnimationFade`). A player that * has not reached anything yet ends at once either way. */ export function stopAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void { if (!holder.animations) return; const hit = (p: AnimationPlayer) => animationId === '' || p.animationId === animationId; const fades = blendSeconds > 0; holder.animations = holder.animations.filter((p) => !hit(p) || (fades && p.bases !== undefined)); if (fades) for (const player of holder.animations) if (hit(player) && !player.fade) player.fade = { seconds: blendSeconds, elapsed: 0 }; } /** Holds it at its frame until `Play animation` lets it run on; `''` holds every one. */ export function pauseAnimation(holder: PlayerHolder, animationId: string): void { for (const player of holder.animations ?? []) if (animationId === '' || player.animationId === animationId) player.playing = false; } /** What a run needs of an animation to say where a player stands in it (#233). */ export interface AnimationClip { frames: number; fps: number; playback: Playback; } export function clipOf(asset: AnimationAsset): AnimationClip { return { frames: frameCount(asset), fps: fpsOf(asset), playback: playbackOf(asset) }; } export function clipDuration(clip: AnimationClip): number { return clip.fps > 0 ? clip.frames / clip.fps : 0; } /** What `Animation playing` answers (#233). */ export interface PlayerStatus { playing: boolean; /** Where the playhead stands on the timeline, 0…1 – on ping-pong's way back it falls again. */ progress: number; /** Seconds until the playhead reaches the end of its pass, and a `once-reset` has blended out. */ timeLeft: number; } export const NOT_PLAYING: PlayerStatus = { playing: false, progress: 0, timeLeft: 0 }; /** * A paused player is not running, and neither is one `Stop animation` fades out. A `once-reset` pass * blending back is: it still shows. */ function isRunning(player: AnimationPlayer): boolean { return player.fade ? player.fade.ended === true : player.playing; } /** * The player `Animation playing` answers for: the one of that animation, or for `''` the one started * last among those running – and when none runs, the one started last at all. */ export function leadingPlayer(holder: PlayerHolder, animationId: string): AnimationPlayer | undefined { const players = (holder.animations ?? []).filter( (p) => animationId === '' || p.animationId === animationId, ); const running = players.filter(isRunning); return (running.length > 0 ? running : players).sort((a, b) => b.order - a.order)[0]; } export function playerStatus(clip: AnimationClip, player: AnimationPlayer): PlayerStatus { const playing = isRunning(player); const fade = player.fade; if (fade?.ended) return { playing, progress: 1, timeLeft: Math.max(0, fade.seconds - fade.elapsed) }; if (clip.frames <= 0 || clip.fps <= 0) return { playing, progress: 0, timeLeft: 0 }; const head = playheadAt(clip.frames, clip.fps, clip.playback, player.time); if (clip.playback === 'pingpong') { const last = clip.frames - 1; if (last <= 0) return { playing, progress: 0, timeLeft: 0 }; const ahead = head.reverse ? head.frame : last - head.frame; return { playing, progress: head.frame / last, timeLeft: fade ? 0 : ahead / clip.fps }; } const blendOut = clip.playback === 'once-reset' ? (player.blendOut ?? 0) : 0; return { playing, progress: Math.min(1, head.frame / clip.frames), timeLeft: fade ? 0 : Math.max(0, clip.frames - head.frame) / clip.fps + blendOut, }; } /** A key on one row, as the timeline selects it. */ export interface KeyRef { trackId: string; frame: number; } export function sameKey(a: KeyRef, b: KeyRef): boolean { return a.trackId === b.trackId && a.frame === b.frame; } export function hasKey(keys: readonly KeyRef[], key: KeyRef): boolean { return keys.some((k) => sameKey(k, key)); } export function findTrack(asset: AnimationAsset, trackId: string): AnimationTrack | undefined { return asset.tracks.find((t) => t.id === trackId); } export function findKey(asset: AnimationAsset, ref: KeyRef): AnimationKey | undefined { return findTrack(asset, ref.trackId)?.keys.find((k) => k.frame === ref.frame); } /** Sorted by frame, at most one per frame (the later wins), frames whole and not negative. */ export function normalizeKeys(keys: readonly AnimationKey[]): AnimationKey[] { const byFrame = new Map(); for (const key of keys) { const frame = Math.max(0, Math.round(key.frame)); byFrame.set(frame, { ...key, frame }); } return [...byFrame.values()].sort((a, b) => a.frame - b.frame); }