Clips and timelines

A clip is an animation written ahead of time: tracks of keyframes on nested timelines, with labels to seek to and marks to be told of. It is built once, sealed, and then played by any number of players, each on its own targets.

Contents

Building a clip

A clip's storage is one block sized by its def, counted against the context's clip storage; mtwnContext_GetClipBytes says how much a def takes, so a host can grow the context's clipStorageBytes with mtwnContext_Grow for the clips it makes. It takes the context, like every call that reads a def, so that only a library whose ABI the context def matched reads it.

mtwnClipDef clipDef = mtwnDefaultClipDef();
uint64_t bytes = 0;
// Its share of the clip storage.
mtwnResult built = mtwnContext_GetClipBytes(context, &clipDef, &bytes);
mtwnClipId clip;
built = mtwnCreateClip(context, &clipDef, &clip);

mtwnKeyframe keys[2] = {0};
keys[1].value = (mtwnVec4){1.0f, 0, 0, 0};
keys[1].offset = 1.0;
keys[0].easing = keys[1].easing = mtwnMakeLinearEasing();
mtwnClipTrackDef track = mtwnDefaultClipTrackDef(); // on the root at 0, slot 0, a number
track.keyframes = keys;
track.keyframeCount = 2;
track.timing.duration = 0.4;
mtwnNode fadeIn;
built = mtwnClip_AddTrack(context, clip, &track, &fadeIn);

track.slot = 1;
track.position.kind = mtwn_positionAfterPrevious; // when the fade ends
mtwnNode slideIn;
built = mtwnClip_AddTrack(context, clip, &track, &slideIn);
built = mtwnClip_Seal(context, clip);

A track writes a slot, which a player binds to a target; a slot keeps one value kind. The track copies its keyframes into the clip. mtwnClip_Seal closes the clip: from then on it is read only, can be played, and its tracks can be read (mtwnClip_GetTrack) and sampled through the values layer from any thread while the clip lives. A track read so points into the clip's storage, and the values layer takes it as it takes a track you made: it reads only within the counts the track states, so keep the track as it was read.

Positions

Each child is placed when it is added, as GSAP's position parameter places it, with the differences below, and the clip keeps only the resulting start times:

Position Starts
mtwn_positionAbsolute At the offset.
mtwn_positionAfterEnd At the parent's end so far, plus the offset.
mtwn_positionWithPrevious With the previous child's start, plus the offset.
mtwn_positionAfterPrevious At the previous child's end, plus the offset.
mtwn_positionAtLabel At a label of the same parent, plus the offset.

The previous child is the last track or timeline added to the parent; labels and marks are not children for this, and do not lengthen their parent. A position that resolves to no finite start, such as after the end of an endless timeline, is refused.

Porting from GSAP: a clip places each child where GSAP 3.15.0's timeline does once a port translates these (test/test_rival_position.c checks scripts of children, labels and callbacks against GSAP's own start times):

Everything else agrees: absolute starts, the end, the previous child's start and end, labels with offsets, rates and repeats, and a nested timeline that grows after a later sibling is placed, which keeps its start.

Timing and timelines

Every track and timeline has a Web Animations timing (mtwnTiming): delay and end delay, iterations (fractional or infinite) from an iteration start, duration, direction, fill and an easing, plus a playback rate of its own, a repeat delay between iterations and a choice of how reverse iterations ease (time running back, Web Animations' way, or the shape repeated). mtwnComputeTiming gives the phase, iteration and progress at a local time for a host that samples on its own. It returns a status: it refuses a timing mtwnValidateTiming refuses and a local time that is NaN or negative infinity, leaving its result as it was.

mtwnClip_AddTimeline nests a group with timing of its own. By default its duration is its latest child's end, kept as children arrive (endless after an endless child); with autoDuration off its timing's duration clips them. Timelines nest at most MTWN_MAX_CLIP_DEPTH (32) below the root. mtwnClip_AddLabel names a time to seek to, and mtwnClip_AddMark an instant that gives an event each time playback crosses it.

Tracks compose where one helper would elsewhere. DOTween's DOJump is a clip of three tracks: x straight across, y along the line to its end height, and the hops, added over that line, repeating up and down:

// Three hops of 50 over 1.2 s: up and down each 0.4 s, eased out
// going up and back the same way down (DOTween's DOJump).
mtwnKeyframe hop[2] = {0};
hop[1].offset = 1.0;
hop[1].value = (mtwnVec4){50.0f, 0, 0, 0};
made = mtwnMakePowerEasing(mtwn_easeOut, 2.0, &hop[0].easing);
mtwnClipTrackDef jump = mtwnDefaultClipTrackDef();
jump.slot = 2; // bound to the height too, added over its line
jump.keyframes = hop;
jump.keyframeCount = 2;
jump.timing.duration = 0.2;
jump.timing.iterations = 6.0;
jump.timing.direction = mtwn_directionAlternate; // each hop down the way it went up
jump.timing.fill = mtwn_fillForwards;            // landed, it stays
mtwnNode hops;
made = mtwnClip_AddTrack(context, clip, &jump, &hops);

Its slots are bound to x, to y, and to y again, adding:

mtwnBinding bindings[3] = {mtwnMakeBinding(0, x), mtwnMakeBinding(1, y), mtwnMakeBinding(2, y)};
bindings[2].composite = mtwn_compositeAdd;

A timeline's easing shapes its children's time. Eased by steps(), it holds them at as many poses, the stepped look of animation on twos or After Effects' Posterize Time:

// Held 12 times a second (on twos at 24 frames): 2 s in 24 steps.
mtwnClipTimelineDef twos = mtwnDefaultClipTimelineDef();
twos.timing.duration = 2.0;
made = mtwnMakeStepsEasing(24, mtwn_jumpEnd, &twos.timing.easing);
mtwnNode held;
made = mtwnClip_AddTimeline(context, clip, &twos, &held);

Tracks added with held as their parent move in steps; the clip's others stay smooth. The holds fall on a grid from the timeline's own start, a whole number of them: to hold on the clock's twelfths, start the timeline at a whole number of twelfths and make its duration a whole number of holds (2 s at 12 a second is 24 steps; 1.3 s would be 15.6, which steps() cannot take). To step everything a context animates, the host rounds its clock down to the rate before each update instead.

A track with incremental set moves on each iteration by its last keyframe minus its first, so a repeated step climbs and a repeated spin turns on.

Staggers

A stagger spreads offsets over items by their distance from an origin: the first or last item, the center, the nearest edge, an item, a point of the grid, or an order from a seed.

mtwnStagger stagger = mtwnDefaultStagger(); // 0.1 s apart, from the first
stagger.columns = 4;                        // a grid of four columns
stagger.origin = mtwn_staggerCenter;        // spreading from its center
double delays[12];
mtwnResult spread = mtwnComputeStagger(&stagger, 12, delays);

The offsets go wherever a time does, such as a track's position offset or a transition's delay, or where a value does, such as a scale from item to item. Items one cell apart are the spacing apart, or with mtwn_staggerAmount the spacing is the whole spread; an axis measures along the columns or the rows only, and an easing redistributes the offsets. A negative spacing reverses the order as GSAP's distribute does: the farthest items take the start and the nearest the start plus the spread, on the easing turned around, so no offset falls below the start and the offsets stay valid delays. Every offset written is finite: a stagger whose start plus its spread times an eased distance would pass the largest double is refused, with nothing written. The easing is sampled at every item before an offset is written, so a host easing that gives a value not finite only when sampled again is refused too, as a rough easing's shape is, and leaves the offsets unspecified.

Porting from GSAP: a stagger gives the offsets of GSAP 3.15.0's distribute, within its rounding, once a port translates these, in all but one case (test/test_rival_stagger.c checks every one against GSAP's own outputs):

Not obvious