@maravilla-labs/frames 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,92 +1,175 @@
1
1
  # @maravilla-labs/frames
2
2
 
3
- A tiny, zero-dependency helper that lets a page declare a Web Animations
4
- API timeline once and have it driven two different ways:
3
+ Declare a timeline of animations and video playback for a page. The same
4
+ declaration works two ways:
5
5
 
6
- - **In a normal browser:** the animations auto-play as soon as the page
7
- loads just like any CSS or WAAPI animation.
8
- - **In the Maravilla deterministic renderer:** the animations stay
9
- paused and the Rust renderer steps `currentTime` frame-by-frame via
10
- `window.__mvFrames.applyState(t)`, producing a deterministic MP4.
6
+ - In a normal browser, each item plays at its scheduled time on its own.
7
+ - When the page is rendered to video by the Maravilla runtime, the
8
+ timeline is stepped frame by frame to produce a deterministic clip.
11
9
 
12
- The whole package is under 50 lines of vanilla TypeScript. No
13
- framework, no scheduler, no `requestAnimationFrame`, no `Date.now()`.
10
+ Zero dependencies. Bring your own animation library if you want one;
11
+ nothing is required.
14
12
 
15
- ## Web Animations API model
13
+ ## Install
16
14
 
17
- Each entry in `TimelineSchema.instructions` is mapped to a single
15
+ ```sh
16
+ pnpm add @maravilla-labs/frames
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { defineTimeline } from "@maravilla-labs/frames";
23
+
24
+ defineTimeline({
25
+ duration: 5000, // total timeline length, ms
26
+
27
+ instructions: [
28
+ // Title fades in from t=0 over the first 500 ms.
29
+ {
30
+ at: 0,
31
+ duration: 500,
32
+ selector: "h1",
33
+ props: [{ opacity: 0 }, { opacity: 1 }],
34
+ },
35
+
36
+ // Subtitle slides up, starting 300 ms after the title begins.
37
+ {
38
+ at: 300,
39
+ duration: 600,
40
+ selector: ".subtitle",
41
+ props: [
42
+ { opacity: 0, transform: "translateY(20px)" },
43
+ { opacity: 1, transform: "translateY(0)" },
44
+ ],
45
+ },
46
+
47
+ // A clip plays from t=2000 ms until t=5000 ms (3 s of video).
48
+ {
49
+ kind: "video",
50
+ at: 2000,
51
+ duration: 3000,
52
+ selector: "video.intro",
53
+ },
54
+ ],
55
+ });
56
+ ```
57
+
58
+ Call `defineTimeline` once, after the elements referenced by every
59
+ `selector` exist in the DOM.
60
+
61
+ ## How `at` and `duration` interact
62
+
63
+ | Field | Meaning |
64
+ | ---------- | ------------------------------------------------------------------ |
65
+ | `at` | When on the timeline this item starts, in ms |
66
+ | `duration` | How long this item runs from `at`, in ms. Omit for sensible default |
67
+
68
+ Two animations with the same `at` start at the same time. Two
69
+ animations with different `at` values start at different times. A
70
+ `duration` of `500` ms means the animation reaches its end keyframe
71
+ 500 ms after it started, regardless of how long the total timeline is.
72
+
73
+ If you omit `duration` on an animation, it runs from `at` until the
74
+ end of the timeline. If you omit `duration` on a video, it plays for
75
+ its natural length.
76
+
77
+ ## Animations
78
+
79
+ Each animation instruction maps to a single
18
80
  [`Animation`](https://developer.mozilla.org/en-US/docs/Web/API/Animation)
19
- created by `Element.animate(keyframes, { duration, fill: "both" })`.
20
- Because the animation has `fill: "both"`, setting `currentTime` to any
21
- value in `[0, duration]` deterministically positions every animated
22
- property — no implicit clock involved.
81
+ created via `Element.animate(keyframes, { duration, fill: "both" })`.
82
+ Anything the Web Animations API supports as a keyframe is valid in
83
+ `props`:
23
84
 
24
- The renderer signals its presence by setting
25
- `window.__mvFramesRendererPresent = true` **before** navigating to the
26
- page. `defineTimeline` reads that flag at call time:
85
+ ```ts
86
+ {
87
+ at: 1000,
88
+ duration: 800,
89
+ selector: ".card",
90
+ props: [
91
+ { transform: "scale(0.9)", opacity: 0 },
92
+ { transform: "scale(1)", opacity: 1 },
93
+ ],
94
+ }
95
+ ```
27
96
 
28
- - flag set each animation is paused immediately; the renderer drives
29
- state via `applyState(t)`.
30
- - flag unset ⇒ animations play normally, so humans visiting the page
31
- can preview what the renderer will capture.
97
+ ## CSS-class animations (Animate.css and friends)
32
98
 
33
- ## Usage
99
+ Use `kind: "css"` to schedule an animation defined as CSS `@keyframes`
100
+ and triggered by a class — for example anything from
101
+ [Animate.css](https://animate.style/):
34
102
 
35
- ```html
36
- <!doctype html>
37
- <html>
38
- <body>
39
- <h1 id="title">Hello</h1>
40
- <script type="module">
41
- import { defineTimeline } from "@maravilla-labs/frames";
42
-
43
- defineTimeline({
44
- duration: 2000,
45
- instructions: [
46
- { at: 0, selector: "#title", props: [{ opacity: 0 }, { opacity: 1 }] },
47
- ],
48
- });
49
- </script>
50
- </body>
51
- </html>
103
+ ```ts
104
+ {
105
+ kind: "css",
106
+ at: 1400,
107
+ duration: 1000, // optional — defaults to the longest keyframes duration on the element
108
+ selector: ".badge",
109
+ classes: ["animate__animated", "animate__bounceInDown"],
110
+ }
52
111
  ```
53
112
 
54
- In Rust the renderer then does, per frame `i`:
113
+ The helper adds the classes, captures the resulting CSS animation as
114
+ a WAAPI `Animation` object via `Element.getAnimations()`, and drives
115
+ its `currentTime` exactly like every other slot. The visual is the
116
+ class's native CSS animation; the timing is yours to control.
117
+
118
+ The stylesheet that defines the keyframes must be loaded before
119
+ `defineTimeline` runs. The simplest pattern is to `await` the page
120
+ `load` event first inside a `<script type="module">`:
55
121
 
56
- ```js
57
- await window.__mvFrames.applyState(i * frameMs);
122
+ ```ts
123
+ await new Promise(r => addEventListener("load", r, { once: true }));
124
+ defineTimeline({ /* ... */ });
58
125
  ```
59
126
 
60
- followed by a CDP `HeadlessExperimental.beginFrame` call that captures
61
- the rendered pixelssee the runtime plan for the full pipeline.
127
+ This works for any library that exposes its motion as CSS classes
128
+ plus shipped `@keyframes`Animate.css, Magic, your own custom
129
+ keyframes — not just Animate.css specifically.
62
130
 
63
- ## API
131
+ When you pass an explicit `duration`, the helper writes
132
+ `animation-duration` inline with `!important` so it beats any
133
+ `@media (prefers-reduced-motion: reduce)` rule the library ships with
134
+ (Animate.css collapses to 1 ms otherwise). You opted into this
135
+ timeline; the helper assumes you mean it.
64
136
 
65
- ### `defineTimeline(schema: TimelineSchema): void`
137
+ ## Video
66
138
 
67
- Sets up the per-page animations and installs
68
- `window.__mvFrames.applyState`. Call it once, after the elements
69
- referenced by every `selector` exist in the DOM.
139
+ Use `kind: "video"` to schedule a `<video>` element on the timeline.
140
+ Optionally seek inside the source file with `seek`:
70
141
 
71
142
  ```ts
72
- type FrameInstr = {
73
- at: number; // start offset, ms (currently informational)
74
- selector: string; // any querySelector
75
- props: Keyframe[]; // WAAPI keyframes, e.g. [{ opacity: 0 }, { opacity: 1 }]
76
- };
77
- type TimelineSchema = {
78
- duration: number; // ms — total length the renderer will step through
79
- instructions: FrameInstr[];
80
- };
143
+ {
144
+ kind: "video",
145
+ at: 4000, // start playing at t=4 s on the timeline
146
+ duration: 6000, // play for 6 s
147
+ seek: 12000, // start 12 s into the source file
148
+ selector: "video#hero",
149
+ }
81
150
  ```
82
151
 
83
- Throws if any `selector` doesn't match an element. Do not call
84
- `defineTimeline` more than once per page.
152
+ The `<video>` element itself stays as you'd write it in HTML set
153
+ `src`, `preload="auto"`, and `muted` so the browser autoplay policy
154
+ doesn't block it.
155
+
156
+ ```html
157
+ <video class="intro" src="/clips/intro.mp4" preload="auto" muted playsinline></video>
158
+ ```
159
+
160
+ If the same composition will be rendered to a final video by the
161
+ Maravilla runtime, the audio track of the embedded `<video>` is not
162
+ captured (browsers don't expose decoded audio to the rendering API).
163
+ Use the `audio` option on `FRAMES.render(...)` to mix a soundtrack
164
+ into the final output instead.
85
165
 
86
166
  ## Bring your own animation library
87
167
 
88
- Any library that exposes an explicit time value works the same way:
89
- GSAP, Lottie, Three.js. Drive their time cursor inside an
90
- `applyState`-equivalent and the renderer will sample them
91
- deterministically. `defineTimeline` is just the trivial WAAPI case
92
- that ships in the box.
168
+ Any library that exposes an explicit time value works alongside this
169
+ helper. Drive its time cursor inside the same applyState-equivalent
170
+ hook and the renderer samples it deterministically. GSAP, Lottie, and
171
+ Three.js all fit this shape.
172
+
173
+ ## License
174
+
175
+ MIT
package/dist/index.d.ts CHANGED
@@ -1,20 +1,63 @@
1
1
  /**
2
- * @maravilla-labs/frames — declare Web Animations API timelines that the
3
- * Maravilla runtime renderer can drive frame-accurately.
2
+ * @maravilla-labs/frames — declare a timeline of animations, video, and
3
+ * CSS-class-driven animations (Animate.css, etc.) for a page. The same
4
+ * declaration works two ways:
4
5
  *
5
- * In a normal browser visit, animations auto-play.
6
- * In the renderer (which sets `window.__mvFramesRendererPresent = true`
7
- * before navigating), animations stay paused and the renderer steps
8
- * them via `window.__mvFrames.applyState(t)` for deterministic output.
6
+ * - In a normal browser, each item plays at its scheduled `at` time.
7
+ * - Inside the Maravilla renderer, the timeline is stepped frame by
8
+ * frame to produce deterministic video output.
9
9
  */
10
- export type FrameInstr = {
10
+ export type AnimationInstr = {
11
+ /** Optional discriminator. Defaults to `"animation"`. */
12
+ kind?: "animation";
13
+ /** When on the timeline this animation starts, in milliseconds. */
11
14
  at: number;
15
+ /** How long the animation runs from `at`, in ms. Defaults to (schema.duration - at). */
16
+ duration?: number;
17
+ /** CSS selector for the element being animated. */
12
18
  selector: string;
19
+ /** Web Animations API keyframes, e.g. `[{ opacity: 0 }, { opacity: 1 }]`. */
13
20
  props: Keyframe[];
14
21
  };
22
+ export type VideoInstr = {
23
+ /** Discriminator — required for video instructions. */
24
+ kind: "video";
25
+ /** When on the timeline the video starts, in milliseconds. */
26
+ at: number;
27
+ /** How long the clip plays, in ms. Defaults to the video's natural duration. */
28
+ duration?: number;
29
+ /** Start offset WITHIN the video file, in ms. Defaults to 0. */
30
+ seek?: number;
31
+ /** CSS selector for the `<video>` element. */
32
+ selector: string;
33
+ };
34
+ /**
35
+ * Drive a CSS-class-triggered animation (Animate.css, Magic, your own
36
+ * `@keyframes`) through the same timeline as everything else. The
37
+ * classes are added to the element, the resulting CSS animations are
38
+ * captured via `Element.getAnimations()`, and the helper drives their
39
+ * `currentTime` exactly like a WAAPI animation.
40
+ *
41
+ * Make sure the stylesheet that defines the keyframes is loaded *before*
42
+ * `defineTimeline` runs — otherwise the class addition produces no
43
+ * animation to drive.
44
+ */
45
+ export type CssClassInstr = {
46
+ /** Discriminator — required. */
47
+ kind: "css";
48
+ /** When on the timeline the animation starts, in milliseconds. */
49
+ at: number;
50
+ /** How long the animation runs. Defaults to the longest CSS @keyframes duration on the element. */
51
+ duration?: number;
52
+ /** CSS selector for the target element. */
53
+ selector: string;
54
+ /** Class names to add, e.g. `["animate__animated", "animate__bounceInDown"]`. */
55
+ classes: string[];
56
+ };
57
+ export type Instr = AnimationInstr | VideoInstr | CssClassInstr;
15
58
  export type TimelineSchema = {
16
59
  duration: number;
17
- instructions: FrameInstr[];
60
+ instructions: Instr[];
18
61
  };
19
62
  declare global {
20
63
  interface Window {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,UAAU,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,QAAQ,EAAE,CAAA;CAAE,CAAC;AAC7E,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC;AAE9E,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,UAAU,EAAE;YAAE,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;SAAE,CAAC;QACrD,yBAAyB,CAAC,EAAE,OAAO,CAAC;KACrC;CACF;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,QAkBpD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,cAAc,GAAG;IAC3B,yDAAyD;IACzD,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,mEAAmE;IACnE,EAAE,EAAE,MAAM,CAAC;IACX,wFAAwF;IACxF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,uDAAuD;IACvD,IAAI,EAAE,OAAO,CAAC;IACd,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,gCAAgC;IAChC,IAAI,EAAE,KAAK,CAAC;IACZ,kEAAkE;IAClE,EAAE,EAAE,MAAM,CAAC;IACX,mGAAmG;IACnG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG,cAAc,GAAG,UAAU,GAAG,aAAa,CAAC;AAChE,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC;AAEzE,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,UAAU,EAAE;YAAE,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;SAAE,CAAC;QACrD,yBAAyB,CAAC,EAAE,OAAO,CAAC;KACrC;CACF;AAaD,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAoH3D"}
package/dist/index.js CHANGED
@@ -1,29 +1,126 @@
1
1
  /**
2
- * @maravilla-labs/frames — declare Web Animations API timelines that the
3
- * Maravilla runtime renderer can drive frame-accurately.
2
+ * @maravilla-labs/frames — declare a timeline of animations, video, and
3
+ * CSS-class-driven animations (Animate.css, etc.) for a page. The same
4
+ * declaration works two ways:
4
5
  *
5
- * In a normal browser visit, animations auto-play.
6
- * In the renderer (which sets `window.__mvFramesRendererPresent = true`
7
- * before navigating), animations stay paused and the renderer steps
8
- * them via `window.__mvFrames.applyState(t)` for deterministic output.
6
+ * - In a normal browser, each item plays at its scheduled `at` time.
7
+ * - Inside the Maravilla renderer, the timeline is stepped frame by
8
+ * frame to produce deterministic video output.
9
9
  */
10
10
  export function defineTimeline(schema) {
11
11
  const rendererPresent = typeof window !== "undefined" && window.__mvFramesRendererPresent === true;
12
- const animations = schema.instructions.map((i) => {
12
+ const slots = schema.instructions.flatMap((i) => {
13
13
  const el = document.querySelector(i.selector);
14
14
  if (!el)
15
15
  throw new Error(`defineTimeline: no element matches ${i.selector}`);
16
- const anim = el.animate(i.props, { duration: schema.duration, fill: "both" });
17
- if (rendererPresent)
18
- anim.pause();
19
- return { at: i.at, anim };
16
+ if (i.kind === "video") {
17
+ if (!(el instanceof HTMLVideoElement)) {
18
+ throw new Error(`defineTimeline: ${i.selector} is not a <video> element`);
19
+ }
20
+ const seek = i.seek ?? 0;
21
+ const naturalMs = isFinite(el.duration) ? el.duration * 1000 : Infinity;
22
+ const duration = i.duration ?? Math.min(naturalMs, schema.duration - i.at);
23
+ el.pause();
24
+ el.currentTime = seek / 1000;
25
+ return [{ kind: "video", at: i.at, duration, seek, el }];
26
+ }
27
+ if (i.kind === "css") {
28
+ if (!(el instanceof HTMLElement)) {
29
+ throw new Error(`defineTimeline: ${i.selector} must be an HTMLElement for kind: "css"`);
30
+ }
31
+ // In browser preview, defer adding the classes until the scheduled
32
+ // time — the CSS animation then plays naturally from t=0.
33
+ if (!rendererPresent) {
34
+ return [{
35
+ kind: "css-pending",
36
+ at: i.at,
37
+ el,
38
+ classes: i.classes,
39
+ duration: i.duration ?? null,
40
+ }];
41
+ }
42
+ // In renderer mode, add the classes now to materialise the CSS
43
+ // animations as WAAPI Animation objects, then pause and drive them
44
+ // via applyState.
45
+ const before = new Set(el.getAnimations());
46
+ el.classList.add(...i.classes);
47
+ const newAnims = el.getAnimations().filter((a) => !before.has(a));
48
+ if (newAnims.length === 0) {
49
+ throw new Error(`defineTimeline: classes [${i.classes.join(", ")}] produced no animation on ${i.selector}. ` +
50
+ `Is the stylesheet that defines those keyframes loaded before defineTimeline runs?`);
51
+ }
52
+ const natural = newAnims.reduce((max, a) => {
53
+ const t = a.effect?.getTiming();
54
+ const d = typeof t?.duration === "number" ? t.duration : 0;
55
+ return Math.max(max, d);
56
+ }, 0);
57
+ // Always honour the user's explicit `duration` (or fall back to the
58
+ // longest captured CSS animation). Override `animation-duration`
59
+ // inline with !important so a stylesheet's
60
+ // `@media (prefers-reduced-motion: reduce)` rule — which Animate.css
61
+ // ships and which collapses durations to 1ms — can't silently shrink
62
+ // the timeline. Users opt into this helper specifically to drive
63
+ // motion; "reduced motion" applies to involuntary motion, not the
64
+ // timeline they're authoring.
65
+ const duration = i.duration ?? (natural > 0 ? natural : Math.max(0, schema.duration - i.at));
66
+ el.style.setProperty("animation-duration", duration + "ms", "important");
67
+ return newAnims.map((anim) => {
68
+ anim.effect?.updateTiming({ duration });
69
+ anim.pause();
70
+ anim.currentTime = 0;
71
+ return { kind: "animation", at: i.at, duration, anim };
72
+ });
73
+ }
74
+ // Standard WAAPI animation.
75
+ const duration = i.duration ?? Math.max(0, schema.duration - i.at);
76
+ const anim = el.animate(i.props, { duration, fill: "both" });
77
+ anim.pause();
78
+ return [{ kind: "animation", at: i.at, duration, anim }];
20
79
  });
21
80
  window.__mvFrames = {
22
81
  async applyState(t) {
23
- for (const { anim } of animations)
24
- anim.currentTime = t;
82
+ for (const s of slots) {
83
+ if (s.kind === "css-pending")
84
+ continue; // only relevant in browser preview
85
+ const local = clamp(t - s.at, 0, s.duration);
86
+ if (s.kind === "video") {
87
+ s.el.pause();
88
+ s.el.currentTime = (s.seek + local) / 1000;
89
+ }
90
+ else {
91
+ s.anim.currentTime = local;
92
+ }
93
+ }
25
94
  await document.fonts.ready;
26
95
  },
27
96
  };
97
+ if (!rendererPresent) {
98
+ for (const s of slots) {
99
+ window.setTimeout(() => {
100
+ if (s.kind === "video") {
101
+ s.el.currentTime = s.seek / 1000;
102
+ void s.el.play();
103
+ }
104
+ else if (s.kind === "css-pending") {
105
+ // Override `animation-duration` BEFORE adding the classes so
106
+ // the inline !important rule beats Animate.css's
107
+ // `prefers-reduced-motion` media query (which would otherwise
108
+ // collapse the animation to 1ms). If the user didn't pass an
109
+ // explicit duration, leave it alone and let the stylesheet's
110
+ // default apply.
111
+ if (s.duration !== null) {
112
+ s.el.style.setProperty("animation-duration", s.duration + "ms", "important");
113
+ }
114
+ s.el.classList.add(...s.classes);
115
+ }
116
+ else {
117
+ s.anim.play();
118
+ }
119
+ }, s.at);
120
+ }
121
+ }
122
+ }
123
+ function clamp(v, lo, hi) {
124
+ return v < lo ? lo : v > hi ? hi : v;
28
125
  }
29
126
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,MAAM,UAAU,cAAc,CAAC,MAAsB;IACnD,MAAM,eAAe,GACnB,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,CAAC,yBAAyB,KAAK,IAAI,CAAC;IAE7E,MAAM,UAAU,GAAG,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QAC/C,MAAM,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC9C,IAAI,CAAC,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC7E,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC9E,IAAI,eAAe;YAAE,IAAI,CAAC,KAAK,EAAE,CAAC;QAClC,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,UAAU,GAAG;QAClB,KAAK,CAAC,UAAU,CAAC,CAAS;YACxB,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,UAAU;gBAAE,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;YACxD,MAAM,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC;QAC7B,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/frames",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Tiny vanilla helper for declaring Web Animations API timelines that the Maravilla runtime renderer drives frame-accurately.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1,15 +1,65 @@
1
1
  /**
2
- * @maravilla-labs/frames — declare Web Animations API timelines that the
3
- * Maravilla runtime renderer can drive frame-accurately.
2
+ * @maravilla-labs/frames — declare a timeline of animations, video, and
3
+ * CSS-class-driven animations (Animate.css, etc.) for a page. The same
4
+ * declaration works two ways:
4
5
  *
5
- * In a normal browser visit, animations auto-play.
6
- * In the renderer (which sets `window.__mvFramesRendererPresent = true`
7
- * before navigating), animations stay paused and the renderer steps
8
- * them via `window.__mvFrames.applyState(t)` for deterministic output.
6
+ * - In a normal browser, each item plays at its scheduled `at` time.
7
+ * - Inside the Maravilla renderer, the timeline is stepped frame by
8
+ * frame to produce deterministic video output.
9
9
  */
10
10
 
11
- export type FrameInstr = { at: number; selector: string; props: Keyframe[] };
12
- export type TimelineSchema = { duration: number; instructions: FrameInstr[] };
11
+ export type AnimationInstr = {
12
+ /** Optional discriminator. Defaults to `"animation"`. */
13
+ kind?: "animation";
14
+ /** When on the timeline this animation starts, in milliseconds. */
15
+ at: number;
16
+ /** How long the animation runs from `at`, in ms. Defaults to (schema.duration - at). */
17
+ duration?: number;
18
+ /** CSS selector for the element being animated. */
19
+ selector: string;
20
+ /** Web Animations API keyframes, e.g. `[{ opacity: 0 }, { opacity: 1 }]`. */
21
+ props: Keyframe[];
22
+ };
23
+
24
+ export type VideoInstr = {
25
+ /** Discriminator — required for video instructions. */
26
+ kind: "video";
27
+ /** When on the timeline the video starts, in milliseconds. */
28
+ at: number;
29
+ /** How long the clip plays, in ms. Defaults to the video's natural duration. */
30
+ duration?: number;
31
+ /** Start offset WITHIN the video file, in ms. Defaults to 0. */
32
+ seek?: number;
33
+ /** CSS selector for the `<video>` element. */
34
+ selector: string;
35
+ };
36
+
37
+ /**
38
+ * Drive a CSS-class-triggered animation (Animate.css, Magic, your own
39
+ * `@keyframes`) through the same timeline as everything else. The
40
+ * classes are added to the element, the resulting CSS animations are
41
+ * captured via `Element.getAnimations()`, and the helper drives their
42
+ * `currentTime` exactly like a WAAPI animation.
43
+ *
44
+ * Make sure the stylesheet that defines the keyframes is loaded *before*
45
+ * `defineTimeline` runs — otherwise the class addition produces no
46
+ * animation to drive.
47
+ */
48
+ export type CssClassInstr = {
49
+ /** Discriminator — required. */
50
+ kind: "css";
51
+ /** When on the timeline the animation starts, in milliseconds. */
52
+ at: number;
53
+ /** How long the animation runs. Defaults to the longest CSS @keyframes duration on the element. */
54
+ duration?: number;
55
+ /** CSS selector for the target element. */
56
+ selector: string;
57
+ /** Class names to add, e.g. `["animate__animated", "animate__bounceInDown"]`. */
58
+ classes: string[];
59
+ };
60
+
61
+ export type Instr = AnimationInstr | VideoInstr | CssClassInstr;
62
+ export type TimelineSchema = { duration: number; instructions: Instr[] };
13
63
 
14
64
  declare global {
15
65
  interface Window {
@@ -18,22 +68,135 @@ declare global {
18
68
  }
19
69
  }
20
70
 
21
- export function defineTimeline(schema: TimelineSchema) {
71
+ type AnimSlot = { kind: "animation"; at: number; duration: number; anim: Animation };
72
+ type VideoSlot = { kind: "video"; at: number; duration: number; seek: number; el: HTMLVideoElement };
73
+ type CssPendingSlot = {
74
+ kind: "css-pending";
75
+ at: number;
76
+ el: HTMLElement;
77
+ classes: string[];
78
+ duration: number | null;
79
+ };
80
+ type Slot = AnimSlot | VideoSlot | CssPendingSlot;
81
+
82
+ export function defineTimeline(schema: TimelineSchema): void {
22
83
  const rendererPresent =
23
84
  typeof window !== "undefined" && window.__mvFramesRendererPresent === true;
24
85
 
25
- const animations = schema.instructions.map((i) => {
86
+ const slots: Slot[] = schema.instructions.flatMap<Slot>((i) => {
26
87
  const el = document.querySelector(i.selector);
27
88
  if (!el) throw new Error(`defineTimeline: no element matches ${i.selector}`);
28
- const anim = el.animate(i.props, { duration: schema.duration, fill: "both" });
29
- if (rendererPresent) anim.pause();
30
- return { at: i.at, anim };
89
+
90
+ if (i.kind === "video") {
91
+ if (!(el instanceof HTMLVideoElement)) {
92
+ throw new Error(`defineTimeline: ${i.selector} is not a <video> element`);
93
+ }
94
+ const seek = i.seek ?? 0;
95
+ const naturalMs = isFinite(el.duration) ? el.duration * 1000 : Infinity;
96
+ const duration = i.duration ?? Math.min(naturalMs, schema.duration - i.at);
97
+ el.pause();
98
+ el.currentTime = seek / 1000;
99
+ return [{ kind: "video", at: i.at, duration, seek, el }];
100
+ }
101
+
102
+ if (i.kind === "css") {
103
+ if (!(el instanceof HTMLElement)) {
104
+ throw new Error(`defineTimeline: ${i.selector} must be an HTMLElement for kind: "css"`);
105
+ }
106
+ // In browser preview, defer adding the classes until the scheduled
107
+ // time — the CSS animation then plays naturally from t=0.
108
+ if (!rendererPresent) {
109
+ return [{
110
+ kind: "css-pending",
111
+ at: i.at,
112
+ el,
113
+ classes: i.classes,
114
+ duration: i.duration ?? null,
115
+ }];
116
+ }
117
+ // In renderer mode, add the classes now to materialise the CSS
118
+ // animations as WAAPI Animation objects, then pause and drive them
119
+ // via applyState.
120
+ const before = new Set(el.getAnimations());
121
+ el.classList.add(...i.classes);
122
+ const newAnims = el.getAnimations().filter((a) => !before.has(a));
123
+ if (newAnims.length === 0) {
124
+ throw new Error(
125
+ `defineTimeline: classes [${i.classes.join(", ")}] produced no animation on ${i.selector}. ` +
126
+ `Is the stylesheet that defines those keyframes loaded before defineTimeline runs?`
127
+ );
128
+ }
129
+ const natural = newAnims.reduce((max, a) => {
130
+ const t = a.effect?.getTiming();
131
+ const d = typeof t?.duration === "number" ? t.duration : 0;
132
+ return Math.max(max, d);
133
+ }, 0);
134
+ // Always honour the user's explicit `duration` (or fall back to the
135
+ // longest captured CSS animation). Override `animation-duration`
136
+ // inline with !important so a stylesheet's
137
+ // `@media (prefers-reduced-motion: reduce)` rule — which Animate.css
138
+ // ships and which collapses durations to 1ms — can't silently shrink
139
+ // the timeline. Users opt into this helper specifically to drive
140
+ // motion; "reduced motion" applies to involuntary motion, not the
141
+ // timeline they're authoring.
142
+ const duration = i.duration ?? (natural > 0 ? natural : Math.max(0, schema.duration - i.at));
143
+ el.style.setProperty("animation-duration", duration + "ms", "important");
144
+ return newAnims.map<AnimSlot>((anim) => {
145
+ anim.effect?.updateTiming({ duration });
146
+ anim.pause();
147
+ anim.currentTime = 0;
148
+ return { kind: "animation", at: i.at, duration, anim };
149
+ });
150
+ }
151
+
152
+ // Standard WAAPI animation.
153
+ const duration = i.duration ?? Math.max(0, schema.duration - i.at);
154
+ const anim = el.animate(i.props, { duration, fill: "both" });
155
+ anim.pause();
156
+ return [{ kind: "animation", at: i.at, duration, anim }];
31
157
  });
32
158
 
33
159
  window.__mvFrames = {
34
160
  async applyState(t: number) {
35
- for (const { anim } of animations) anim.currentTime = t;
161
+ for (const s of slots) {
162
+ if (s.kind === "css-pending") continue; // only relevant in browser preview
163
+ const local = clamp(t - s.at, 0, s.duration);
164
+ if (s.kind === "video") {
165
+ s.el.pause();
166
+ s.el.currentTime = (s.seek + local) / 1000;
167
+ } else {
168
+ s.anim.currentTime = local;
169
+ }
170
+ }
36
171
  await document.fonts.ready;
37
172
  },
38
173
  };
174
+
175
+ if (!rendererPresent) {
176
+ for (const s of slots) {
177
+ window.setTimeout(() => {
178
+ if (s.kind === "video") {
179
+ s.el.currentTime = s.seek / 1000;
180
+ void s.el.play();
181
+ } else if (s.kind === "css-pending") {
182
+ // Override `animation-duration` BEFORE adding the classes so
183
+ // the inline !important rule beats Animate.css's
184
+ // `prefers-reduced-motion` media query (which would otherwise
185
+ // collapse the animation to 1ms). If the user didn't pass an
186
+ // explicit duration, leave it alone and let the stylesheet's
187
+ // default apply.
188
+ if (s.duration !== null) {
189
+ s.el.style.setProperty("animation-duration", s.duration + "ms", "important");
190
+ }
191
+ s.el.classList.add(...s.classes);
192
+ } else {
193
+ s.anim.play();
194
+ }
195
+ }, s.at);
196
+ }
197
+ }
198
+ }
199
+
200
+ function clamp(v: number, lo: number, hi: number): number {
201
+ return v < lo ? lo : v > hi ? hi : v;
39
202
  }