@maravilla-labs/frames
v0.7.0
Published
Tiny vanilla helper for declaring Web Animations API timelines that the Maravilla runtime renderer drives frame-accurately.
Maintainers
Readme
@maravilla-labs/frames
Declare a timeline of animations and video playback for a page. The same declaration works two ways:
- In a normal browser, each item plays at its scheduled time on its own.
- When the page is rendered to video by the Maravilla runtime, the timeline is stepped frame by frame to produce a deterministic clip.
Zero dependencies. Bring your own animation library if you want one; nothing is required.
Install
pnpm add @maravilla-labs/framesUsage
import { defineTimeline } from "@maravilla-labs/frames";
defineTimeline({
duration: 5000, // total timeline length, ms
instructions: [
// Title fades in from t=0 over the first 500 ms.
{
at: 0,
duration: 500,
selector: "h1",
props: [{ opacity: 0 }, { opacity: 1 }],
},
// Subtitle slides up, starting 300 ms after the title begins.
{
at: 300,
duration: 600,
selector: ".subtitle",
props: [
{ opacity: 0, transform: "translateY(20px)" },
{ opacity: 1, transform: "translateY(0)" },
],
},
// A clip plays from t=2000 ms until t=5000 ms (3 s of video).
{
kind: "video",
at: 2000,
duration: 3000,
selector: "video.intro",
},
],
});Call defineTimeline once, after the elements referenced by every
selector exist in the DOM.
How at and duration interact
| Field | Meaning |
| ---------- | ------------------------------------------------------------------ |
| at | When on the timeline this item starts, in ms |
| duration | How long this item runs from at, in ms. Omit for sensible default |
Two animations with the same at start at the same time. Two
animations with different at values start at different times. A
duration of 500 ms means the animation reaches its end keyframe
500 ms after it started, regardless of how long the total timeline is.
If you omit duration on an animation, it runs from at until the
end of the timeline. If you omit duration on a video, it plays for
its natural length.
Animations
Each animation instruction maps to a single
Animation
created via Element.animate(keyframes, { duration, fill: "both" }).
Anything the Web Animations API supports as a keyframe is valid in
props:
{
at: 1000,
duration: 800,
selector: ".card",
props: [
{ transform: "scale(0.9)", opacity: 0 },
{ transform: "scale(1)", opacity: 1 },
],
}CSS-class animations (Animate.css and friends)
Use kind: "css" to schedule an animation defined as CSS @keyframes
and triggered by a class — for example anything from
Animate.css:
{
kind: "css",
at: 1400,
duration: 1000, // optional — defaults to the longest keyframes duration on the element
selector: ".badge",
classes: ["animate__animated", "animate__bounceInDown"],
}The helper adds the classes, captures the resulting CSS animation as
a WAAPI Animation object via Element.getAnimations(), and drives
its currentTime exactly like every other slot. The visual is the
class's native CSS animation; the timing is yours to control.
The stylesheet that defines the keyframes must be loaded before the
animations are materialised — but you don't need to do anything for
that. defineTimeline waits internally for render-blocking
stylesheets and fonts to be ready before it reads the keyframes, so you
can just call it as soon as your module runs:
defineTimeline({ /* ... */ });Do not gate
defineTimelinebehind the windowloadevent (await new Promise(r => addEventListener("load", r))).loadis also held open by<video preload>and<img>, which on a media-heavy page can take long enough to blow past the Maravilla renderer's readiness budget and fail the render with "timeline never became available."defineTimelinealready waits for exactly what it needs (stylesheets + fonts) and nothing it doesn't (media). If you need that signal yourself, importstylesheetsReady().
This works for any library that exposes its motion as CSS classes
plus shipped @keyframes — Animate.css, Magic, your own custom
keyframes — not just Animate.css specifically.
When you pass an explicit duration, the helper writes
animation-duration inline with !important so it beats any
@media (prefers-reduced-motion: reduce) rule the library ships with
(Animate.css collapses to 1 ms otherwise). You opted into this
timeline; the helper assumes you mean it.
Pre-hiding the element
If you need the element invisible before its at time, use
visibility: hidden in your baseline CSS — not opacity: 0. The
helper flips visibility: visible automatically when the css
instruction fires.
.badge { visibility: hidden; }Why not opacity: 0? Animate.css's "*In" keyframes (bounceInDown,
fadeInUp, etc.) define opacity at the start and middle of the
animation but not at 100%. CSS fills missing keyframe values with
the element's underlying cascade value — so a baseline of
opacity: 0 makes the animation interpolate 1 → 0 at the tail end
and the element fades back out as soon as it lands. visibility is
not animated by the keyframes, so it stays cleanly out of the way.
Video
Use kind: "video" to schedule a <video> element on the timeline.
Optionally seek inside the source file with seek:
{
kind: "video",
at: 4000, // start playing at t=4 s on the timeline
duration: 6000, // play for 6 s
seek: 12000, // start 12 s into the source file
selector: "video#hero",
}The <video> element itself stays as you'd write it in HTML — set
src, preload="auto", and muted so the browser autoplay policy
doesn't block it.
<video class="intro" src="/clips/intro.mp4" preload="auto" muted playsinline></video>The audio track of an embedded <video> is not captured (browsers
don't expose decoded audio to the rendering API). Add a kind: "audio"
instruction (below) instead — or, for a single pre-mixed soundtrack, the
audio option on FRAMES.render(...).
Audio
Declare time-bound audio tracks right in the timeline. They are mixed into the final video by the renderer (multi-track ffmpeg, server-side) and played in the browser preview by a built-in Web Audio engine — so preview matches render.
defineTimeline({
duration: 12000,
instructions: [
// Background music bed for the whole piece.
{ kind: "audio", at: 0, duration: 12000, role: "music",
src: "/audio/bed.m4a", key: "audio/bed.m4a", fadeIn: 400, fadeOut: 800 },
// A voiceover from 3s; music automatically ducks beneath it.
{ kind: "audio", at: 3000, role: "voiceover",
src: "/audio/vo.m4a", key: "audio/vo.m4a" },
],
});Fields: at / duration / seek (ms, like video); src — a URL the
browser preview fetches + decodes; key — the storage key the renderer
stages to mix (omit for preview-only tracks); role —
"voiceover" | "music" | "sfx" (default "music"); gain (linear,
default 1); fadeIn / fadeOut (ms); duck — whether a music bed dips
under voiceover (default true for role: "music").
Ducking is a static envelope computed from the voiceover clips' positions (not their loudness), so it's deterministic and the preview reproduces the exact dip the export bakes in.
Preview transport. Scrubbing via applyState(t) stays silent (like
any NLE); audio is heard during continuous playback. Drive it from your
player with window.__mvFrames.playAudio(fromMs) /
stopAudio() / setAudioMuted(muted). The renderer reads the tracks it
mixes from window.__mvFrames.audio.
Bring your own animation library
Any library that exposes an explicit time value works alongside this helper. Drive its time cursor inside the same applyState-equivalent hook and the renderer samples it deterministically. GSAP, Lottie, and Three.js all fit this shape.
License
MIT
