animate_it 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +166 -0
  5. data/app/controllers/animate_it/application_controller.rb +73 -0
  6. data/app/controllers/animate_it/audio_controller.rb +31 -0
  7. data/app/controllers/animate_it/frames_controller.rb +22 -0
  8. data/app/controllers/animate_it/props_controller.rb +10 -0
  9. data/app/controllers/animate_it/renders_controller.rb +82 -0
  10. data/app/controllers/animate_it/studio_controller.rb +17 -0
  11. data/app/jobs/animate_it/application_job.rb +6 -0
  12. data/app/jobs/animate_it/render_job.rb +28 -0
  13. data/app/views/animate_it/frames/filmstrip.html.haml +65 -0
  14. data/app/views/animate_it/frames/fragment.html.haml +6 -0
  15. data/app/views/animate_it/frames/show.html.haml +54 -0
  16. data/app/views/animate_it/props/_form.html.haml +3 -0
  17. data/app/views/animate_it/props/update.html.haml +8 -0
  18. data/app/views/animate_it/renders/create.html.haml +9 -0
  19. data/app/views/animate_it/renders/show.html.haml +10 -0
  20. data/app/views/animate_it/studio/_composition_list.html.haml +7 -0
  21. data/app/views/animate_it/studio/_preview_pane.html.haml +35 -0
  22. data/app/views/animate_it/studio/_props_pane.html.haml +14 -0
  23. data/app/views/animate_it/studio/_studio_script.html.haml +273 -0
  24. data/app/views/animate_it/studio/_studio_styles.html.haml +284 -0
  25. data/app/views/animate_it/studio/_timeline_lanes.html.haml +34 -0
  26. data/app/views/animate_it/studio/index.html.haml +8 -0
  27. data/app/views/animate_it/studio/show.html.haml +23 -0
  28. data/app/views/layouts/animate_it/application.html.haml +17 -0
  29. data/config/routes.rb +12 -0
  30. data/exe/render_animate_it_video +53 -0
  31. data/lib/animate_it/animation.rb +189 -0
  32. data/lib/animate_it/animation_helpers.rb +86 -0
  33. data/lib/animate_it/asset_renderer.rb +49 -0
  34. data/lib/animate_it/beats.rb +61 -0
  35. data/lib/animate_it/composition.rb +354 -0
  36. data/lib/animate_it/configuration.rb +27 -0
  37. data/lib/animate_it/easing.rb +40 -0
  38. data/lib/animate_it/engine.rb +31 -0
  39. data/lib/animate_it/errors.rb +4 -0
  40. data/lib/animate_it/frame_context.rb +28 -0
  41. data/lib/animate_it/frame_duration.rb +13 -0
  42. data/lib/animate_it/output.rb +57 -0
  43. data/lib/animate_it/props_schema.rb +49 -0
  44. data/lib/animate_it/registry.rb +30 -0
  45. data/lib/animate_it/render_store.rb +189 -0
  46. data/lib/animate_it/scene.rb +237 -0
  47. data/lib/animate_it/style.rb +23 -0
  48. data/lib/animate_it/timeline.rb +81 -0
  49. data/lib/animate_it/timing.rb +63 -0
  50. data/lib/animate_it/units.rb +38 -0
  51. data/lib/animate_it/version.rb +3 -0
  52. data/lib/animate_it/video_renderer.rb +219 -0
  53. data/lib/animate_it/view_helpers.rb +84 -0
  54. data/lib/animate_it.rb +93 -0
  55. data/lib/generators/animate_it/install/install_generator.rb +40 -0
  56. data/lib/generators/animate_it/install/templates/animate_it.rb +8 -0
  57. data/lib/tasks/animate_it_tasks.rake +83 -0
  58. metadata +120 -0
@@ -0,0 +1,354 @@
1
+ module AnimateIt
2
+ class Composition
3
+ SUPPORTED_OUTPUT_FORMATS = %i[webm mp4 mov gif png_sequence png].freeze
4
+
5
+ class << self
6
+ attr_reader :timeline, :props_schema, :width, :height, :duration_in_frames
7
+
8
+ def inherited(subclass)
9
+ subclass.instance_variable_set(:@timeline, Timeline.new)
10
+ subclass.instance_variable_set(:@props_schema, PropsSchema.new)
11
+ subclass.instance_variable_set(:@fps, 30)
12
+ subclass.instance_variable_set(:@width, 1920)
13
+ subclass.instance_variable_set(:@height, 1080)
14
+ subclass.instance_variable_set(:@zoom, 1.0)
15
+ subclass.instance_variable_set(:@duration_in_frames, 30)
16
+ subclass.instance_variable_set(:@output_format, :webm)
17
+ super
18
+ end
19
+
20
+ def output_format(value = nil)
21
+ return @output_format if value.nil?
22
+
23
+ unless SUPPORTED_OUTPUT_FORMATS.include?(value)
24
+ raise ArgumentError,
25
+ "Unsupported output_format #{value.inspect} — pick :webm, :mp4, :mov, :gif, :png_sequence, or :png"
26
+ end
27
+
28
+ @output_format = value
29
+ end
30
+
31
+ def transparent?
32
+ %i[webm mov gif png_sequence png].include?(output_format)
33
+ end
34
+
35
+ # Declarative outputs: list every (format, path) target this composition
36
+ # should produce. Without a block, returns the registered outputs (empty
37
+ # array if none have been declared).
38
+ #
39
+ # outputs do
40
+ # mp4 to: "app/assets/images/pages/product/screener-hero.mp4"
41
+ # gif to: "app/assets/images/pages/product/screener-hero.gif"
42
+ # png to: "app/assets/images/pages/product/screener-hero-first.png", frame: 0
43
+ # end
44
+ #
45
+ # Paths are repo-relative; the renderer joins them onto Rails.root.
46
+ def outputs(&block)
47
+ @outputs ||= []
48
+ return @outputs unless block
49
+
50
+ builder = OutputsBuilder.new(assets_dir: assets_dir, basename: output_basename)
51
+ builder.instance_eval(&block)
52
+ @outputs = builder.outputs.freeze
53
+ # Keep `output_format` in sync with the first animated output so single-
54
+ # output legacy callers (bin/render_animate_it_video, AnimateIt Studio)
55
+ # see a sensible format without re-declaring it.
56
+ primary = @outputs.find { |o| o.frame.nil? }
57
+ @output_format = primary.format if primary
58
+ @outputs
59
+ end
60
+
61
+ def id(value = nil)
62
+ return @id if value.nil?
63
+
64
+ @id = value.to_s
65
+ AnimateIt.register(self)
66
+ end
67
+
68
+ def fps(value = nil)
69
+ return @fps if value.nil?
70
+
71
+ @fps = value.to_i
72
+ end
73
+
74
+ def size(width, height)
75
+ @width = width.to_i
76
+ @height = height.to_i
77
+ end
78
+
79
+ # Browser-side magnification applied via CSS `zoom` on `<body>` in the
80
+ # frame layout. Multiplies every visual length (font sizes, padding,
81
+ # widths) without changing the output viewport, so heros built against
82
+ # px-sized host partials can lift up the apparent scale without
83
+ # rewriting CSS. Default `1.0` (no zoom).
84
+ #
85
+ # size 1080, 1170
86
+ # zoom 1.1
87
+ #
88
+ # The viewport stays at `size(...)`; only the rendered content inside
89
+ # `<body>` is magnified. Combine `zoom` with a smaller `size` to "zoom
90
+ # in" the apparent scale of the composition at the same aspect ratio.
91
+ def zoom(value = nil)
92
+ return @zoom if value.nil?
93
+
94
+ @zoom = value.to_f
95
+ end
96
+
97
+ def duration(value = nil)
98
+ return @duration_in_frames if value.nil?
99
+
100
+ @duration_in_frames = Units.frames(value, fps:)
101
+ end
102
+
103
+ def props(&block)
104
+ props_schema.instance_eval(&block) if block
105
+ props_schema
106
+ end
107
+
108
+ # ----- Beats: named time markers on the composition timeline ------
109
+ def beat(name, at:, length:)
110
+ beats.add(name, at: at, length: length)
111
+ end
112
+
113
+ def beats
114
+ @beats ||= Beats.new(fps: fps)
115
+ end
116
+
117
+ # ----- Outputs path helpers --------------------------------------
118
+ # Declare a directory + basename so `outputs do ... end` entries can
119
+ # be specified by format alone:
120
+ #
121
+ # assets_dir "app/assets/images/pages/product"
122
+ # output_basename "screener-hero"
123
+ #
124
+ # outputs do
125
+ # mp4
126
+ # webm
127
+ # gif
128
+ # png frame: 0 # → screener-hero-first.png
129
+ # end
130
+ def assets_dir(value = nil)
131
+ @assets_dir = value.to_s if value
132
+ @assets_dir
133
+ end
134
+
135
+ def output_basename(value = nil)
136
+ @output_basename = value.to_s if value
137
+ @output_basename || @id
138
+ end
139
+
140
+ # ----- Audio DSL --------------------------------------------------
141
+ # Loop a track for the full duration (or a slice). Background music.
142
+ def audio_loop(path, duration: nil, from: 0, name: nil, gain: 1.0, track: :background)
143
+ # Simplest implementation: place the same source once at `from`
144
+ # for the requested length. The renderer's audio mixer handles
145
+ # truncation if the underlying file is longer than the segment.
146
+ length = duration || (@duration_in_frames - Units.frames(from, fps: fps))
147
+ audio(path, duration: length, from: from, name: name || "loop-#{File.basename(path.to_s)}", track: track,
148
+ gain: gain)
149
+ end
150
+
151
+ # Play a voice-over clip at a named beat (or time/frame). The clip's
152
+ # duration defaults to the beat's length.
153
+ def voice_over(path, at:, duration: nil, name: nil, gain: 1.0, track: :voice)
154
+ beat = at.is_a?(Symbol) ? beats.fetch(at) : nil
155
+ from = beat ? beat.start_frame : at
156
+ length = duration || (beat ? beat.duration_frames : 1.second)
157
+ audio(path, duration: length, from: from, name: name || File.basename(path.to_s), track: track, gain: gain)
158
+ end
159
+
160
+ def sequence(
161
+ from: 0,
162
+ start_at: nil,
163
+ duration: nil,
164
+ name: nil,
165
+ scene: nil,
166
+ layout: :none,
167
+ class_name: nil,
168
+ style: nil,
169
+ show_in_timeline: true,
170
+ track: :main,
171
+ &block
172
+ )
173
+ timeline.add_segment(
174
+ name: name || scene&.name || "sequence-#{timeline.segments.size + 1}",
175
+ from_frame: Units.frames(start_at || from, fps:),
176
+ duration_frames: Units.frames(duration, fps:),
177
+ scene_class: scene,
178
+ renderer: block,
179
+ layout:,
180
+ class_name:,
181
+ style:,
182
+ show_in_timeline:,
183
+ track:
184
+ )
185
+ end
186
+
187
+ def scene(scene_class, duration: nil, from: 0, start_at: nil, name: nil, **)
188
+ # Tell the scene class which composition it belongs to so its
189
+ # animation property procs can resolve symbolic beat names against
190
+ # the composition's beat registry.
191
+ scene_class.composition_class = self if scene_class.respond_to?(:composition_class=)
192
+ # If duration isn't given, default to the full composition duration
193
+ # (single-scene shorthand support).
194
+ duration ||= @duration_in_frames
195
+ sequence(from:, start_at:, duration:, name:, scene: scene_class, **)
196
+ end
197
+
198
+ # Sweep nested AnimateIt::Scene subclasses and auto-mount the single
199
+ # scene if no explicit `scene` / `series` was declared. Called by the
200
+ # registrar after composition file load.
201
+ def auto_mount_single_scene!
202
+ return if timeline.segments.any?
203
+
204
+ nested = constants.map { |c| const_get(c) }.select do |const|
205
+ const.is_a?(Class) && const < AnimateIt::Scene
206
+ end
207
+ return unless nested.size == 1
208
+
209
+ scene(nested.first)
210
+ end
211
+
212
+ # Place an audio segment on its own track. The renderer doesn't draw
213
+ # audio; the studio plays it via <audio> tags synced to the scrubber, and
214
+ # ffmpeg muxes it into the final encode.
215
+ def audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0)
216
+ timeline.add_segment(
217
+ name: name || File.basename(path.to_s),
218
+ from_frame: Units.frames(start_at || from, fps:),
219
+ duration_frames: Units.frames(duration, fps:),
220
+ kind: :audio,
221
+ track:,
222
+ source: { path: path.to_s, gain: gain.to_f },
223
+ show_in_timeline: true
224
+ )
225
+ end
226
+
227
+ def series(&)
228
+ SeriesBuilder.new(self).instance_eval(&)
229
+ end
230
+
231
+ def transition_series(&)
232
+ TransitionSeriesBuilder.new(self).instance_eval(&)
233
+ end
234
+
235
+ def frame_context(frame:, props:, segment: nil)
236
+ FrameContext.new(
237
+ self,
238
+ props_schema.resolve(props),
239
+ frame.to_i,
240
+ segment ? segment.local_frame(frame.to_i) : frame.to_i,
241
+ fps,
242
+ duration_in_frames,
243
+ width,
244
+ height,
245
+ segment
246
+ )
247
+ end
248
+
249
+ def render_frame(view_context, frame:, props: {})
250
+ resolved_props = props_schema.resolve(props)
251
+ segments = timeline.active_segments(frame.to_i, kind: :scene)
252
+ return "" if segments.empty?
253
+
254
+ rendered_segments = segments.map do |segment|
255
+ context = frame_context(frame:, props: resolved_props, segment:)
256
+ render_segment(view_context, segment, context)
257
+ end
258
+ view_context.safe_join(rendered_segments)
259
+ end
260
+
261
+ private
262
+
263
+ def render_segment(view_context, segment, context)
264
+ return "" if segment.kind == :transition
265
+
266
+ content = if segment.scene_class
267
+ segment.scene_class.new(context:, props: context.props).render(view_context)
268
+ else
269
+ view_context.capture(context, context.props, &segment.renderer)
270
+ end
271
+
272
+ return content unless segment.layout == :absolute_fill
273
+
274
+ view_context.tag.div(
275
+ content,
276
+ class: ["animate-it-absolute-fill", segment.class_name].compact,
277
+ style: Style.build(
278
+ "position: absolute",
279
+ "inset: 0",
280
+ "width: 100%",
281
+ "height: 100%",
282
+ "display: flex",
283
+ "flex-direction: column",
284
+ segment.style
285
+ )
286
+ )
287
+ end
288
+ end
289
+
290
+ class SeriesBuilder
291
+ def initialize(composition)
292
+ @composition = composition
293
+ @cursor = 0
294
+ end
295
+
296
+ def scene(scene_class, duration:, after: 0, start_at: nil, name: nil, **)
297
+ duration_frames = Units.frames(duration, fps: @composition.fps)
298
+ from_frame = if start_at
299
+ Units.frames(start_at, fps: @composition.fps)
300
+ else
301
+ @cursor + Units.frames(after, fps: @composition.fps)
302
+ end
303
+
304
+ @composition.scene(scene_class, from: from_frame, duration: duration_frames.frames, name:, **)
305
+ @cursor = from_frame + duration_frames
306
+ end
307
+
308
+ def audio(path, duration: nil, after: 0, start_at: nil, name: nil, track: :audio, gain: 1.0)
309
+ from_frame = if start_at
310
+ Units.frames(start_at, fps: @composition.fps)
311
+ else
312
+ @cursor + Units.frames(after, fps: @composition.fps)
313
+ end
314
+
315
+ @composition.audio(path, duration:, from: from_frame, name:, track:, gain:)
316
+ end
317
+
318
+ def transition(_name, duration:)
319
+ @cursor -= Units.frames(duration, fps: @composition.fps)
320
+ end
321
+ end
322
+
323
+ class TransitionSeriesBuilder
324
+ def initialize(composition)
325
+ @composition = composition
326
+ @cursor = 0
327
+ end
328
+
329
+ def scene(scene_class, duration:, name: nil, **)
330
+ duration_frames = Units.frames(duration, fps: @composition.fps)
331
+ @composition.scene(scene_class, from: @cursor, duration: duration_frames.frames, name:, **)
332
+ @cursor += duration_frames
333
+ end
334
+
335
+ def transition(name, duration:)
336
+ @cursor -= Units.frames(duration, fps: @composition.fps)
337
+ @composition.timeline.add_segment(
338
+ name: "#{name}-transition",
339
+ from_frame: @cursor,
340
+ duration_frames: Units.frames(duration, fps: @composition.fps),
341
+ kind: :transition,
342
+ transition: name,
343
+ show_in_timeline: true
344
+ )
345
+ end
346
+
347
+ def overlay(scene_class, duration:, offset: 0, name: nil, **)
348
+ from_frame = @cursor - (Units.frames(duration,
349
+ fps: @composition.fps) / 2) + Units.frames(offset, fps: @composition.fps)
350
+ @composition.scene(scene_class, from: from_frame, duration:, name:, **)
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,27 @@
1
+ module AnimateIt
2
+ # Host-app-overridable configuration. Set via the initializer that
3
+ # `bin/rails animate_it:install` generates, or in any initializer:
4
+ #
5
+ # AnimateIt.configure do |config|
6
+ # config.mount_path = "/studio"
7
+ # end
8
+ class Configuration
9
+ attr_accessor :mount_path
10
+
11
+ # Host-app stylesheets to load into every rendered frame/filmstrip <head>.
12
+ # Compositions that re-use host partials (which expect the host's component
13
+ # CSS) list the bundles they need here, e.g.:
14
+ #
15
+ # config.render_stylesheets = %w[application components/star-ratings]
16
+ #
17
+ # Names are passed straight to `stylesheet_link_tag`, so they resolve
18
+ # through the host's asset pipeline. Default empty — a composition built
19
+ # from self-contained markup needs none.
20
+ attr_accessor :render_stylesheets
21
+
22
+ def initialize
23
+ @mount_path = "/animate_it"
24
+ @render_stylesheets = []
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ module AnimateIt
2
+ module Easing
3
+ module_function
4
+
5
+ def resolve(easing)
6
+ return easing if easing.respond_to?(:call)
7
+
8
+ case easing
9
+ when nil, :linear
10
+ method(:linear)
11
+ when :ease_in
12
+ method(:ease_in)
13
+ when :ease_out
14
+ method(:ease_out)
15
+ when :ease_in_out
16
+ method(:ease_in_out)
17
+ else
18
+ raise ArgumentError, "Unknown easing: #{easing.inspect}"
19
+ end
20
+ end
21
+
22
+ def linear(progress)
23
+ progress
24
+ end
25
+
26
+ def ease_in(progress)
27
+ progress * progress
28
+ end
29
+
30
+ def ease_out(progress)
31
+ 1 - ((1 - progress) * (1 - progress))
32
+ end
33
+
34
+ def ease_in_out(progress)
35
+ return 2 * progress * progress if progress < 0.5
36
+
37
+ 1 - ((((-2 * progress) + 2)**2) / 2.0)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ require "rails/engine"
2
+
3
+ module AnimateIt
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace AnimateIt
6
+
7
+ # `app/videos/` is automatically autoloaded + reloaded by Rails: the
8
+ # host's `paths.add "app", glob: "*"` default registers every
9
+ # `app/<subdir>/` with Zeitwerk's `main` autoloader, which has reload
10
+ # enabled in development. We don't need to add the path manually.
11
+ #
12
+ # On boot AND on every dev-mode reload (after Zeitwerk has cleared
13
+ # old constants and removed orphaned descendants), reset the registry
14
+ # and eager-load `app/videos` so composition classes self-register
15
+ # via the `id "..."` DSL before any request hits `AnimateIt.compositions`,
16
+ # the Studio UI, or the renderer.
17
+ #
18
+ # Reload sequence in development:
19
+ # 1. file changes under app/videos/
20
+ # 2. Rails reloader → Zeitwerk reload → `remove_const` for every
21
+ # autoloaded composition class
22
+ # 3. our `to_prepare` callback → `AnimateIt.reload_compositions!`
23
+ # → registry.reset! + eager_load_dir → every class redefines
24
+ # from scratch, `inherited` fires, fresh Timeline/@beats/@outputs.
25
+ initializer "animate_it.load_compositions" do
26
+ Rails.application.config.to_prepare do
27
+ AnimateIt.reload_compositions!
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,4 @@
1
+ module AnimateIt
2
+ class Error < StandardError; end
3
+ class CompositionNotFoundError < Error; end
4
+ end
@@ -0,0 +1,28 @@
1
+ module AnimateIt
2
+ FrameContext = Data.define(
3
+ :composition,
4
+ :props,
5
+ :frame,
6
+ :local_frame,
7
+ :fps,
8
+ :duration_in_frames,
9
+ :width,
10
+ :height,
11
+ :segment
12
+ ) do
13
+ def progress(duration_frames: nil)
14
+ total = duration_frames || segment&.duration_frames || duration_in_frames
15
+ return 0.0 if total.to_i <= 1
16
+
17
+ local_frame.to_f / (total - 1)
18
+ end
19
+
20
+ def interpolate(input, input_range, output_range, **)
21
+ Timing.interpolate(input, input_range, output_range, **)
22
+ end
23
+
24
+ def spring(**)
25
+ Timing.spring(frame: local_frame, fps:, **)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ module AnimateIt
2
+ FrameDuration = Data.define(:value) do
3
+ delegate :to_i, to: :value
4
+ end
5
+ end
6
+
7
+ unless Numeric.method_defined?(:frames)
8
+ class Numeric
9
+ def frames
10
+ AnimateIt::FrameDuration.new(to_i)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,57 @@
1
+ module AnimateIt
2
+ # Declarative output target: one rendered file at one path.
3
+ # `format` selects the encoder; `path` is repo-relative (joined onto
4
+ # Rails.root by the renderer); `frame` is set for single-frame stills,
5
+ # nil for animated outputs (the whole composition).
6
+ Output = Struct.new(:format, :path, :frame, keyword_init: true) do
7
+ def still?
8
+ !frame.nil?
9
+ end
10
+
11
+ def absolute_path
12
+ Rails.root.join(path.to_s)
13
+ end
14
+ end
15
+
16
+ # Block DSL used inside `Composition.outputs do ... end`.
17
+ #
18
+ # Each method declares one Output. Two ways to call each format:
19
+ #
20
+ # # explicit path
21
+ # mp4 to: "app/assets/images/pages/product/screener-hero.mp4"
22
+ #
23
+ # # convention-based path (composition declared `assets_dir` + `output_basename`)
24
+ # mp4
25
+ # webm
26
+ # gif
27
+ # png frame: 0 # → <basename>-first.png
28
+ class OutputsBuilder
29
+ attr_reader :outputs
30
+
31
+ def initialize(assets_dir: nil, basename: nil)
32
+ @outputs = []
33
+ @assets_dir = assets_dir
34
+ @basename = basename
35
+ end
36
+
37
+ Composition::SUPPORTED_OUTPUT_FORMATS.each do |fmt|
38
+ define_method(fmt) do |to: nil, frame: nil|
39
+ path = to || conventional_path(fmt, frame)
40
+ @outputs << Output.new(format: fmt, path: path, frame: frame)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def conventional_path(fmt, frame)
47
+ if @assets_dir.blank? || @basename.blank?
48
+ raise ArgumentError,
49
+ "Pass `to:` or declare `assets_dir`/`output_basename` on the composition"
50
+ end
51
+
52
+ ext = AnimateIt::VideoRenderer::EXTENSION_FOR_FORMAT[fmt] || fmt.to_s
53
+ suffix = frame&.zero? ? "-first" : nil # frame is nil for animated outputs
54
+ File.join(@assets_dir, "#{@basename}#{suffix}.#{ext}")
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ module AnimateIt
2
+ class PropsSchema
3
+ Field = Data.define(:name, :type, :default, :options)
4
+
5
+ attr_reader :fields
6
+
7
+ def initialize
8
+ @fields = []
9
+ end
10
+
11
+ def string(name, default: nil, **)
12
+ field(name, :string, default:, **)
13
+ end
14
+
15
+ def color(name, default: nil, **)
16
+ field(name, :color, default:, **)
17
+ end
18
+
19
+ def asset(name, default: nil, **)
20
+ field(name, :asset, default:, **)
21
+ end
22
+
23
+ def integer(name, default: nil, **)
24
+ field(name, :integer, default:, **)
25
+ end
26
+
27
+ def number(name, default: nil, **)
28
+ field(name, :number, default:, **)
29
+ end
30
+
31
+ def boolean(name, default: nil, **)
32
+ field(name, :boolean, default:, **)
33
+ end
34
+
35
+ def defaults
36
+ fields.to_h { |field| [field.name, field.default] }
37
+ end
38
+
39
+ def resolve(input)
40
+ defaults.merge(input.transform_keys(&:to_sym))
41
+ end
42
+
43
+ private
44
+
45
+ def field(name, type, default:, **options)
46
+ fields << Field.new(name.to_sym, type, default, options)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,30 @@
1
+ module AnimateIt
2
+ class Registry
3
+ def initialize
4
+ @compositions = {}
5
+ end
6
+
7
+ def register(composition_class)
8
+ id = composition_class.id
9
+ raise ArgumentError, "Composition #{composition_class.name} must declare an id" if id.blank?
10
+
11
+ @compositions[id] = composition_class
12
+ end
13
+
14
+ def fetch(id)
15
+ @compositions.fetch(id) do
16
+ raise CompositionNotFoundError, "No AnimateIt composition registered for #{id.inspect}"
17
+ end
18
+ end
19
+
20
+ def all
21
+ @compositions.values.sort_by(&:id)
22
+ end
23
+
24
+ # Drop every registration. Used by `AnimateIt.reload_compositions!` on
25
+ # dev-mode reload so renamed/removed composition ids don't linger.
26
+ def reset!
27
+ @compositions.clear
28
+ end
29
+ end
30
+ end