times-fps
v1.0.0-alpha
Published
A tiny frame-sampling toolkit with timeline, easing, and interpolation utilities.
Downloads
8
Maintainers
Readme
times-fps
times-fps is a tiny TypeScript toolkit for sampling animation frames with timelines, easing, and interpolation utilities.
This library allows you to describe your animation with named phases. times-fps gives you:
- a
Timelineyou can use to define animation phases - an
Interpolatoryou can use to interpolate between values mapping normalized progress and animation segments to ranges - a
FrameSamplerthat generates frames for an FPS and duration - a
TimelineInspectoryou can use to ask "has this phase started/finished yet?"
FrameSampler can produce frame values in the form of numbers, strings, arrays, or nested objects.
Installation
npm install times-fps
# or
yarn add times-fps
# or
pnpm add times-fpsThe library is written in TypeScript, ships as an ES module, and includes type definitions.
Features
Timeline Phases
Build timelines from named phases and ratios instead of hard‑coded numeric intervals.
import {Timeline} from "times-fps";
const timeline = Timeline.ofPhases(
["intro", 1], // 1 unit of time
["main", 2], // 2 units of time
["outro", 1] // 1 unit of time
);
// Each phase is a Segment with [start, end] in [0, 1]
const phases = timeline.phases;
// phases.intro.start === 0
// phases.main.end === 0.75
// phases.outro.end === 1Under the hood, Timeline builds a Sequence of Segments and scales them to the normal [0, 1] interval.
Interpolator & Segment Mapping
Turn a normalized progress value into numbers and ranges that respect your timeline structure.
import {createFrameSampler, getInterpolator, Timeline} from "times-fps";
const tl = Timeline.ofPhases(
["intro", 1],
["main", 2],
["outro", 1]
);
const p = tl.phases;
const sampler = createFrameSampler(progress => {
const map = getInterpolator(progress);
// Map the "intro" phase to a radius range
const introRadius = map(p.intro).to(0, 50);
// Map the "main" phase to an opacity range
const mainOpacity = map(p.main).to(0, 1);
// Map the whole sequence to anchor values
const x = map.sequence(tl.sequence).to(0, 100, 200, 300);
// end-inclusive slicing
const y = map.sequence(tl.sequence.subsequence("main", "outro")).to(0, 100, 200);
return {introRadius, mainOpacity, x, y};
});segment(segment).to(start, end): remap the current progress within a specific segment to a numeric range.segment(segment).withEasing(easing).to(start, end): apply custom easing inside that segment.sequence(sequence).to(...anchors): map the full sequence or a subsequence to a list of anchor values, one more than the number of segments.
Easing & Cubic Bézier Curves
Use built‑in easing functions or create custom cubic‑Bézier easings for your frames.
import {cubicBezierEasing, easeIn, easeOut, easeInOut} from "times-fps";
const customEase = cubicBezierEasing(0.55, 0.085, 0.68, 0.53);
// All easing functions are (t: number) => number
customEase(0.5); // eased value in [0, 1]
easeIn(0.3);
easeOut(0.8);
easeInOut(0.4);Easing functions can be plugged directly into the FrameSampler (to ease global progress) or into the Interpolator when mapping particular segments.
Frame Sampling
Sample your animation at a fixed frames‑per‑second and get back structured frame data.
import {createFrameSampler, type Frame} from "times-fps";
type MyValue = {
x: number;
y: number;
opacity: number;
}
const sampler = createFrameSampler<MyValue>(progress => {
const t = progress.value; // normalized 0 → 1
return {
x: t * 100,
y: t * 50,
opacity: t
};
});
// Lazily iterate frames
for (const frame of sampler.iterate({duration: 2, fps: 60})) {
// frame: { progress: number; value: MyValue }
}
// Or eagerly collect them
const data = sampler.collect({duration: 2, fps: 60});
// data.duration === 2
// data.fps === 60
// data.frames === Frame<MyValue>[]FrameSampler works with nested data: numbers, strings, arrays, and objects, so you can shape the frame values as you like.
Timeline Inspection
Ask timeline‑related questions at any point in time: “has this phase started yet?”, “is this sequence still active?”.
import {createFrameSampler, getTimelineInspector, Timeline} from "times-fps";
const timeline = Timeline.ofPhases(
["intro", 1],
["main", 2],
["outro", 1]
);
const sampler = createFrameSampler(progress => {
const inspect = getTimelineInspector(progress);
const p = timeline.phases;
const intro = inspect(p.intro);
const main = inspect(p.main);
if (intro.isActive()) {
// do something during intro
}
if (main.hasStarted()) {
// do something when main starts
}
return {};
});TimelineInspector is built on top of the same normalized progress value, so it stays in sync with your frame sampling and interpolation.
Exporting Frames (Node.js)
Use the FrameExporter helper to write frame data to disk when running in Node.js—for example, to precompute animation data for a game engine or for SVG path animations.
import {createFrameSampler, cubicBezierEasing, Timeline} from "times-fps";
import {FrameExporter} from "times-fps/exporter";
const timeline = Timeline.ofPhases(
["intro", 1],
["main", 2],
["outro", 1]
);
const sampler = createFrameSampler(progress => {
return { x: progress.value * 100 };
});
await FrameExporter.exportToJson(
sampler.collect({
duration: 3,
fps: 120,
easing: cubicBezierEasing(0.55, 0.085, 0.68, 0.53)
}),
// this path should be relative to `process.cwd()`
"./json-exports",
"frames"
);Note:
exportToJsonis implemented only for the Node.js build (times-fps/exporter); the browser variant throws to guard against accidental use.
WAAPI & requestAnimationFrame
You can use FrameSampler with Web Animations API:
const sampler = createFrameSampler(progress => {
return {x: progress.value * 100};
});
const duration = 3;
const frames = sampler.collect({
duration,
fps: 120,
easing: cubicBezierEasing(0.55, 0.085, 0.68, 0.53)
});
const element = document.getElementById("element-id");
element.animate(frames, {duration: duration * 1000});or requestAnimationFrame:
const sampler = createFrameSampler(progress => {
return {x: progress.value * 100};
});
const duration = 3;
const generator = sampler.iterate({
duration,
fps: 120,
easing: cubicBezierEasing(0.55, 0.085, 0.68, 0.53)
});
const animate = () => {
const next = generator.next();
if (next.done)
return;
// use the value
const value = next.value;
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);You could also do:
const resolver = createFrameSampler(progress => {
return {x: progress.value * 100};
});
const duration = 3;
let progress = 0;
let startTime: number | undefined;
const animate = (time) => {
if (typeof startTime === "undefined")
startTime = time;
progress = (time - startTime) / duration;
if (progress > 1)
return;
// use the value
const value = sampler.sampleAt(progress);
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);Example: Driving an SVG Path Animation
Here is a more complete, end‑to‑end example combining times-fps with another library I authored, svg-path-kit, to precompute SVG path frames:
import {PathBuilder, Point2D, Angle} from "svg-path-kit";
import {
createFrameSampler,
getInterpolator,
Timeline,
cubicBezierEasing
} from "times-fps";
import {FrameExporter} from "times-fps/exporter";
const timeline = Timeline.ofPhases(
["arc1", 1],
["arc2", 1]
);
const p = timeline.phases;
const sampler = createFrameSampler(progress => {
const map = getInterpolator(progress);
const pb = PathBuilder.m(Point2D.ORIGIN);
const arc1Radius = map(p.arc1).to(1, 5);
const arc1EndAngle = map(p.arc1).to(0, 3 * Math.PI / 4);
pb.bezierCircularArc(arc1Radius, Angle.ZERO, arc1EndAngle);
pb.l(pb.currentVelocity);
const arc2Radius = map(p.arc2).to(1, 5);
const arc2EndAngle = map(p.arc2).to(0, -Math.PI);
const lineAngle = pb.currentVelocity.angle;
pb.bezierCircularArc(arc2Radius, Angle.of(lineAngle).halfTurnBackward(), arc2EndAngle);
return pb.toSVGPathString();
});
await FrameExporter.exportToJson(
sampler.collect({
duration: 3,
fps: 120,
easing: cubicBezierEasing(0.55, 0.085, 0.68, 0.53)
}),
"../json-exports",
"path-data"
); // This will store the animation frames in a `path-data.json` fileAPI Overview
The primary entry points you will usually work with are:
Timeline: build normalized timelines with named phases.Timeline.ofPhases(...[name, duration])– create a timeline from labeled durations..phases– a read‑only map of phase names toSegments..sequence– the underlyingSequenceof segments (useful with the interpolator).
createFrameSampler: create a sampler around aprogress => valuefunction..sampleAt(t)– sample a single frame at normalized time (t)..iterate(options)– lazily generate frames..collect(options)– eagerly collect frames along withdurationandfps.
getInterpolator: attach anInterpolatorto a specificAnimationProgress.map(segment).to(start, end)– map progress in a segment to a numeric range.map.segment(segment).withEasing(easing).to(start, end)– map with easing.map.sequence(sequence).to(...anchors)– map a full sequence to anchor values.
getTimelineInspector: derive aTimelineInspectorfrom anAnimationProgress.inspect(segment).hasStarted() / hasFinished() / isActive()– query phase/segment state.
Easing helpers:
cubicBezierEasing(mX1, mY1, mX2, mY2)– create custom easing functions.easeIn,easeOut,easeInOut– standard easing presets.
All of these are exported from the root times-fps entry point, except for FrameExporter, which is available from times-fps/exporter.
Note from Author
Designing animations often means thinking in terms of timelines and phases—“intro”, “main”, “outro”, loops, beats—but many APIs still expose only raw numbers: milliseconds, pixel deltas, easing parameters. I wrote times-fps to bridge that gap: to let me talk about time in the same structured way I sketch animations, while still giving me precise control over individual segments, easing, and sampling.
My goal is to keep this library small, focused, and composable with geometry and rendering toolkits, so you can use it in GUI apps, data visualizations, or SVG art without pulling in heavy dependencies. If you have ideas, suggestions, or corrections, the project is open‑source and I’d be very happy to hear from you.
Thanks a lot!
