animate_it 0.3.2 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +131 -19
- data/app/controllers/animate_it/frames_controller.rb +7 -0
- data/app/controllers/animate_it/public_players_controller.rb +69 -0
- data/app/controllers/animate_it/renders_controller.rb +3 -17
- data/app/controllers/animate_it/studio_controller.rb +1 -0
- data/app/jobs/animate_it/render_job.rb +2 -0
- data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
- data/app/views/animate_it/frames/player.html.haml +81 -0
- data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
- data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
- data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
- data/app/views/animate_it/studio/show.html.haml +10 -3
- data/config/routes.rb +5 -0
- data/lib/animate_it/asset_manifest.rb +92 -0
- data/lib/animate_it/asset_renderer.rb +39 -7
- data/lib/animate_it/composition.rb +100 -9
- data/lib/animate_it/embed_helper.rb +22 -0
- data/lib/animate_it/engine.rb +4 -0
- data/lib/animate_it/runtime/runtime.js +385 -0
- data/lib/animate_it/runtime.rb +11 -0
- data/lib/animate_it/scene.rb +57 -3
- data/lib/animate_it/text_effects.rb +104 -0
- data/lib/animate_it/track_document_schema.rb +71 -0
- data/lib/animate_it/tracks/document.rb +100 -0
- data/lib/animate_it/tracks/layer.rb +13 -0
- data/lib/animate_it/tracks/recorder.rb +149 -0
- data/lib/animate_it/verification.rb +187 -0
- data/lib/animate_it/version.rb +1 -1
- data/lib/animate_it/video_renderer.rb +89 -26
- data/lib/animate_it/view_helpers.rb +11 -1
- data/lib/animate_it.rb +9 -0
- data/lib/tasks/animate_it_tasks.rake +133 -0
- metadata +13 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// AnimateIt client runtime: plays a recorded track document against a
|
|
2
|
+
// once-rendered scene DOM.
|
|
3
|
+
(function (global) {
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
var Easing = {
|
|
7
|
+
linear: function (p) { return p; },
|
|
8
|
+
ease_in: function (p) { return p * p; },
|
|
9
|
+
ease_out: function (p) { return 1 - (1 - p) * (1 - p); },
|
|
10
|
+
ease_in_out: function (p) {
|
|
11
|
+
if (p < 0.5) return 2 * p * p;
|
|
12
|
+
var q = -2 * p + 2;
|
|
13
|
+
return 1 - (q * q) / 2;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function round4(value) {
|
|
18
|
+
var rounded = Math.round(Math.abs(value) * 1e4) / 1e4;
|
|
19
|
+
return value < 0 ? -rounded : rounded;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatComputed(value) {
|
|
23
|
+
return Number.isInteger(value) ? value.toFixed(1) : String(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function interpolate(input, frames, values, easingName) {
|
|
27
|
+
var n = frames.length;
|
|
28
|
+
if (input < frames[0]) return { raw: values[0] };
|
|
29
|
+
if (input > frames[n - 1]) return { raw: values[n - 1] };
|
|
30
|
+
|
|
31
|
+
var left = 0;
|
|
32
|
+
if (input > frames[0]) {
|
|
33
|
+
left = -1;
|
|
34
|
+
for (var i = 0; i < n - 1; i += 1) {
|
|
35
|
+
if (input >= frames[i] && input <= frames[i + 1]) { left = i; break; }
|
|
36
|
+
}
|
|
37
|
+
if (left === -1) left = n - 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var start = frames[left];
|
|
41
|
+
var end = frames[left + 1];
|
|
42
|
+
if (end === start) return { raw: values[left] };
|
|
43
|
+
|
|
44
|
+
var progress = (input - start) / (end - start);
|
|
45
|
+
var eased = (Easing[easingName] || Easing.ease_out)(progress);
|
|
46
|
+
return { computed: values[left] + (values[left + 1] - values[left]) * eased };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compileTrack(track) {
|
|
50
|
+
if (track.t === "rle") {
|
|
51
|
+
var expanded = [];
|
|
52
|
+
for (var i = 0; i < track.r.length; i += 1) {
|
|
53
|
+
var value = track.r[i][0];
|
|
54
|
+
for (var j = 0; j < track.r[i][1]; j += 1) expanded.push(value);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
valueAt: function (frame) {
|
|
58
|
+
if (!expanded.length) return "";
|
|
59
|
+
return expanded[Math.min(frame, expanded.length - 1)];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var frames = track.k.map(function (pair) { return pair[0]; });
|
|
65
|
+
var values = track.k.map(function (pair) { return pair[1]; });
|
|
66
|
+
var unit = track.u || "";
|
|
67
|
+
var easing = track.e || "ease_out";
|
|
68
|
+
return {
|
|
69
|
+
valueAt: function (frame) {
|
|
70
|
+
var result = interpolate(frame, frames, values, easing);
|
|
71
|
+
if ("raw" in result) return String(result.raw) + unit;
|
|
72
|
+
return formatComputed(round4(result.computed)) + unit;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pause native CSS/Web Animations and seek them to deterministic frame
|
|
78
|
+
// time. Layers use scene-local time so delayed scenes start at zero.
|
|
79
|
+
var animationCache = typeof WeakMap === "undefined" ? null : new WeakMap();
|
|
80
|
+
|
|
81
|
+
function seekAnimations(root, frame, fps) {
|
|
82
|
+
if (!root || typeof root.getAnimations !== "function") return [];
|
|
83
|
+
|
|
84
|
+
var frameRate = Number(fps);
|
|
85
|
+
if (!(frameRate > 0)) return [];
|
|
86
|
+
|
|
87
|
+
var currentTime = (frame / frameRate) * 1000;
|
|
88
|
+
var animations = Array.prototype.slice.call(root.getAnimations({ subtree: true }));
|
|
89
|
+
var cached = animationCache && animationCache.get(root);
|
|
90
|
+
if (cached) {
|
|
91
|
+
cached.forEach(function (animation) {
|
|
92
|
+
if (animation.playState !== "idle" && animations.indexOf(animation) === -1) animations.push(animation);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
animations.forEach(function (animation) {
|
|
96
|
+
animation.pause();
|
|
97
|
+
animation.currentTime = currentTime;
|
|
98
|
+
});
|
|
99
|
+
if (animationCache) animationCache.set(root, animations);
|
|
100
|
+
return animations;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createPlayer(doc, root) {
|
|
104
|
+
var duration = doc.duration;
|
|
105
|
+
var varBindings = [];
|
|
106
|
+
var group;
|
|
107
|
+
var name;
|
|
108
|
+
|
|
109
|
+
for (group in doc.groups || {}) {
|
|
110
|
+
var groupSelector = (doc.groupSelectors || {})[group] || '[data-animate-vars="' + group + '"]';
|
|
111
|
+
var els = root.querySelectorAll(groupSelector);
|
|
112
|
+
if (!els.length) continue;
|
|
113
|
+
for (name in doc.groups[group]) {
|
|
114
|
+
varBindings.push({
|
|
115
|
+
els: els,
|
|
116
|
+
name: name,
|
|
117
|
+
track: compileTrack(doc.groups[group][name]),
|
|
118
|
+
last: null,
|
|
119
|
+
initialized: false
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
var textBindings = [];
|
|
125
|
+
for (name in doc.texts || {}) {
|
|
126
|
+
var textSelector = (doc.textSelectors || {})[name] || '[data-animate-text="' + name + '"]';
|
|
127
|
+
var textEls = root.querySelectorAll(textSelector);
|
|
128
|
+
if (!textEls.length) continue;
|
|
129
|
+
textBindings.push({ els: textEls, track: compileTrack(doc.texts[name]), last: null });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var layers = (doc.layers || []).map(function (layer) {
|
|
133
|
+
return {
|
|
134
|
+
els: root.querySelectorAll(layer.sel),
|
|
135
|
+
from: layer.from,
|
|
136
|
+
to: layer.to,
|
|
137
|
+
origin: Number(layer.origin) || 0,
|
|
138
|
+
active: null
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
var current = -1;
|
|
143
|
+
|
|
144
|
+
function setFrame(n) {
|
|
145
|
+
var frame = Math.max(0, Math.min(duration - 1, Math.round(Number(n) || 0)));
|
|
146
|
+
current = frame;
|
|
147
|
+
|
|
148
|
+
layers.forEach(function (layer) {
|
|
149
|
+
var active = frame >= layer.from && frame < layer.to;
|
|
150
|
+
if (active === layer.active) return;
|
|
151
|
+
layer.active = active;
|
|
152
|
+
layer.els.forEach(function (el) { el.classList.toggle("is-active", active); });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
varBindings.forEach(function (binding) {
|
|
156
|
+
var value = binding.track.valueAt(frame);
|
|
157
|
+
if (binding.initialized && value === binding.last) return;
|
|
158
|
+
binding.initialized = true;
|
|
159
|
+
binding.last = value;
|
|
160
|
+
binding.els.forEach(function (el) {
|
|
161
|
+
if (value === null) el.style.removeProperty(binding.name);
|
|
162
|
+
else el.style.setProperty(binding.name, value);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
textBindings.forEach(function (binding) {
|
|
167
|
+
var value = binding.track.valueAt(frame);
|
|
168
|
+
if (value === binding.last) return;
|
|
169
|
+
binding.last = value;
|
|
170
|
+
binding.els.forEach(function (el) { el.textContent = value; });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (layers.length) {
|
|
174
|
+
layers.forEach(function (layer) {
|
|
175
|
+
if (!layer.active) return;
|
|
176
|
+
layer.els.forEach(function (el) {
|
|
177
|
+
seekAnimations(el, frame - layer.origin, doc.fps);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
seekAnimations(root, frame, doc.fps);
|
|
182
|
+
}
|
|
183
|
+
return frame;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
duration: duration,
|
|
188
|
+
fps: doc.fps,
|
|
189
|
+
setFrame: setFrame,
|
|
190
|
+
currentFrame: function () { return current; }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Owns wall-clock playback for production embeds. Audio starts only from
|
|
195
|
+
// this transport, so a browser-blocked autoplay attempt falls back to the
|
|
196
|
+
// visible Play control instead of silently advancing the picture.
|
|
197
|
+
function createTransport(player, audios, options) {
|
|
198
|
+
var settings = options || {};
|
|
199
|
+
var shouldLoop = settings.loop !== false;
|
|
200
|
+
var button = settings.button || null;
|
|
201
|
+
var duration = player.duration;
|
|
202
|
+
var fps = player.fps;
|
|
203
|
+
var frame = player.currentFrame() < 0 ? 0 : player.currentFrame();
|
|
204
|
+
var animationFrame = null;
|
|
205
|
+
var startedAt = 0;
|
|
206
|
+
var startedFrame = frame;
|
|
207
|
+
|
|
208
|
+
audios.forEach(function (el) {
|
|
209
|
+
var gain = Number(el.dataset.gain);
|
|
210
|
+
if (Number.isFinite(gain)) el.volume = Math.max(0, Math.min(1, gain));
|
|
211
|
+
el.loop = el.dataset.loop === "true";
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function audioWindow(el) {
|
|
215
|
+
var start = Number(el.dataset.fromFrame) || 0;
|
|
216
|
+
var rawLength = Number(el.dataset.durationFrames);
|
|
217
|
+
return { start: start, length: rawLength > 0 ? rawLength : duration - start };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function audioTime(el, localTime) {
|
|
221
|
+
if (el.dataset.loop !== "true" || !Number.isFinite(el.duration) || el.duration <= 0) return localTime;
|
|
222
|
+
return localTime % el.duration;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function updateButton(playing) {
|
|
226
|
+
if (!button) return;
|
|
227
|
+
button.textContent = playing ? "Pause" : "Play";
|
|
228
|
+
button.setAttribute("aria-pressed", playing ? "true" : "false");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function prepareAudio(el, currentFrame, shouldPlay) {
|
|
232
|
+
var window = audioWindow(el);
|
|
233
|
+
var within = currentFrame >= window.start && currentFrame < window.start + window.length;
|
|
234
|
+
if (!within) {
|
|
235
|
+
if (!el.paused) el.pause();
|
|
236
|
+
return Promise.resolve();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
var begin = function () {
|
|
240
|
+
var time = audioTime(el, (currentFrame - window.start) / fps);
|
|
241
|
+
if (!shouldPlay || el.paused) el.currentTime = time;
|
|
242
|
+
if (!shouldPlay) {
|
|
243
|
+
if (!el.paused) el.pause();
|
|
244
|
+
return Promise.resolve();
|
|
245
|
+
}
|
|
246
|
+
if (!el.paused) return Promise.resolve();
|
|
247
|
+
return Promise.resolve(el.play());
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (el.readyState >= 1) return begin();
|
|
251
|
+
return new Promise(function (resolve, reject) {
|
|
252
|
+
el.addEventListener("loadedmetadata", function () {
|
|
253
|
+
begin().then(resolve, reject);
|
|
254
|
+
}, { once: true });
|
|
255
|
+
el.addEventListener("error", function () {
|
|
256
|
+
reject(new Error("AnimateIt audio failed to load"));
|
|
257
|
+
}, { once: true });
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function syncAudio(currentFrame, shouldPlay) {
|
|
262
|
+
return Promise.all(audios.map(function (el) {
|
|
263
|
+
return prepareAudio(el, currentFrame, shouldPlay);
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stopAudio() {
|
|
268
|
+
audios.forEach(function (el) { if (!el.paused) el.pause(); });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pause() {
|
|
272
|
+
if (animationFrame !== null) global.cancelAnimationFrame(animationFrame);
|
|
273
|
+
animationFrame = null;
|
|
274
|
+
syncAudio(frame, false);
|
|
275
|
+
updateButton(false);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function seek(nextFrame) {
|
|
279
|
+
frame = player.setFrame(nextFrame);
|
|
280
|
+
if (animationFrame !== null) {
|
|
281
|
+
stopAudio();
|
|
282
|
+
startedAt = global.performance.now();
|
|
283
|
+
startedFrame = frame;
|
|
284
|
+
syncAudio(frame, true).catch(pause);
|
|
285
|
+
} else {
|
|
286
|
+
syncAudio(frame, false);
|
|
287
|
+
}
|
|
288
|
+
return frame;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function tick(now) {
|
|
292
|
+
if (animationFrame === null) return;
|
|
293
|
+
var next = startedFrame + Math.floor(((now - startedAt) / 1000) * fps);
|
|
294
|
+
if (next >= duration) {
|
|
295
|
+
if (!shouldLoop) {
|
|
296
|
+
frame = player.setFrame(duration - 1);
|
|
297
|
+
pause();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
frame = player.setFrame(0);
|
|
301
|
+
stopAudio();
|
|
302
|
+
startedAt = now;
|
|
303
|
+
startedFrame = 0;
|
|
304
|
+
syncAudio(frame, true).catch(pause);
|
|
305
|
+
} else if (next !== frame) {
|
|
306
|
+
frame = player.setFrame(next);
|
|
307
|
+
syncAudio(frame, true).catch(pause);
|
|
308
|
+
}
|
|
309
|
+
if (animationFrame !== null) animationFrame = global.requestAnimationFrame(tick);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function play() {
|
|
313
|
+
if (animationFrame !== null) return Promise.resolve();
|
|
314
|
+
if (frame >= duration - 1) frame = player.setFrame(0);
|
|
315
|
+
startedAt = global.performance.now();
|
|
316
|
+
startedFrame = frame;
|
|
317
|
+
updateButton(true);
|
|
318
|
+
animationFrame = global.requestAnimationFrame(tick);
|
|
319
|
+
return syncAudio(frame, true).catch(function (error) {
|
|
320
|
+
pause();
|
|
321
|
+
throw error;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function toggle() {
|
|
326
|
+
return animationFrame === null ? play() : (pause(), Promise.resolve());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (button) button.addEventListener("click", function () { toggle().catch(function () {}); });
|
|
330
|
+
updateButton(false);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
play: play,
|
|
334
|
+
pause: pause,
|
|
335
|
+
toggle: toggle,
|
|
336
|
+
seek: seek,
|
|
337
|
+
playing: function () { return animationFrame !== null; },
|
|
338
|
+
currentFrame: function () { return frame; }
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function boot() {
|
|
343
|
+
var script = document.querySelector("script[data-animate-it-tracks]");
|
|
344
|
+
if (!script) return;
|
|
345
|
+
var player = createPlayer(JSON.parse(script.textContent), document);
|
|
346
|
+
player.setFrame(0);
|
|
347
|
+
global.__animateIt = { totalFrames: player.duration, setFrame: player.setFrame };
|
|
348
|
+
global.AnimateItRuntime = player;
|
|
349
|
+
if (script.dataset.animateItTransport === "true") {
|
|
350
|
+
var transport = createTransport(
|
|
351
|
+
player,
|
|
352
|
+
Array.prototype.slice.call(document.querySelectorAll("audio[data-from-frame]")),
|
|
353
|
+
{
|
|
354
|
+
loop: script.dataset.animateItLoop === "true",
|
|
355
|
+
button: document.querySelector("[data-animate-it-play]")
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
global.AnimateItTransport = transport;
|
|
359
|
+
if (script.dataset.animateItAutoplay === "true") transport.play().catch(function () {});
|
|
360
|
+
}
|
|
361
|
+
document.documentElement.dataset.animateItReady = "1";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
var api = {
|
|
365
|
+
Easing: Easing,
|
|
366
|
+
round4: round4,
|
|
367
|
+
formatComputed: formatComputed,
|
|
368
|
+
interpolate: interpolate,
|
|
369
|
+
compileTrack: compileTrack,
|
|
370
|
+
seekAnimations: seekAnimations,
|
|
371
|
+
createPlayer: createPlayer,
|
|
372
|
+
createTransport: createTransport
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
376
|
+
module.exports = api;
|
|
377
|
+
} else {
|
|
378
|
+
global.AnimateItRuntimeApi = api;
|
|
379
|
+
if (document.readyState === "loading") {
|
|
380
|
+
document.addEventListener("DOMContentLoaded", boot);
|
|
381
|
+
} else {
|
|
382
|
+
boot();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
})(typeof window !== "undefined" ? window : globalThis);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# The client-side playback runtime, inlined into the player page via
|
|
3
|
+
# `javascript_tag` (no asset-pipeline coupling).
|
|
4
|
+
module Runtime
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def source
|
|
8
|
+
@source ||= File.read(File.expand_path("runtime/runtime.js", __dir__)).freeze
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/animate_it/scene.rb
CHANGED
|
@@ -2,6 +2,7 @@ module AnimateIt
|
|
|
2
2
|
class Scene
|
|
3
3
|
include AnimationHelpers
|
|
4
4
|
include ViewHelpers
|
|
5
|
+
include TextEffects
|
|
5
6
|
|
|
6
7
|
attr_reader :context, :props, :view_context
|
|
7
8
|
|
|
@@ -49,6 +50,29 @@ module AnimateIt
|
|
|
49
50
|
@animations ||= AnimationSet.new
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Register pure per-frame CSS-variable math for client recording.
|
|
54
|
+
def track_vars(name = :root, &block)
|
|
55
|
+
raise ArgumentError, "track_vars requires a block" unless block
|
|
56
|
+
|
|
57
|
+
own_var_groups[name.to_sym] = block
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def var_groups
|
|
61
|
+
inherited = superclass.respond_to?(:var_groups) ? superclass.var_groups : {}
|
|
62
|
+
inherited.merge(own_var_groups)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def text_track(key, &block)
|
|
66
|
+
raise ArgumentError, "text_track requires a block" unless block
|
|
67
|
+
|
|
68
|
+
own_text_tracks[key.to_sym] = block
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def text_tracks
|
|
72
|
+
inherited = superclass.respond_to?(:text_tracks) ? superclass.text_tracks : {}
|
|
73
|
+
inherited.merge(own_text_tracks)
|
|
74
|
+
end
|
|
75
|
+
|
|
52
76
|
# Composition class this scene belongs to. Set by the engine when the
|
|
53
77
|
# scene is mounted via `composition.scene MyScene` / single-scene
|
|
54
78
|
# auto-mount; lets animation property procs reach `Composition.beats`.
|
|
@@ -112,6 +136,14 @@ module AnimateIt
|
|
|
112
136
|
@disable_fragment_caching == true
|
|
113
137
|
end
|
|
114
138
|
|
|
139
|
+
def own_var_groups
|
|
140
|
+
@own_var_groups ||= {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def own_text_tracks
|
|
144
|
+
@own_text_tracks ||= {}
|
|
145
|
+
end
|
|
146
|
+
|
|
115
147
|
# Class-side mirror of ViewHelpers#stub_methods so class-method
|
|
116
148
|
# fixture builders (which run before any Scene instance exists)
|
|
117
149
|
# can also use the helper to define singleton-method stubs on
|
|
@@ -165,7 +197,7 @@ module AnimateIt
|
|
|
165
197
|
# programmatic (no sidecar template) or has multiple acts.
|
|
166
198
|
def body
|
|
167
199
|
tag.div(class: self.class.canvas_class, style: canvas_style) do
|
|
168
|
-
absolute_fill(style: animation_var_style) do
|
|
200
|
+
absolute_fill(style: animation_var_style, **animate_wrapper_attributes) do
|
|
169
201
|
parts = []
|
|
170
202
|
parts << generated_animation_styles if self.class.animations.elements.any?
|
|
171
203
|
parts << render_scene_template(self.class.template)
|
|
@@ -178,7 +210,7 @@ module AnimateIt
|
|
|
178
210
|
# get the same wrapper + animation-var div around custom content.
|
|
179
211
|
def with_canvas(&block)
|
|
180
212
|
tag.div(class: self.class.canvas_class, style: canvas_style) do
|
|
181
|
-
absolute_fill(style: animation_var_style) do
|
|
213
|
+
absolute_fill(style: animation_var_style, **animate_wrapper_attributes) do
|
|
182
214
|
parts = []
|
|
183
215
|
parts << generated_animation_styles if self.class.animations.elements.any?
|
|
184
216
|
parts << view_context.capture(&block) if block
|
|
@@ -189,10 +221,26 @@ module AnimateIt
|
|
|
189
221
|
|
|
190
222
|
# CSS var bag computed from this frame's animations. Available inside
|
|
191
223
|
# custom render methods that want extra inline styles.
|
|
192
|
-
def animation_vars(**extras)
|
|
224
|
+
def animation_vars(group = nil, **extras)
|
|
225
|
+
return Style.vars(**evaluate_var_group(group), **extras) if group
|
|
226
|
+
|
|
193
227
|
Style.vars(**self.class.animations.vars_for(self), **extras)
|
|
194
228
|
end
|
|
195
229
|
|
|
230
|
+
def evaluate_var_group(name)
|
|
231
|
+
block = self.class.var_groups[name.to_sym]
|
|
232
|
+
raise Error, "Unknown track_vars group :#{name} on #{self.class}" unless block
|
|
233
|
+
|
|
234
|
+
instance_exec(&block)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def evaluate_text_track(key)
|
|
238
|
+
block = self.class.text_tracks[key.to_sym]
|
|
239
|
+
raise Error, "Unknown text_track :#{key} on #{self.class}" unless block
|
|
240
|
+
|
|
241
|
+
instance_exec(&block)
|
|
242
|
+
end
|
|
243
|
+
|
|
196
244
|
delegate :composition_class, to: :class
|
|
197
245
|
|
|
198
246
|
private
|
|
@@ -209,6 +257,12 @@ module AnimateIt
|
|
|
209
257
|
view_context.capture(...)
|
|
210
258
|
end
|
|
211
259
|
|
|
260
|
+
def animate_wrapper_attributes
|
|
261
|
+
return {} unless self.class.animations.elements.any?
|
|
262
|
+
|
|
263
|
+
{ data: { animate_vars: Tracks::Recorder::ANIMATE_GROUP } }
|
|
264
|
+
end
|
|
265
|
+
|
|
212
266
|
def generated_animation_styles
|
|
213
267
|
view_context.tag.style(self.class.animations.css_rules.html_safe)
|
|
214
268
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# Declarative word-by-word headline reveals that can be recorded as compact
|
|
3
|
+
# keyframe tracks while retaining server-rendered fallback values.
|
|
4
|
+
module TextEffects
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def word_reveal(key, text, start:, offset: 0, stagger: 4, dur: 12, rise: 18)
|
|
11
|
+
own_word_reveals[key.to_sym] = { kind: :rise, text:, start:, offset:, stagger:, dur:, rise: }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def punch_reveal(key, text, start:, offset: 0, stagger: 5, dur: 10)
|
|
15
|
+
own_word_reveals[key.to_sym] = { kind: :punch, text:, start:, offset:, stagger:, dur: }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def word_reveals_registry
|
|
19
|
+
inherited = superclass.respond_to?(:word_reveals_registry) ? superclass.word_reveals_registry : {}
|
|
20
|
+
inherited.merge(own_word_reveals)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def own_word_reveals
|
|
24
|
+
@own_word_reveals ||= {}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def word_reveal_tracks(key)
|
|
29
|
+
spec = self.class.word_reveals_registry.fetch(key.to_sym)
|
|
30
|
+
base = resolve_reveal_start(spec)
|
|
31
|
+
spec[:text].split.each_with_index.flat_map do |_word, index|
|
|
32
|
+
from = base + (index * spec[:stagger])
|
|
33
|
+
to = from + spec[:dur]
|
|
34
|
+
if spec[:kind] == :punch
|
|
35
|
+
[
|
|
36
|
+
{ var: "#{key}-w#{index}-op", frames: [from, to], values: [0, 1], unit: "" },
|
|
37
|
+
{
|
|
38
|
+
var: "#{key}-w#{index}-sc",
|
|
39
|
+
frames: [from, from + (spec[:dur] * 0.6).round, to],
|
|
40
|
+
values: [1.3, 1.06, 1.0],
|
|
41
|
+
unit: ""
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
else
|
|
45
|
+
[
|
|
46
|
+
{ var: "#{key}-w#{index}-op", frames: [from, to], values: [0, 1], unit: "" },
|
|
47
|
+
{ var: "#{key}-w#{index}-y", frames: [from, to], values: [spec[:rise], 0], unit: "px" }
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reveal_words(key)
|
|
54
|
+
spec = self.class.word_reveals_registry.fetch(key.to_sym)
|
|
55
|
+
static = word_reveal_tracks(key).to_h do |track|
|
|
56
|
+
value = interpolate(
|
|
57
|
+
local_frame,
|
|
58
|
+
track[:frames],
|
|
59
|
+
track[:values],
|
|
60
|
+
easing: :ease_out,
|
|
61
|
+
extrapolate_left: :clamp,
|
|
62
|
+
extrapolate_right: :clamp
|
|
63
|
+
).round(4)
|
|
64
|
+
[track[:var].to_sym, "#{value}#{track[:unit]}"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
spans = spec[:text].split.each_with_index.map do |word, index|
|
|
68
|
+
tag.span(word, style: reveal_word_style(spec, key, index))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
tag.span(
|
|
72
|
+
safe_join(spans, " "),
|
|
73
|
+
data: { animate_vars: reveal_group(key) },
|
|
74
|
+
style: Style.build("display: contents", Style.vars(**static))
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reveal_plain_words(key)
|
|
79
|
+
spec = self.class.word_reveals_registry.fetch(key.to_sym)
|
|
80
|
+
safe_join(spec[:text].split.map { |word| tag.span(word) }, " ")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reveal_group(key)
|
|
84
|
+
"textfx-#{key.to_s.tr("_", "-")}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def resolve_reveal_start(spec)
|
|
90
|
+
base = spec[:start].is_a?(Symbol) ? beat_frame(spec[:start]) : spec[:start]
|
|
91
|
+
base + spec[:offset]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reveal_word_style(spec, key, index)
|
|
95
|
+
if spec[:kind] == :punch
|
|
96
|
+
"display:inline-block; opacity: var(--#{key}-w#{index}-op, 0); " \
|
|
97
|
+
"transform: scale(var(--#{key}-w#{index}-sc, 1.3));"
|
|
98
|
+
else
|
|
99
|
+
"display:inline-block; opacity: var(--#{key}-w#{index}-op, 0); " \
|
|
100
|
+
"transform: translateY(var(--#{key}-w#{index}-y, #{spec[:rise]}px));"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# Validates the server/browser track-document boundary before embedding it.
|
|
3
|
+
module TrackDocumentSchema
|
|
4
|
+
CURRENT_VERSION = 2
|
|
5
|
+
SUPPORTED_VERSIONS = [1, CURRENT_VERSION].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def validate!(document)
|
|
10
|
+
data = document.respond_to?(:as_json) ? document.as_json : document
|
|
11
|
+
raise Error, "AnimateIt track document must be a JSON object" unless data.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
version = data["v"]
|
|
14
|
+
unless SUPPORTED_VERSIONS.include?(version)
|
|
15
|
+
raise Error,
|
|
16
|
+
"Unsupported AnimateIt track schema #{version.inspect}; " \
|
|
17
|
+
"supported versions are #{SUPPORTED_VERSIONS.join(", ")}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
validate_positive_number!(data, "fps")
|
|
21
|
+
validate_positive_integer!(data, "duration")
|
|
22
|
+
validate_hash!(data, "groups")
|
|
23
|
+
validate_hash!(data, "texts")
|
|
24
|
+
validate_array!(data, "layers")
|
|
25
|
+
validate_v2!(data) if version == CURRENT_VERSION
|
|
26
|
+
data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_v2!(data)
|
|
30
|
+
validate_hash!(data, "groupSelectors")
|
|
31
|
+
validate_hash!(data, "textSelectors")
|
|
32
|
+
validate_selector_keys!(data["groups"], data["groupSelectors"], "group")
|
|
33
|
+
validate_selector_keys!(data["texts"], data["textSelectors"], "text")
|
|
34
|
+
end
|
|
35
|
+
private_class_method :validate_v2!
|
|
36
|
+
|
|
37
|
+
def validate_selector_keys!(tracks, selectors, kind)
|
|
38
|
+
extra = selectors.keys - tracks.keys
|
|
39
|
+
return if extra.empty?
|
|
40
|
+
|
|
41
|
+
raise Error, "AnimateIt v2 #{kind} selectors reference missing tracks: #{extra.join(", ")}"
|
|
42
|
+
end
|
|
43
|
+
private_class_method :validate_selector_keys!
|
|
44
|
+
|
|
45
|
+
def validate_positive_number!(data, key)
|
|
46
|
+
value = data[key]
|
|
47
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
48
|
+
|
|
49
|
+
raise Error, "AnimateIt track document #{key} must be a positive number"
|
|
50
|
+
end
|
|
51
|
+
private_class_method :validate_positive_number!
|
|
52
|
+
|
|
53
|
+
def validate_positive_integer!(data, key)
|
|
54
|
+
value = data[key]
|
|
55
|
+
return if value.is_a?(Integer) && value.positive?
|
|
56
|
+
|
|
57
|
+
raise Error, "AnimateIt track document #{key} must be a positive integer"
|
|
58
|
+
end
|
|
59
|
+
private_class_method :validate_positive_integer!
|
|
60
|
+
|
|
61
|
+
def validate_hash!(data, key)
|
|
62
|
+
raise Error, "AnimateIt track document #{key} must be an object" unless data[key].is_a?(Hash)
|
|
63
|
+
end
|
|
64
|
+
private_class_method :validate_hash!
|
|
65
|
+
|
|
66
|
+
def validate_array!(data, key)
|
|
67
|
+
raise Error, "AnimateIt track document #{key} must be an array" unless data[key].is_a?(Array)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :validate_array!
|
|
70
|
+
end
|
|
71
|
+
end
|