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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/MIT-LICENSE +21 -0
- data/README.md +166 -0
- data/app/controllers/animate_it/application_controller.rb +73 -0
- data/app/controllers/animate_it/audio_controller.rb +31 -0
- data/app/controllers/animate_it/frames_controller.rb +22 -0
- data/app/controllers/animate_it/props_controller.rb +10 -0
- data/app/controllers/animate_it/renders_controller.rb +82 -0
- data/app/controllers/animate_it/studio_controller.rb +17 -0
- data/app/jobs/animate_it/application_job.rb +6 -0
- data/app/jobs/animate_it/render_job.rb +28 -0
- data/app/views/animate_it/frames/filmstrip.html.haml +65 -0
- data/app/views/animate_it/frames/fragment.html.haml +6 -0
- data/app/views/animate_it/frames/show.html.haml +54 -0
- data/app/views/animate_it/props/_form.html.haml +3 -0
- data/app/views/animate_it/props/update.html.haml +8 -0
- data/app/views/animate_it/renders/create.html.haml +9 -0
- data/app/views/animate_it/renders/show.html.haml +10 -0
- data/app/views/animate_it/studio/_composition_list.html.haml +7 -0
- data/app/views/animate_it/studio/_preview_pane.html.haml +35 -0
- data/app/views/animate_it/studio/_props_pane.html.haml +14 -0
- data/app/views/animate_it/studio/_studio_script.html.haml +273 -0
- data/app/views/animate_it/studio/_studio_styles.html.haml +284 -0
- data/app/views/animate_it/studio/_timeline_lanes.html.haml +34 -0
- data/app/views/animate_it/studio/index.html.haml +8 -0
- data/app/views/animate_it/studio/show.html.haml +23 -0
- data/app/views/layouts/animate_it/application.html.haml +17 -0
- data/config/routes.rb +12 -0
- data/exe/render_animate_it_video +53 -0
- data/lib/animate_it/animation.rb +189 -0
- data/lib/animate_it/animation_helpers.rb +86 -0
- data/lib/animate_it/asset_renderer.rb +49 -0
- data/lib/animate_it/beats.rb +61 -0
- data/lib/animate_it/composition.rb +354 -0
- data/lib/animate_it/configuration.rb +27 -0
- data/lib/animate_it/easing.rb +40 -0
- data/lib/animate_it/engine.rb +31 -0
- data/lib/animate_it/errors.rb +4 -0
- data/lib/animate_it/frame_context.rb +28 -0
- data/lib/animate_it/frame_duration.rb +13 -0
- data/lib/animate_it/output.rb +57 -0
- data/lib/animate_it/props_schema.rb +49 -0
- data/lib/animate_it/registry.rb +30 -0
- data/lib/animate_it/render_store.rb +189 -0
- data/lib/animate_it/scene.rb +237 -0
- data/lib/animate_it/style.rb +23 -0
- data/lib/animate_it/timeline.rb +81 -0
- data/lib/animate_it/timing.rb +63 -0
- data/lib/animate_it/units.rb +38 -0
- data/lib/animate_it/version.rb +3 -0
- data/lib/animate_it/video_renderer.rb +219 -0
- data/lib/animate_it/view_helpers.rb +84 -0
- data/lib/animate_it.rb +93 -0
- data/lib/generators/animate_it/install/install_generator.rb +40 -0
- data/lib/generators/animate_it/install/templates/animate_it.rb +8 -0
- data/lib/tasks/animate_it_tasks.rake +83 -0
- 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,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,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
|