animate_it 0.3.2 → 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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +212 -20
  4. data/app/controllers/animate_it/embed_assets_controller.rb +25 -0
  5. data/app/controllers/animate_it/frames_controller.rb +10 -0
  6. data/app/controllers/animate_it/public_players_controller.rb +73 -0
  7. data/app/controllers/animate_it/renders_controller.rb +3 -17
  8. data/app/controllers/animate_it/studio_controller.rb +1 -0
  9. data/app/jobs/animate_it/render_job.rb +2 -0
  10. data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
  11. data/app/views/animate_it/frames/player.html.haml +92 -0
  12. data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
  13. data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
  14. data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
  15. data/app/views/animate_it/studio/show.html.haml +10 -3
  16. data/config/routes.rb +10 -0
  17. data/lib/animate_it/asset_manifest.rb +92 -0
  18. data/lib/animate_it/asset_renderer.rb +39 -7
  19. data/lib/animate_it/chapter_navigation.rb +160 -0
  20. data/lib/animate_it/chapters.rb +95 -0
  21. data/lib/animate_it/composition.rb +115 -9
  22. data/lib/animate_it/embed_helper.rb +214 -0
  23. data/lib/animate_it/embed_runtime/embed.js +338 -0
  24. data/lib/animate_it/embed_runtime.rb +13 -0
  25. data/lib/animate_it/embed_styles.rb +126 -0
  26. data/lib/animate_it/engine.rb +7 -0
  27. data/lib/animate_it/player_manifest.rb +29 -0
  28. data/lib/animate_it/runtime/runtime.js +518 -0
  29. data/lib/animate_it/runtime.rb +11 -0
  30. data/lib/animate_it/scene.rb +57 -3
  31. data/lib/animate_it/text_effects.rb +104 -0
  32. data/lib/animate_it/track_document_schema.rb +71 -0
  33. data/lib/animate_it/tracks/document.rb +100 -0
  34. data/lib/animate_it/tracks/layer.rb +13 -0
  35. data/lib/animate_it/tracks/recorder.rb +149 -0
  36. data/lib/animate_it/verification.rb +187 -0
  37. data/lib/animate_it/version.rb +1 -1
  38. data/lib/animate_it/video_renderer.rb +89 -26
  39. data/lib/animate_it/view_helpers.rb +11 -1
  40. data/lib/animate_it.rb +17 -0
  41. data/lib/tasks/animate_it_tasks.rake +133 -0
  42. metadata +25 -6
@@ -0,0 +1,338 @@
1
+ (function (global) {
2
+ "use strict";
3
+
4
+ if (!global.customElements || global.customElements.get("animate-it-embed")) return;
5
+
6
+ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
7
+
8
+ class AnimateItEmbed extends HTMLElement {
9
+ connectedCallback() {
10
+ if (this.connected) return;
11
+ this.connected = true;
12
+ this.manifest = JSON.parse(this.querySelector("[data-animate-it-embed-manifest]").textContent);
13
+ this.options = this.manifest.options;
14
+ this.viewport = this.querySelector("[data-animate-it-embed-viewport]");
15
+ this.frame = this.querySelector("[data-animate-it-embed-frame]");
16
+ this.control = this.querySelector("[data-animate-it-embed-control]");
17
+ this.chapterControls = Array.from(this.querySelectorAll("[data-animate-it-chapter]"));
18
+ this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)");
19
+ this.userPaused = false;
20
+ this.userStarted = this.options.autoplay;
21
+ this.visibleRatio = 0;
22
+ this.playerReady = false;
23
+ this.currentChapter = null;
24
+ this.playing = false;
25
+ this.boundMessage = this.receiveMessage.bind(this);
26
+ this.boundVisibility = this.syncPlayback.bind(this);
27
+ this.boundVariantChange = this.variantChanged.bind(this);
28
+ this.boundReducedMotion = this.reducedMotionChanged.bind(this);
29
+ this.boundControl = this.toggle.bind(this);
30
+ this.boundResize = this.scaleFrame.bind(this);
31
+ global.addEventListener("message", this.boundMessage);
32
+ document.addEventListener("visibilitychange", this.boundVisibility);
33
+ this.control.addEventListener("click", this.boundControl);
34
+ this.reducedMotion.addEventListener("change", this.boundReducedMotion);
35
+ this.chapterControls.forEach((control) => {
36
+ control.addEventListener("click", () => this.seekChapter(control.dataset.animateItChapter));
37
+ });
38
+ if ("ResizeObserver" in global) {
39
+ this.resizeObserver = new global.ResizeObserver(this.boundResize);
40
+ this.resizeObserver.observe(this.viewport);
41
+ } else {
42
+ global.addEventListener("resize", this.boundResize);
43
+ }
44
+ this.setupVariants();
45
+ this.setupVisibility();
46
+ this.applyReducedMotion();
47
+ }
48
+
49
+ disconnectedCallback() {
50
+ global.removeEventListener("message", this.boundMessage);
51
+ document.removeEventListener("visibilitychange", this.boundVisibility);
52
+ this.control && this.control.removeEventListener("click", this.boundControl);
53
+ this.reducedMotion && this.reducedMotion.removeEventListener("change", this.boundReducedMotion);
54
+ this.resizeObserver && this.resizeObserver.disconnect();
55
+ global.removeEventListener("resize", this.boundResize);
56
+ this.intersectionObserver && this.intersectionObserver.disconnect();
57
+ (this.variantQueries || []).forEach((entry) => entry.query.removeEventListener("change", this.boundVariantChange));
58
+ this.cancelLoad();
59
+ this.clearReadyTimer();
60
+ this.removePlayer();
61
+ this.connected = false;
62
+ }
63
+
64
+ setupVariants() {
65
+ this.variantQueries = this.manifest.variants.filter((variant) => variant.media).map((variant) => ({
66
+ variant: variant,
67
+ query: global.matchMedia(variant.media)
68
+ }));
69
+ this.variantQueries.forEach((entry) => entry.query.addEventListener("change", this.boundVariantChange));
70
+ this.activateVariant(this.selectedVariant());
71
+ }
72
+
73
+ selectedVariant() {
74
+ const matched = this.variantQueries.find((entry) => entry.query.matches);
75
+ return matched ? matched.variant : this.manifest.variants[0];
76
+ }
77
+
78
+ variantChanged() {
79
+ const variant = this.selectedVariant();
80
+ if (this.variant && variant.source === this.variant.source) return;
81
+ this.activateVariant(variant);
82
+ }
83
+
84
+ activateVariant(variant) {
85
+ const resumeChapter = this.currentChapter;
86
+ this.cancelLoad();
87
+ this.clearReadyTimer();
88
+ this.removePlayer();
89
+ this.variant = variant;
90
+ this.resumeChapter = resumeChapter;
91
+ this.playerReady = false;
92
+ this.dataset.playerReady = "false";
93
+ const composition = variant.composition;
94
+ this.style.setProperty("--animate-it-crossfade-duration", `${this.options.crossfadeDuration}ms`);
95
+ this.viewport.style.aspectRatio = `${composition.width} / ${composition.height}`;
96
+ this.scaleFrame();
97
+ this.updateChapters(0, null);
98
+ if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.scheduleLoad();
99
+ }
100
+
101
+ setupVisibility() {
102
+ if (!global.IntersectionObserver) {
103
+ this.visibleRatio = 1;
104
+ if (!this.reducedMotion.matches) this.scheduleLoad();
105
+ return;
106
+ }
107
+ const thresholds = Array.from(new Set([0, this.options.loadWhenVisible, this.options.playWhenVisible, 1])).sort();
108
+ this.intersectionObserver = new IntersectionObserver((entries) => {
109
+ const entry = entries[entries.length - 1];
110
+ this.visibleRatio = entry && entry.isIntersecting && entry.boundingClientRect.height > 0 ?
111
+ entry.intersectionRect.height / entry.boundingClientRect.height : 0;
112
+ if (this.visibleRatio >= this.options.loadWhenVisible && !this.iframe && !this.reducedMotion.matches) this.scheduleLoad();
113
+ this.syncPlayback();
114
+ }, { threshold: thresholds });
115
+ this.intersectionObserver.observe(this.viewport);
116
+ }
117
+
118
+ applyReducedMotion() {
119
+ this.dataset.reducedMotion = this.reducedMotion.matches ? "true" : "false";
120
+ if (this.reducedMotion.matches) {
121
+ this.cancelLoad();
122
+ this.removePlayer();
123
+ }
124
+ }
125
+
126
+ reducedMotionChanged() {
127
+ this.applyReducedMotion();
128
+ if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.scheduleLoad();
129
+ }
130
+
131
+ scheduleLoad() {
132
+ if (this.iframe || this.loadHandle) return;
133
+ const load = () => {
134
+ this.loadHandle = null;
135
+ if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.mountPlayer();
136
+ };
137
+ if ("requestIdleCallback" in global) this.loadHandle = global.requestIdleCallback(load, { timeout: 800 });
138
+ else this.loadHandle = global.setTimeout(load, 0);
139
+ }
140
+
141
+ cancelLoad() {
142
+ if (!this.loadHandle) return;
143
+ if ("cancelIdleCallback" in global) global.cancelIdleCallback(this.loadHandle);
144
+ else global.clearTimeout(this.loadHandle);
145
+ this.loadHandle = null;
146
+ }
147
+
148
+ mountPlayer() {
149
+ if (this.iframe) return;
150
+ const composition = this.variant.composition;
151
+ const separator = this.variant.source.includes("?") ? "&" : "?";
152
+ const iframe = document.createElement("iframe");
153
+ iframe.src = `${this.variant.source}${separator}embedded=1&host_navigation=${this.chapterControls.length ? "1" : "0"}`;
154
+ iframe.title = this.manifest.title;
155
+ iframe.loading = "eager";
156
+ iframe.tabIndex = -1;
157
+ iframe.setAttribute("aria-hidden", "true");
158
+ iframe.setAttribute("allow", "autoplay");
159
+ iframe.width = composition.width;
160
+ iframe.height = composition.height;
161
+ iframe.addEventListener("load", () => {
162
+ if (iframe !== this.iframe) return;
163
+ if (!iframe.contentDocument || !iframe.contentDocument.querySelector("[data-animate-it-tracks]")) {
164
+ this.fail(new Error("AnimateIt player returned an invalid document"));
165
+ return;
166
+ }
167
+ this.readyTimer = global.setTimeout(() => this.degradedReady(), this.options.readyTimeout);
168
+ }, { once: true });
169
+ iframe.addEventListener("error", () => this.fail(new Error("AnimateIt player failed to load")), { once: true });
170
+ this.iframe = iframe;
171
+ this.frame.replaceChildren(iframe);
172
+ this.scaleFrame();
173
+ }
174
+
175
+ removePlayer() {
176
+ if (this.iframe) {
177
+ this.send("pause");
178
+ this.iframe.remove();
179
+ }
180
+ this.iframe = null;
181
+ this.playerReady = false;
182
+ this.playing = false;
183
+ if (this.control) this.control.hidden = true;
184
+ }
185
+
186
+ scaleFrame() {
187
+ if (!this.variant || !this.viewport) return;
188
+ const composition = this.variant.composition;
189
+ const scale = this.viewport.clientWidth / composition.width;
190
+ this.frame.style.width = `${composition.width}px`;
191
+ this.frame.style.height = `${composition.height}px`;
192
+ this.frame.style.transform = `scale(${scale})`;
193
+ }
194
+
195
+ receiveMessage(event) {
196
+ if (!this.iframe || event.source !== this.iframe.contentWindow || event.origin !== global.location.origin) return;
197
+ const message = event.data || {};
198
+ if (message.namespace !== "animate-it" || !message.event) return;
199
+ const detail = message.detail || {};
200
+ if (message.event === "ready") this.ready(detail);
201
+ else if (message.event === "framechange") this.frameChanged(detail);
202
+ else if (message.event === "chapterchange") this.chapterChanged(detail);
203
+ else if (message.event === "play") this.setPlaying(true);
204
+ else if (message.event === "pause" || message.event === "ended") this.setPlaying(false);
205
+ else if (message.event === "error") this.fail(new Error(detail.message || "AnimateIt player error"));
206
+ this.dispatchEvent(new CustomEvent(`animateit:${message.event}`, { detail }));
207
+ }
208
+
209
+ ready(detail) {
210
+ this.clearReadyTimer();
211
+ this.playerReady = true;
212
+ this.dataset.playerReady = "true";
213
+ this.dataset.playerReadiness = "complete";
214
+ this.control.hidden = false;
215
+ if (this.resumeChapter) this.send("seekChapter", { chapter: this.resumeChapter });
216
+ this.updateChapters(detail.frame || 0, detail.chapter);
217
+ this.syncPlayback();
218
+ }
219
+
220
+ degradedReady() {
221
+ if (!this.iframe || this.playerReady) return;
222
+ this.playerReady = true;
223
+ this.dataset.playerReady = "true";
224
+ this.dataset.playerReadiness = "degraded";
225
+ this.control.hidden = false;
226
+ this.syncPlayback();
227
+ this.dispatchEvent(new CustomEvent("animateit:degradedready"));
228
+ }
229
+
230
+ fail(error) {
231
+ this.clearReadyTimer();
232
+ this.dataset.playerReady = "false";
233
+ this.dataset.playerError = "true";
234
+ this.playerReady = false;
235
+ this.control.hidden = true;
236
+ this.dispatchEvent(new CustomEvent("animateit:error", { detail: { message: error.message } }));
237
+ }
238
+
239
+ clearReadyTimer() {
240
+ if (this.readyTimer) global.clearTimeout(this.readyTimer);
241
+ this.readyTimer = null;
242
+ }
243
+
244
+ frameChanged(detail) {
245
+ this.lastFrame = Number(detail.frame) || 0;
246
+ this.updateChapters(this.lastFrame, detail.chapter);
247
+ }
248
+
249
+ chapterChanged(detail) {
250
+ this.currentChapter = detail.chapter || null;
251
+ this.updateChapters(Number(detail.frame) || 0, this.currentChapter);
252
+ }
253
+
254
+ updateChapters(frame, namedChapter) {
255
+ const chapters = this.variant.composition.chapters;
256
+ let currentIndex = -1;
257
+ for (let index = chapters.length - 1; index >= 0; index -= 1) {
258
+ if (frame >= chapters[index].startFrame) { currentIndex = index; break; }
259
+ }
260
+ if (namedChapter) currentIndex = chapters.findIndex((chapter) => chapter.name === namedChapter);
261
+ const current = currentIndex >= 0 ? chapters[currentIndex] : null;
262
+ this.currentChapter = current && current.name;
263
+ const progress = current ? (current.durationFrames === 1 ? 1 :
264
+ clamp((frame - current.startFrame) / (current.durationFrames - 1), 0, 1)) : 0;
265
+ this.chapterControls.forEach((control) => {
266
+ const index = chapters.findIndex((chapter) => chapter.name === control.dataset.animateItChapter);
267
+ const state = currentIndex < 0 || index > currentIndex ? "upcoming" : (index < currentIndex ? "completed" : "current");
268
+ const position = currentIndex < 0 ? "hidden" :
269
+ (index === currentIndex ? "current" : (index === currentIndex - 1 ? "previous" : (index === currentIndex + 1 ? "next" : "hidden")));
270
+ control.dataset.chapterState = state;
271
+ control.dataset.chapterPosition = position;
272
+ control.style.setProperty("--animate-it-chapter-progress", String(state === "completed" ? 1 : (state === "current" ? progress : 0)));
273
+ control.style.setProperty("--animate-it-chapter-active", state === "current" ? "1" : "0");
274
+ control.style.setProperty("--animate-it-chapter-complete", state === "completed" ? "1" : "0");
275
+ if (state === "current") control.setAttribute("aria-current", "step");
276
+ else control.removeAttribute("aria-current");
277
+ });
278
+ }
279
+
280
+ seekChapter(name) {
281
+ if (!this.playerReady) { this.resumeChapter = name; return; }
282
+ this.send("seekChapter", { chapter: name });
283
+ }
284
+
285
+ seek(frame) {
286
+ this.send("seek", { frame: Number(frame) || 0 });
287
+ }
288
+
289
+ play() {
290
+ this.userStarted = true;
291
+ this.userPaused = false;
292
+ this.send("play");
293
+ }
294
+
295
+ pause() {
296
+ this.userPaused = true;
297
+ this.send("pause");
298
+ }
299
+
300
+ playingState() {
301
+ return this.playing;
302
+ }
303
+
304
+ currentFrame() {
305
+ return this.lastFrame || 0;
306
+ }
307
+
308
+ toggle() {
309
+ if (!this.playerReady) return;
310
+ this.userStarted = true;
311
+ this.userPaused = this.playing;
312
+ this.send(this.playing ? "pause" : "play");
313
+ }
314
+
315
+ syncPlayback() {
316
+ if (!this.playerReady) return;
317
+ const inViewport = this.visibleRatio >= this.options.playWhenVisible;
318
+ const shouldPlay = this.userStarted && !this.userPaused && !document.hidden &&
319
+ (inViewport || !this.options.pauseOffscreen);
320
+ if (shouldPlay && !this.playing) this.send("play");
321
+ else if (!shouldPlay && this.playing) this.send("pause");
322
+ }
323
+
324
+ send(command, detail) {
325
+ if (!this.iframe || !this.iframe.contentWindow) return;
326
+ this.iframe.contentWindow.postMessage(Object.assign({ namespace: "animate-it", command }, detail || {}), global.location.origin);
327
+ }
328
+
329
+ setPlaying(playing) {
330
+ this.playing = playing;
331
+ this.control.textContent = playing ? "Pause" : "Play";
332
+ this.control.setAttribute("aria-label", playing ? "Pause animation" : "Play animation");
333
+ this.control.setAttribute("aria-pressed", playing ? "true" : "false");
334
+ }
335
+ }
336
+
337
+ global.customElements.define("animate-it-embed", AnimateItEmbed);
338
+ })(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,13 @@
1
+ module AnimateIt
2
+ module EmbedRuntime
3
+ module_function
4
+
5
+ def javascript
6
+ @javascript ||= File.read(File.expand_path("embed_runtime/embed.js", __dir__)).freeze
7
+ end
8
+
9
+ def stylesheet
10
+ EmbedStyles.source
11
+ end
12
+ end
13
+ end
@@ -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
@@ -27,5 +27,12 @@ module AnimateIt
27
27
  AnimateIt.reload_compositions!
28
28
  end
29
29
  end
30
+
31
+ initializer "animate_it.embed_helper" do
32
+ ActiveSupport.on_load(:action_view) do
33
+ include AnimateIt::EmbedHelper
34
+ include AnimateIt::ChapterNavigationHelper
35
+ end
36
+ end
30
37
  end
31
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