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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +131 -19
  4. data/app/controllers/animate_it/frames_controller.rb +7 -0
  5. data/app/controllers/animate_it/public_players_controller.rb +69 -0
  6. data/app/controllers/animate_it/renders_controller.rb +3 -17
  7. data/app/controllers/animate_it/studio_controller.rb +1 -0
  8. data/app/jobs/animate_it/render_job.rb +2 -0
  9. data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
  10. data/app/views/animate_it/frames/player.html.haml +81 -0
  11. data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
  12. data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
  13. data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
  14. data/app/views/animate_it/studio/show.html.haml +10 -3
  15. data/config/routes.rb +5 -0
  16. data/lib/animate_it/asset_manifest.rb +92 -0
  17. data/lib/animate_it/asset_renderer.rb +39 -7
  18. data/lib/animate_it/composition.rb +100 -9
  19. data/lib/animate_it/embed_helper.rb +22 -0
  20. data/lib/animate_it/engine.rb +4 -0
  21. data/lib/animate_it/runtime/runtime.js +385 -0
  22. data/lib/animate_it/runtime.rb +11 -0
  23. data/lib/animate_it/scene.rb +57 -3
  24. data/lib/animate_it/text_effects.rb +104 -0
  25. data/lib/animate_it/track_document_schema.rb +71 -0
  26. data/lib/animate_it/tracks/document.rb +100 -0
  27. data/lib/animate_it/tracks/layer.rb +13 -0
  28. data/lib/animate_it/tracks/recorder.rb +149 -0
  29. data/lib/animate_it/verification.rb +187 -0
  30. data/lib/animate_it/version.rb +1 -1
  31. data/lib/animate_it/video_renderer.rb +89 -26
  32. data/lib/animate_it/view_helpers.rb +11 -1
  33. data/lib/animate_it.rb +9 -0
  34. data/lib/tasks/animate_it_tasks.rake +133 -0
  35. 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
@@ -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