Miraiclip SDK
Creative

Keyframes & animation

Animate any clip property with easings.

// Fade a clip in over its first second
project.dispatch({
  type: "keyframe/set",
  payload: { clipId: "title", property: "opacity", timeUs: 0, value: 0, easing: "easeOut" },
});
project.dispatch({
  type: "keyframe/set",
  payload: { clipId: "title", property: "opacity", timeUs: 1_000_000, value: 1 },
});

Keyframe times are relative to the clip's start, in µs. Keyframes play in preview and in every export.

Properties

PropertyRangeClips
x, yNormalized position, like transformAll kinds
scale≥ 0All kinds
rotationDegreesAll kinds
opacity0 to 1All kinds
volume≥ 0video and audio only

A property without keyframes uses the clip's static value (transform or volume). Keyframes on a property replace that value. Custom clip kinds animate the same way.

Commands

// Same time and anchor as an existing keyframe: updates it in place
project.dispatch({
  type: "keyframe/set",
  payload: { clipId: "title", property: "opacity", timeUs: 0, value: 0.5 },
});

// Remove one keyframe: timeUs and anchor must match exactly
project.dispatch({ type: "keyframe/remove", payload: { clipId: "title", property: "opacity", timeUs: 0 } });

// Clear one property, or every property
project.dispatch({ type: "keyframe/clear", payload: { clipId: "title", property: "opacity" } });
project.dispatch({ type: "keyframe/clear", payload: { clipId: "title" } });
CommandPayloadNotes
keyframe/set{ clipId, property, timeUs, value, easing?, anchor? }Upserts by timeUs + anchor. easing defaults to linear
keyframe/remove{ clipId, property, timeUs, anchor? }Rejected with keyframe-not-found if nothing matches
keyframe/clear{ clipId, property? }Omit property to clear all

Rejections: out-of-range (opacity outside 0..1, or negative scale/volume), no-audio (volume on a clip without sound).

Easings

The easing on a keyframe shapes the segment from that keyframe to the next one.

EasingStored as
linear{ kind: "bezier", x1: 0, y1: 0, x2: 1, y2: 1 }
easeIn{ kind: "bezier", x1: 0.42, y1: 0, x2: 1, y2: 1 }
easeOut{ kind: "bezier", x1: 0, y1: 0, x2: 0.58, y2: 1 }
easeInOut{ kind: "bezier", x1: 0.42, y1: 0, x2: 0.58, y2: 1 }
hold{ kind: "hold" }: keep the value, jump at the next keyframe

Preset names are input shorthand. The document always stores the expanded form, so read keyframe.easing.kind, not a preset name. Pass any cubic bézier directly (CSS cubic-bezier() convention):

// Overshoot, then settle
project.dispatch({
  type: "keyframe/set",
  payload: {
    clipId: "title",
    property: "y",
    timeUs: 0,
    value: 0.6,
    easing: { kind: "bezier", x1: 0.34, y1: 1.56, x2: 0.64, y2: 1 },
  },
});

Animate out from the end

With anchor: "end", timeUs counts back from the clip's end (0 is the last instant). Trimming or resizing the clip moves these keyframes with the end.

// Fade out over the last 0.5 s, however long the clip becomes
project.dispatch({
  type: "keyframe/set",
  payload: { clipId: "title", property: "opacity", timeUs: 500_000, anchor: "end", value: 1 },
});
project.dispatch({
  type: "keyframe/set",
  payload: { clipId: "title", property: "opacity", timeUs: 0, anchor: "end", value: 0 },
});
  • Start- and end-anchored keyframes can share a property. If the clip becomes shorter than both animations, the keyframes interleave by time.
  • clip/split drops end-anchored keyframes from the left half.
  • In the document, end-anchored keyframes carry anchor: "end". Start-anchored ones have no anchor field.

Presets

Core ships ready-made animations built from ordinary keyframes. A clip's animation is a recipe with up to three slots: in, loop and out.

import { animationCommands, applyCommands, describeAnimation, readAnimation } from "@miraiclip/core";

const clip = project.getState().doc.clips["title"]!;

applyCommands(
  project,
  animationCommands(clip, {
    in: { preset: "in:pop", durationUs: 600_000, easing: "smooth" },
    loop: { preset: "loop:float", durationUs: 0, easing: "linear" },
    out: { preset: "out:fade", durationUs: 400_000, easing: "smooth" },
  }),
); // one undo step

const { recipe, custom } = readAnimation(project.getState().doc.clips["title"]!);
describeAnimation(recipe); // "Pop in · Float · Fade out"
SlotPreset ids
in, outfade, up, down, left, right, zoom, spin, pop (as in:fade, out:fade, …)
looploop:pulse, loop:float, loop:sway, loop:kenburns
FieldMeaning
presetA preset id from the table above. ANIMATION_PRESETS lists them with labels; animationPreset(id) looks one up
durationUsSlot length. Loops span the time between In and Out, so pass 0
easing"smooth", "linear" or "snappy" (ANIMATION_EASINGS). Loops have fixed timing
HelperWhat it does
animationCommands(clip, recipe)Commands that clear the clip's visual keyframes and set the recipe's. Volume keyframes are kept. {} removes the animation
readAnimation(clip)Recognizes the recipe from the keyframes. custom: true when keyframes match no preset; stale: true when a trim left the loop out of date (apply the recipe again)
describeAnimation(recipe)A short label for a panel
fitAnimation(recipe, clipUs)Shortens In and Out so both fit a clip of that length

Out-slot keyframes are end-anchored, so they follow trims. The recipe itself isn't stored: readAnimation works after a reload or an undo because presets are deterministic.

Reading animated values

import { evaluateClipAt } from "@miraiclip/core";

const clip = project.getState().doc.clips["title"]!;
const local = 750_000; // clip-relative µs
const { x, y, scale, rotation, opacity, volume } = evaluateClipAt(clip, local);
FunctionUse
evaluateClipAt(clip, clipTimeUs)Every animatable value at one time, with static values as fallback
evaluateClipInto(clip, clipTimeUs, out)Same, written into a reused object (no allocation per frame)
evaluateKeyframes(keyframes, timeUs, fallback, clipDurationUs?)One property. Pass clipDurationUs when the list may contain end-anchored keyframes
resolveKeyframes(keyframes, clipDurationUs)Keyframes with every time measured from the clip start, sorted
resolveEasing(input)A preset name or easing in, the stored form out

Before the first keyframe the first value holds; after the last, the last value holds.

On this page