animate_it 0.4.0 → 0.5.0

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.
@@ -0,0 +1,126 @@
1
+ module AnimateIt
2
+ module EmbedStyles
3
+ module_function
4
+
5
+ def chapter_source
6
+ @chapter_source ||= <<~CSS.freeze
7
+ .animate-it-chapters {
8
+ --animate-it-progress-color: #28d2bc;
9
+ --animate-it-progress-width: 2.5px;
10
+ --animate-it-active-glow: 0 0 10px 2px rgb(40 210 188 / 42%);
11
+ --animate-it-chapter-color: #414b57;
12
+ --animate-it-chapter-background: #fff;
13
+ --animate-it-chapter-shadow: 0 4px 12px rgb(39 47 57 / 8%);
14
+ --animate-it-chapter-font: 700 14px/1 system-ui, sans-serif;
15
+ --animate-it-chapter-gap: 14px;
16
+ --animate-it-chapter-height: 44px;
17
+ --animate-it-carousel-distance: 108%;
18
+ --animate-it-carousel-scale: .84;
19
+ --animate-it-carousel-opacity: .68;
20
+ --animate-it-carousel-duration: 180ms;
21
+ display: grid;
22
+ grid-template-columns: repeat(var(--animate-it-chapter-count, 4), minmax(0, 1fr));
23
+ gap: var(--animate-it-chapter-gap);
24
+ }
25
+ .animate-it-chapter--pills {
26
+ --animate-it-chapter-progress: 0;
27
+ --animate-it-chapter-active: 0;
28
+ --animate-it-chapter-complete: 0;
29
+ position: relative;
30
+ min-width: 0;
31
+ min-height: 44px;
32
+ height: var(--animate-it-chapter-height);
33
+ padding: 2px;
34
+ border: 0;
35
+ border-radius: 999px;
36
+ appearance: none;
37
+ color: var(--animate-it-chapter-color);
38
+ background: var(--animate-it-chapter-background);
39
+ box-shadow: var(--animate-it-chapter-shadow);
40
+ cursor: pointer;
41
+ }
42
+ .animate-it-chapter--pills[data-chapter-state="current"] { box-shadow: var(--animate-it-active-glow); }
43
+ .animate-it-chapter--pills:focus-visible { outline: 3px solid var(--animate-it-progress-color); outline-offset: 3px; }
44
+ .animate-it-chapter__progress { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
45
+ .animate-it-chapter__progress rect {
46
+ fill: none;
47
+ stroke: var(--animate-it-progress-color);
48
+ stroke-width: var(--animate-it-progress-width);
49
+ stroke-dasharray: var(--animate-it-chapter-progress) 1;
50
+ stroke-opacity: clamp(0, calc(var(--animate-it-chapter-progress) * 1000), 1);
51
+ stroke-linecap: round;
52
+ vector-effect: non-scaling-stroke;
53
+ }
54
+ .animate-it-chapter__label {
55
+ position: relative;
56
+ display: flex;
57
+ height: 100%;
58
+ align-items: center;
59
+ justify-content: center;
60
+ opacity: calc(.58 + (var(--animate-it-chapter-active) * .42));
61
+ font: var(--animate-it-chapter-font);
62
+ }
63
+ @media (max-width: 767px) {
64
+ .animate-it-chapters--mobile-carousel { position: relative; display: block; height: var(--animate-it-chapter-height); overflow: clip; }
65
+ .animate-it-chapters--mobile-carousel .animate-it-chapter--pills {
66
+ position: absolute;
67
+ left: 50%;
68
+ width: min(31.25%, 122px);
69
+ transition: transform var(--animate-it-carousel-duration) ease, opacity var(--animate-it-carousel-duration) ease;
70
+ }
71
+ .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="previous"] { transform: translateX(calc(-50% - var(--animate-it-carousel-distance))) scale(var(--animate-it-carousel-scale)); opacity: var(--animate-it-carousel-opacity); }
72
+ .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="current"] { transform: translateX(-50%); opacity: 1; }
73
+ .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="next"] { transform: translateX(calc(-50% + var(--animate-it-carousel-distance))) scale(var(--animate-it-carousel-scale)); opacity: var(--animate-it-carousel-opacity); }
74
+ .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="hidden"] { visibility: hidden; pointer-events: none; opacity: 0; }
75
+ }
76
+ @media (prefers-reduced-motion: reduce) {
77
+ .animate-it-chapter--pills { transition: none !important; }
78
+ }
79
+ CSS
80
+ end
81
+
82
+ def source
83
+ @source ||= <<~CSS.freeze
84
+ #{chapter_source}
85
+ animate-it-embed {
86
+ --animate-it-crossfade-duration: 120ms;
87
+ position: relative;
88
+ display: block;
89
+ width: 100%;
90
+ background: transparent;
91
+ }
92
+ .animate-it-embed__navigation { margin-bottom: 14px; }
93
+ .animate-it-embed__viewport { position: relative; width: 100%; background: transparent; }
94
+ .animate-it-embed__poster, .animate-it-embed__shell { position: absolute; inset: 0; width: 100%; height: 100%; }
95
+ .animate-it-embed__poster { z-index: 2; margin: 0; opacity: 1; transition: opacity var(--animate-it-crossfade-duration) ease; pointer-events: none; }
96
+ .animate-it-embed__poster img { display: block; width: 100%; height: 100%; object-fit: contain; }
97
+ .animate-it-embed__shell { z-index: 1; overflow: hidden; opacity: 0; transition: opacity var(--animate-it-crossfade-duration) ease; background: transparent; }
98
+ .animate-it-embed__frame { position: absolute; top: 0; left: 0; transform-origin: top left; background: transparent; }
99
+ .animate-it-embed__frame iframe { display: block; border: 0; background: transparent; pointer-events: none; }
100
+ animate-it-embed[data-player-ready="true"] .animate-it-embed__poster { opacity: 0; }
101
+ animate-it-embed[data-player-ready="true"] .animate-it-embed__shell { opacity: 1; }
102
+ .animate-it-embed__control {
103
+ position: absolute;
104
+ z-index: 4;
105
+ right: 12px;
106
+ bottom: 12px;
107
+ width: 44px;
108
+ height: 44px;
109
+ padding: 0;
110
+ border: 0;
111
+ border-radius: 999px;
112
+ color: #fff;
113
+ background: rgb(18 24 29 / 84%);
114
+ font: 700 13px/1 system-ui, sans-serif;
115
+ cursor: pointer;
116
+ }
117
+ .animate-it-embed__control:focus-visible { outline: 3px solid var(--animate-it-progress-color, #28d2bc); outline-offset: 3px; }
118
+ animate-it-embed[data-reduced-motion="true"] .animate-it-embed__navigation,
119
+ animate-it-embed[data-reduced-motion="true"] .animate-it-embed__control { display: none; }
120
+ @media (prefers-reduced-motion: reduce) {
121
+ .animate-it-embed__poster, .animate-it-embed__shell { transition: none; }
122
+ }
123
+ CSS
124
+ end
125
+ end
126
+ end
@@ -29,7 +29,10 @@ module AnimateIt
29
29
  end
30
30
 
31
31
  initializer "animate_it.embed_helper" do
32
- ActiveSupport.on_load(:action_view) { include AnimateIt::EmbedHelper }
32
+ ActiveSupport.on_load(:action_view) do
33
+ include AnimateIt::EmbedHelper
34
+ include AnimateIt::ChapterNavigationHelper
35
+ end
33
36
  end
34
37
  end
35
38
  end
@@ -0,0 +1,29 @@
1
+ module AnimateIt
2
+ class PlayerManifest
3
+ VERSION = 1
4
+
5
+ def initialize(composition)
6
+ @composition = composition
7
+ end
8
+
9
+ def as_json(*)
10
+ @composition.chapters.validate!
11
+ {
12
+ "version" => VERSION,
13
+ "id" => @composition.id,
14
+ "width" => @composition.width,
15
+ "height" => @composition.height,
16
+ "fps" => @composition.fps,
17
+ "duration" => @composition.duration_in_frames,
18
+ "chapters" => @composition.chapters.as_json,
19
+ "playback" => @composition.public_player_options.transform_keys { |key| camelize(key) }
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def camelize(key)
26
+ key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
27
+ end
28
+ end
29
+ end
@@ -74,6 +74,51 @@
74
74
  };
75
75
  }
76
76
 
77
+ function clamp(value, min, max) {
78
+ return Math.max(min, Math.min(max, value));
79
+ }
80
+
81
+ function createChapterState(manifest, root) {
82
+ var chapters = (manifest && manifest.chapters) || [];
83
+ var elements = chapters.length ? Array.prototype.slice.call(root.querySelectorAll("[data-animate-it-chapter]")) : [];
84
+ var lastName = null;
85
+
86
+ function setElementState(el, chapter, index, currentIndex, progress) {
87
+ var state = currentIndex < 0 || index > currentIndex ? "upcoming" : (index < currentIndex ? "completed" : "current");
88
+ var position = currentIndex < 0 ? "hidden" :
89
+ (index === currentIndex ? "current" : (index === currentIndex - 1 ? "previous" : (index === currentIndex + 1 ? "next" : "hidden")));
90
+ var chapterProgress = state === "completed" ? 1 : (state === "current" ? progress : 0);
91
+ el.dataset.chapterState = state;
92
+ el.dataset.chapterPosition = position;
93
+ el.style.setProperty("--animate-it-chapter-progress", String(chapterProgress));
94
+ el.style.setProperty("--animate-it-chapter-active", state === "current" ? "1" : "0");
95
+ el.style.setProperty("--animate-it-chapter-complete", state === "completed" ? "1" : "0");
96
+ if (el.tagName === "BUTTON") {
97
+ if (state === "current") el.setAttribute("aria-current", "step");
98
+ else el.removeAttribute("aria-current");
99
+ }
100
+ }
101
+
102
+ function update(frame) {
103
+ var currentIndex = -1;
104
+ for (var i = chapters.length - 1; i >= 0; i -= 1) {
105
+ if (frame >= chapters[i].startFrame) { currentIndex = i; break; }
106
+ }
107
+ var current = currentIndex >= 0 ? chapters[currentIndex] : null;
108
+ var progress = current ? (current.durationFrames === 1 ? 1 :
109
+ clamp((frame - current.startFrame) / (current.durationFrames - 1), 0, 1)) : 0;
110
+ elements.forEach(function (el) {
111
+ var index = chapters.findIndex(function (chapter) { return chapter.name === el.dataset.animateItChapter; });
112
+ if (index >= 0) setElementState(el, chapters[index], index, currentIndex, progress);
113
+ });
114
+ var changed = (current && current.name) !== lastName;
115
+ lastName = current && current.name;
116
+ return { chapter: current, index: currentIndex, progress: progress, changed: changed };
117
+ }
118
+
119
+ return { update: update, chapters: chapters };
120
+ }
121
+
77
122
  // Pause native CSS/Web Animations and seek them to deterministic frame
78
123
  // time. Layers use scene-local time so delayed scenes start at zero.
79
124
  var animationCache = typeof WeakMap === "undefined" ? null : new WeakMap();
@@ -100,7 +145,8 @@
100
145
  return animations;
101
146
  }
102
147
 
103
- function createPlayer(doc, root) {
148
+ function createPlayer(doc, root, options) {
149
+ var settings = options || {};
104
150
  var duration = doc.duration;
105
151
  var varBindings = [];
106
152
  var group;
@@ -140,9 +186,12 @@
140
186
  });
141
187
 
142
188
  var current = -1;
189
+ var chapterState = createChapterState(settings.manifest, root);
190
+ var currentChapter = null;
143
191
 
144
192
  function setFrame(n) {
145
193
  var frame = Math.max(0, Math.min(duration - 1, Math.round(Number(n) || 0)));
194
+ var frameChanged = frame !== current;
146
195
  current = frame;
147
196
 
148
197
  layers.forEach(function (layer) {
@@ -180,6 +229,8 @@
180
229
  } else {
181
230
  seekAnimations(root, frame, doc.fps);
182
231
  }
232
+ currentChapter = chapterState.update(frame);
233
+ if (frameChanged && typeof settings.onFrame === "function") settings.onFrame(frame, currentChapter);
183
234
  return frame;
184
235
  }
185
236
 
@@ -187,7 +238,8 @@
187
238
  duration: duration,
188
239
  fps: doc.fps,
189
240
  setFrame: setFrame,
190
- currentFrame: function () { return current; }
241
+ currentFrame: function () { return current; },
242
+ currentChapter: function () { return currentChapter; }
191
243
  };
192
244
  }
193
245
 
@@ -204,6 +256,7 @@
204
256
  var animationFrame = null;
205
257
  var startedAt = 0;
206
258
  var startedFrame = frame;
259
+ var emit = typeof settings.onEvent === "function" ? settings.onEvent : function () {};
207
260
 
208
261
  audios.forEach(function (el) {
209
262
  var gain = Number(el.dataset.gain);
@@ -269,10 +322,12 @@
269
322
  }
270
323
 
271
324
  function pause() {
325
+ var wasPlaying = animationFrame !== null;
272
326
  if (animationFrame !== null) global.cancelAnimationFrame(animationFrame);
273
327
  animationFrame = null;
274
328
  syncAudio(frame, false);
275
329
  updateButton(false);
330
+ if (wasPlaying) emit("pause", { frame: frame });
276
331
  }
277
332
 
278
333
  function seek(nextFrame) {
@@ -294,6 +349,7 @@
294
349
  if (next >= duration) {
295
350
  if (!shouldLoop) {
296
351
  frame = player.setFrame(duration - 1);
352
+ emit("ended", { frame: frame });
297
353
  pause();
298
354
  return;
299
355
  }
@@ -316,8 +372,10 @@
316
372
  startedFrame = frame;
317
373
  updateButton(true);
318
374
  animationFrame = global.requestAnimationFrame(tick);
375
+ emit("play", { frame: frame });
319
376
  return syncAudio(frame, true).catch(function (error) {
320
377
  pause();
378
+ emit("error", { message: error && error.message ? error.message : "AnimateIt playback failed" });
321
379
  throw error;
322
380
  });
323
381
  }
@@ -339,10 +397,56 @@
339
397
  };
340
398
  }
341
399
 
400
+ function waitForReady(root) {
401
+ var fonts = root.fonts && root.fonts.ready ? root.fonts.ready.catch(function () {}) : Promise.resolve();
402
+ var images = Array.prototype.slice.call(root.images || []).filter(function (image) {
403
+ if (image.hidden) return false;
404
+ if (typeof image.getBoundingClientRect !== "function") return true;
405
+ var rect = image.getBoundingClientRect();
406
+ return rect.width > 0 && rect.height > 0;
407
+ }).map(function (image) {
408
+ if (image.complete) {
409
+ if (image.naturalWidth === 0) return Promise.reject(new Error("AnimateIt visible image failed to load"));
410
+ return typeof image.decode === "function" ? image.decode() : Promise.resolve();
411
+ }
412
+ return new Promise(function (resolve, reject) {
413
+ image.addEventListener("load", resolve, { once: true });
414
+ image.addEventListener("error", function () {
415
+ reject(new Error("AnimateIt visible image failed to load"));
416
+ }, { once: true });
417
+ });
418
+ });
419
+ return Promise.all([fonts, Promise.all(images)]).then(function () {
420
+ return new Promise(function (resolve) {
421
+ global.requestAnimationFrame(function () { global.requestAnimationFrame(resolve); });
422
+ });
423
+ });
424
+ }
425
+
342
426
  function boot() {
343
427
  var script = document.querySelector("script[data-animate-it-tracks]");
344
428
  if (!script) return;
345
- var player = createPlayer(JSON.parse(script.textContent), document);
429
+ var manifestScript = document.querySelector("script[data-animate-it-manifest]");
430
+ var manifest = manifestScript ? JSON.parse(manifestScript.textContent) : { chapters: [] };
431
+ var currentChapterName = null;
432
+ function emit(name, detail) {
433
+ var payload = Object.assign({ frame: player ? player.currentFrame() : 0 }, detail || {});
434
+ if (typeof global.CustomEvent === "function") global.dispatchEvent(new CustomEvent("animateit:" + name, { detail: payload }));
435
+ if (global.parent && global.parent !== global && global.location) {
436
+ global.parent.postMessage({ namespace: "animate-it", event: name, detail: payload }, global.location.origin);
437
+ }
438
+ }
439
+ var player = createPlayer(JSON.parse(script.textContent), document, {
440
+ manifest: manifest,
441
+ onFrame: function (frame, chapterState) {
442
+ var chapter = chapterState.chapter;
443
+ emit("framechange", { frame: frame, chapter: chapter && chapter.name, progress: chapterState.progress });
444
+ if (chapterState.changed) {
445
+ currentChapterName = chapter && chapter.name;
446
+ emit("chapterchange", { frame: frame, chapter: currentChapterName, progress: chapterState.progress });
447
+ }
448
+ }
449
+ });
346
450
  player.setFrame(0);
347
451
  global.__animateIt = { totalFrames: player.duration, setFrame: player.setFrame };
348
452
  global.AnimateItRuntime = player;
@@ -352,13 +456,41 @@
352
456
  Array.prototype.slice.call(document.querySelectorAll("audio[data-from-frame]")),
353
457
  {
354
458
  loop: script.dataset.animateItLoop === "true",
355
- button: document.querySelector("[data-animate-it-play]")
459
+ button: document.querySelector("[data-animate-it-play]"),
460
+ onEvent: emit
356
461
  }
357
462
  );
463
+ transport.seekChapter = function (name) {
464
+ var chapter = (manifest.chapters || []).find(function (item) { return item.name === String(name); });
465
+ if (!chapter) throw new Error("Unknown AnimateIt chapter: " + name);
466
+ return transport.seek(chapter.startFrame);
467
+ };
358
468
  global.AnimateItTransport = transport;
469
+ global.AnimateItPlayer = transport;
470
+ global.addEventListener("message", function (event) {
471
+ if (event.source !== global.parent || !global.location || event.origin !== global.location.origin) return;
472
+ var data = event.data || {};
473
+ if (data.namespace !== "animate-it" || data.command === undefined) return;
474
+ try {
475
+ if (data.command === "play") transport.play().catch(function () {});
476
+ else if (data.command === "pause") transport.pause();
477
+ else if (data.command === "toggle") transport.toggle().catch(function () {});
478
+ else if (data.command === "seek") transport.seek(data.frame);
479
+ else if (data.command === "seekChapter") transport.seekChapter(data.chapter);
480
+ } catch (error) {
481
+ emit("error", { message: error.message });
482
+ }
483
+ });
359
484
  if (script.dataset.animateItAutoplay === "true") transport.play().catch(function () {});
360
485
  }
361
- document.documentElement.dataset.animateItReady = "1";
486
+ waitForReady(document)
487
+ .then(function () {
488
+ document.documentElement.dataset.animateItReady = "1";
489
+ emit("ready", { manifest: manifest, chapter: currentChapterName });
490
+ })
491
+ .catch(function (error) {
492
+ emit("error", { message: error && error.message ? error.message : "AnimateIt player failed readiness" });
493
+ });
362
494
  }
363
495
 
364
496
  var api = {
@@ -367,6 +499,7 @@
367
499
  formatComputed: formatComputed,
368
500
  interpolate: interpolate,
369
501
  compileTrack: compileTrack,
502
+ createChapterState: createChapterState,
370
503
  seekAnimations: seekAnimations,
371
504
  createPlayer: createPlayer,
372
505
  createTransport: createTransport
@@ -1,3 +1,3 @@
1
1
  module AnimateIt
2
- VERSION = "0.4.0".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end
data/lib/animate_it.rb CHANGED
@@ -1,3 +1,6 @@
1
+ require "active_support"
2
+ require "active_support/core_ext"
3
+
1
4
  require_relative "animate_it/version"
2
5
  require_relative "animate_it/errors"
3
6
  require_relative "animate_it/frame_duration"
@@ -13,6 +16,7 @@ require_relative "animate_it/timeline"
13
16
  require_relative "animate_it/registry"
14
17
  require_relative "animate_it/render_store"
15
18
  require_relative "animate_it/beats"
19
+ require_relative "animate_it/chapters"
16
20
  require_relative "animate_it/animation"
17
21
  require_relative "animate_it/text_effects"
18
22
  require_relative "animate_it/scene"
@@ -22,6 +26,10 @@ require_relative "animate_it/tracks/layer"
22
26
  require_relative "animate_it/tracks/document"
23
27
  require_relative "animate_it/tracks/recorder"
24
28
  require_relative "animate_it/track_document_schema"
29
+ require_relative "animate_it/player_manifest"
30
+ require_relative "animate_it/embed_styles"
31
+ require_relative "animate_it/chapter_navigation"
32
+ require_relative "animate_it/embed_runtime"
25
33
  require_relative "animate_it/runtime"
26
34
  require_relative "animate_it/embed_helper"
27
35
  require_relative "animate_it/output"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: animate_it
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - growth-constant
@@ -58,6 +58,7 @@ files:
58
58
  - README.md
59
59
  - app/controllers/animate_it/application_controller.rb
60
60
  - app/controllers/animate_it/audio_controller.rb
61
+ - app/controllers/animate_it/embed_assets_controller.rb
61
62
  - app/controllers/animate_it/frames_controller.rb
62
63
  - app/controllers/animate_it/props_controller.rb
63
64
  - app/controllers/animate_it/public_players_controller.rb
@@ -90,15 +91,21 @@ files:
90
91
  - lib/animate_it/asset_manifest.rb
91
92
  - lib/animate_it/asset_renderer.rb
92
93
  - lib/animate_it/beats.rb
94
+ - lib/animate_it/chapter_navigation.rb
95
+ - lib/animate_it/chapters.rb
93
96
  - lib/animate_it/composition.rb
94
97
  - lib/animate_it/configuration.rb
95
98
  - lib/animate_it/easing.rb
96
99
  - lib/animate_it/embed_helper.rb
100
+ - lib/animate_it/embed_runtime.rb
101
+ - lib/animate_it/embed_runtime/embed.js
102
+ - lib/animate_it/embed_styles.rb
97
103
  - lib/animate_it/engine.rb
98
104
  - lib/animate_it/errors.rb
99
105
  - lib/animate_it/frame_context.rb
100
106
  - lib/animate_it/frame_duration.rb
101
107
  - lib/animate_it/output.rb
108
+ - lib/animate_it/player_manifest.rb
102
109
  - lib/animate_it/props_schema.rb
103
110
  - lib/animate_it/registry.rb
104
111
  - lib/animate_it/render_store.rb
@@ -121,14 +128,14 @@ files:
121
128
  - lib/generators/animate_it/install/install_generator.rb
122
129
  - lib/generators/animate_it/install/templates/animate_it.rb
123
130
  - lib/tasks/animate_it_tasks.rake
124
- homepage: https://github.com/growth-constant/animate_it
131
+ homepage: https://github.com/joinbuildit/animate_it
125
132
  licenses:
126
133
  - MIT
127
134
  metadata:
128
- homepage_uri: https://github.com/growth-constant/animate_it
129
- source_code_uri: https://github.com/growth-constant/animate_it
130
- changelog_uri: https://github.com/growth-constant/animate_it/blob/main/CHANGELOG.md
131
- bug_tracker_uri: https://github.com/growth-constant/animate_it/issues
135
+ homepage_uri: https://github.com/joinbuildit/animate_it
136
+ source_code_uri: https://github.com/joinbuildit/animate_it
137
+ changelog_uri: https://github.com/joinbuildit/animate_it/blob/main/CHANGELOG.md
138
+ bug_tracker_uri: https://github.com/joinbuildit/animate_it/issues
132
139
  rubygems_mfa_required: 'true'
133
140
  rdoc_options: []
134
141
  require_paths: