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,189 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class RenderStore
|
|
3
|
+
CHANNEL = "animate_it_renders".freeze
|
|
4
|
+
TARGET = "animate_it_renders".freeze
|
|
5
|
+
|
|
6
|
+
Render = Data.define(
|
|
7
|
+
:id,
|
|
8
|
+
:composition_id,
|
|
9
|
+
:status,
|
|
10
|
+
:current_frame,
|
|
11
|
+
:total_frames,
|
|
12
|
+
:output_path,
|
|
13
|
+
:error
|
|
14
|
+
) do
|
|
15
|
+
def progress_percent
|
|
16
|
+
return 0 if total_frames.to_i.zero?
|
|
17
|
+
|
|
18
|
+
((current_frame.to_f / total_frames) * 100).round
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def create!(id:, composition_id:, total_frames:, output_path:)
|
|
24
|
+
render = nil
|
|
25
|
+
synchronize do
|
|
26
|
+
render = Render.new(id, composition_id, :queued, 0, total_frames, output_path.to_s, nil)
|
|
27
|
+
renders[id] = render
|
|
28
|
+
end
|
|
29
|
+
broadcast_append(render)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def start!(id)
|
|
33
|
+
update!(id, status: :rendering)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def progress!(id, frame:, total_frames:)
|
|
37
|
+
update!(id, status: :rendering, current_frame: frame, total_frames:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def complete!(id)
|
|
41
|
+
render = fetch(id)
|
|
42
|
+
update!(id, status: :complete, current_frame: render.total_frames)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fail!(id, error)
|
|
46
|
+
update!(id, status: :failed, error: error.message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cancel!(id)
|
|
50
|
+
update!(id, status: :cancelled, error: "Cancelled by user")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cancelled?(id)
|
|
54
|
+
fetch(id).status == :cancelled
|
|
55
|
+
rescue KeyError
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def all
|
|
60
|
+
synchronize { renders.values.reverse }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fetch(id)
|
|
64
|
+
synchronize { renders.fetch(id) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# nil-tolerant lookup — returns nil when the render isn't registered
|
|
68
|
+
# (e.g. server restart cleared the in-memory store but a tab still
|
|
69
|
+
# holds the old render id).
|
|
70
|
+
def find(id)
|
|
71
|
+
synchronize { renders[id] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def broadcast_replace(render)
|
|
75
|
+
return unless defined?(Turbo::StreamsChannel)
|
|
76
|
+
|
|
77
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
78
|
+
CHANNEL,
|
|
79
|
+
target: row_id(render.id),
|
|
80
|
+
html: render_row(render)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def broadcast_append(render)
|
|
85
|
+
return unless defined?(Turbo::StreamsChannel)
|
|
86
|
+
|
|
87
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
88
|
+
CHANNEL,
|
|
89
|
+
target: TARGET,
|
|
90
|
+
html: render_row(render)
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def html
|
|
95
|
+
<<~HTML
|
|
96
|
+
<div id="#{TARGET}" class="animate-it-renders-list">
|
|
97
|
+
#{render_rows}
|
|
98
|
+
</div>
|
|
99
|
+
HTML
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def update!(id, **attributes)
|
|
105
|
+
updated = synchronize do
|
|
106
|
+
render = renders.fetch(id)
|
|
107
|
+
renders[id] = Render.new(
|
|
108
|
+
id,
|
|
109
|
+
attributes.fetch(:composition_id, render.composition_id),
|
|
110
|
+
attributes.fetch(:status, render.status),
|
|
111
|
+
attributes.fetch(:current_frame, render.current_frame),
|
|
112
|
+
attributes.fetch(:total_frames, render.total_frames),
|
|
113
|
+
attributes.fetch(:output_path, render.output_path),
|
|
114
|
+
attributes.fetch(:error, render.error)
|
|
115
|
+
)
|
|
116
|
+
renders[id]
|
|
117
|
+
end
|
|
118
|
+
broadcast_replace(updated)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def render_row(render)
|
|
122
|
+
output = render.output_path.to_s
|
|
123
|
+
link = output_link(render, output)
|
|
124
|
+
kill = %i[queued rendering].include?(render.status) ? kill_button(render) : ""
|
|
125
|
+
error = render.error.present? ? %(<p style="color: #b91c1c;">#{ERB::Util.html_escape(render.error)}</p>) : ""
|
|
126
|
+
|
|
127
|
+
<<~HTML
|
|
128
|
+
<div id="#{row_id(render.id)}" class="animate-it-render-row">
|
|
129
|
+
<strong>#{ERB::Util.html_escape(render.composition_id)}</strong>
|
|
130
|
+
<span>#{ERB::Util.html_escape(render.status.to_s)}</span>
|
|
131
|
+
<span>frame #{render.current_frame} / #{render.total_frames}</span>
|
|
132
|
+
<progress max="100" value="#{render.progress_percent}"></progress>
|
|
133
|
+
<code class="animate-it-render-path" title="#{ERB::Util.html_escape(output)}">#{ERB::Util.html_escape(output)}</code>
|
|
134
|
+
#{link}
|
|
135
|
+
#{kill}
|
|
136
|
+
#{error}
|
|
137
|
+
</div>
|
|
138
|
+
HTML
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def render_rows
|
|
142
|
+
rows = all.map { |render| render_row(render) }.join
|
|
143
|
+
return rows if rows.present?
|
|
144
|
+
|
|
145
|
+
<<~HTML
|
|
146
|
+
<p id="animate_it_renders_empty" style="color: #6b7280;">No renders yet.</p>
|
|
147
|
+
HTML
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def row_id(render_id)
|
|
151
|
+
"animate_it_render_#{render_id}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def output_link(render, output)
|
|
155
|
+
return "" unless %i[complete cancelled].include?(render.status)
|
|
156
|
+
return "" unless File.exist?(output)
|
|
157
|
+
|
|
158
|
+
%(<a href="#{AnimateIt.config.mount_path}/renders/#{render.id}?download=1" target="_blank" rel="noopener">Open MP4</a>)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def kill_button(render)
|
|
162
|
+
<<~HTML
|
|
163
|
+
<form action="#{AnimateIt.config.mount_path}/renders/#{render.id}/cancel" method="post" data-turbo="true">
|
|
164
|
+
<input type="hidden" name="authenticity_token" value="#{ERB::Util.html_escape(authenticity_token)}">
|
|
165
|
+
<button type="submit" class="button is-danger is-small">Kill render</button>
|
|
166
|
+
</form>
|
|
167
|
+
HTML
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def authenticity_token
|
|
171
|
+
ApplicationController.renderer.instance_eval { form_authenticity_token }
|
|
172
|
+
rescue StandardError
|
|
173
|
+
""
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def renders
|
|
177
|
+
@renders ||= {}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def synchronize(&)
|
|
181
|
+
mutex.synchronize(&)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def mutex
|
|
185
|
+
@mutex ||= Mutex.new
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class Scene
|
|
3
|
+
include AnimationHelpers
|
|
4
|
+
include ViewHelpers
|
|
5
|
+
|
|
6
|
+
attr_reader :context, :props, :view_context
|
|
7
|
+
|
|
8
|
+
def initialize(context:, props:)
|
|
9
|
+
@context = context
|
|
10
|
+
@props = props
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
delegate :frame, :local_frame, :fps, :progress, :interpolate, :spring, to: :context
|
|
14
|
+
|
|
15
|
+
# ----- class-level DSL -----------------------------------------------
|
|
16
|
+
class << self
|
|
17
|
+
# Sidecar template name. Defaults to "canvas" — i.e. a scene whose
|
|
18
|
+
# composition is `MyHero` looks for `app/videos/my_hero/canvas.html.haml`.
|
|
19
|
+
# Override to point to a sibling template instead.
|
|
20
|
+
def template(name = nil)
|
|
21
|
+
@template = name.to_s if name
|
|
22
|
+
@template ||= "canvas"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Outer wrapper class for the scene's content. Defaults to
|
|
26
|
+
# "hero-render-canvas" (the convention used by all current heros).
|
|
27
|
+
def canvas_class(value = nil)
|
|
28
|
+
@canvas_class = value.to_s if value
|
|
29
|
+
@canvas_class ||= "hero-render-canvas"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Declare an animatable element by name. Animation properties are
|
|
33
|
+
# attached via the block:
|
|
34
|
+
#
|
|
35
|
+
# animate :step_1 do
|
|
36
|
+
# fade during: 10..30
|
|
37
|
+
# slide during: 10..30, from: 16
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# The framework auto-generates the `[data-anim="step_1"]` CSS rules
|
|
41
|
+
# and the per-frame CSS variables — no `:css` block in the HAML.
|
|
42
|
+
def animate(name, &block)
|
|
43
|
+
animations.add(name, block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def animations
|
|
47
|
+
@animations ||= AnimationSet.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Composition class this scene belongs to. Set by the engine when the
|
|
51
|
+
# scene is mounted via `composition.scene MyScene` / single-scene
|
|
52
|
+
# auto-mount; lets animation property procs reach `Composition.beats`.
|
|
53
|
+
attr_accessor :composition_class
|
|
54
|
+
|
|
55
|
+
# Set automatically at the start of every `render` to the scene's
|
|
56
|
+
# current local_frame. Fixture closures (e.g. a singleton-method
|
|
57
|
+
# stub that needs to vary by frame) can call `MyScene.current_frame`
|
|
58
|
+
# without the scene having to remember to assign it in `body`.
|
|
59
|
+
attr_accessor :current_frame
|
|
60
|
+
|
|
61
|
+
# Declare a class-level fixture builder. With a block, registers the
|
|
62
|
+
# builder *lazily* (no work done at class load). Without a block,
|
|
63
|
+
# invokes the registered builder once behind a mutex — `Faker::Config.random`
|
|
64
|
+
# is reset to `Random.new(seed)` first (when `seed:` was given) and
|
|
65
|
+
# `FactoryBot.find_definitions` is invoked if no factories are loaded.
|
|
66
|
+
# Subsequent calls return the memoized hash.
|
|
67
|
+
#
|
|
68
|
+
# class HeroScene < AnimateIt::Scene
|
|
69
|
+
# fixtures(seed: 42) do
|
|
70
|
+
# company = build_stubbed_company
|
|
71
|
+
# { company:, job: build_stubbed_job(company) }
|
|
72
|
+
# end
|
|
73
|
+
# end
|
|
74
|
+
#
|
|
75
|
+
# HeroScene.fixtures # → builds once, returns hash
|
|
76
|
+
def fixtures(seed: nil, &block)
|
|
77
|
+
if block
|
|
78
|
+
@fixtures_block = block
|
|
79
|
+
@fixtures_seed = seed
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return @fixtures_value if defined?(@fixtures_value) && @fixtures_value
|
|
84
|
+
|
|
85
|
+
@fixtures_mutex ||= Mutex.new
|
|
86
|
+
@fixtures_mutex.synchronize do
|
|
87
|
+
next @fixtures_value if @fixtures_value
|
|
88
|
+
|
|
89
|
+
FactoryBot.find_definitions if defined?(FactoryBot) && FactoryBot.factories.none?
|
|
90
|
+
Faker::Config.random = Random.new(@fixtures_seed) if defined?(Faker) && @fixtures_seed
|
|
91
|
+
@fixtures_value = @fixtures_block&.call
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reset_fixtures!
|
|
96
|
+
return unless @fixtures_mutex
|
|
97
|
+
|
|
98
|
+
@fixtures_mutex.synchronize { @fixtures_value = nil }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Toggles fragment caching off for the rendering controller before
|
|
102
|
+
# `body` runs. Defeats the dev-environment `cache do ... end` blocks
|
|
103
|
+
# in production partials that key on stable record `updated_at`s and
|
|
104
|
+
# would otherwise reuse frame 1's HTML for every subsequent frame.
|
|
105
|
+
def disable_fragment_caching!
|
|
106
|
+
@disable_fragment_caching = true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fragment_caching_disabled?
|
|
110
|
+
@disable_fragment_caching == true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Class-side mirror of ViewHelpers#stub_methods so class-method
|
|
114
|
+
# fixture builders (which run before any Scene instance exists)
|
|
115
|
+
# can also use the helper to define singleton-method stubs on
|
|
116
|
+
# build_stubbed records.
|
|
117
|
+
def stub_methods(object, **stubs)
|
|
118
|
+
stubs.each do |name, value|
|
|
119
|
+
body = value.respond_to?(:call) ? value : ->(*) { value }
|
|
120
|
+
object.define_singleton_method(name) { |*args| body.call(*args) }
|
|
121
|
+
end
|
|
122
|
+
object
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ----- runtime --------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
# Stash view_context so helpers (`tag`, `safe_join`, `capture`) work,
|
|
129
|
+
# then call `body` for the actual markup. Subclasses should override
|
|
130
|
+
# `body` (no args) — view_context is already available via the
|
|
131
|
+
# accessor.
|
|
132
|
+
#
|
|
133
|
+
# The framework's filmstrip + frame controllers call `render(view_context)`;
|
|
134
|
+
# don't override that signature unless you really need to.
|
|
135
|
+
def render(view_context)
|
|
136
|
+
@view_context = view_context
|
|
137
|
+
self.class.current_frame = local_frame
|
|
138
|
+
if self.class.fragment_caching_disabled? &&
|
|
139
|
+
view_context.respond_to?(:controller) &&
|
|
140
|
+
view_context.controller.respond_to?(:perform_caching=)
|
|
141
|
+
view_context.controller.perform_caching = false
|
|
142
|
+
end
|
|
143
|
+
body
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Set ivars on the view_context so templates rendered via
|
|
147
|
+
# `render_scene_template` / `view_context.render(template:)` can read
|
|
148
|
+
# them as `@foo`. ActionView's `assigns:` keyword doesn't propagate
|
|
149
|
+
# through template-level renders, so we push ivars directly onto the
|
|
150
|
+
# same view context the template will be evaluated against.
|
|
151
|
+
#
|
|
152
|
+
# expose(:job, :user, from: fixtures, top_match: matches.first)
|
|
153
|
+
#
|
|
154
|
+
# The positional `keys` are pulled from the `from:` hash by name; the
|
|
155
|
+
# keyword args are set verbatim.
|
|
156
|
+
def expose(*keys, from: nil, **ivars)
|
|
157
|
+
keys.each { |k| view_context.instance_variable_set("@#{k}", from[k]) } if from
|
|
158
|
+
ivars.each { |k, v| view_context.instance_variable_set("@#{k}", v) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Default markup: canvas wrapper + sidecar template + auto-generated
|
|
162
|
+
# animation CSS. Override in a subclass when the composition is
|
|
163
|
+
# programmatic (no sidecar template) or has multiple acts.
|
|
164
|
+
def body
|
|
165
|
+
tag.div(class: self.class.canvas_class, style: canvas_style) do
|
|
166
|
+
absolute_fill(style: animation_var_style) do
|
|
167
|
+
parts = []
|
|
168
|
+
parts << generated_animation_styles if self.class.animations.elements.any?
|
|
169
|
+
parts << render_scene_template(self.class.template)
|
|
170
|
+
view_context.safe_join(parts)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Convenience: when a subclass overrides `render` it can call this to
|
|
176
|
+
# get the same wrapper + animation-var div around custom content.
|
|
177
|
+
def with_canvas(&block)
|
|
178
|
+
tag.div(class: self.class.canvas_class, style: canvas_style) do
|
|
179
|
+
absolute_fill(style: animation_var_style) do
|
|
180
|
+
parts = []
|
|
181
|
+
parts << generated_animation_styles if self.class.animations.elements.any?
|
|
182
|
+
parts << view_context.capture(&block) if block
|
|
183
|
+
view_context.safe_join(parts)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# CSS var bag computed from this frame's animations. Available inside
|
|
189
|
+
# custom render methods that want extra inline styles.
|
|
190
|
+
def animation_vars(**extras)
|
|
191
|
+
Style.vars(**self.class.animations.vars_for(self), **extras)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
delegate :composition_class, to: :class
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def tag
|
|
199
|
+
view_context.tag
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def safe_join(...)
|
|
203
|
+
view_context.safe_join(...)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def capture(...)
|
|
207
|
+
view_context.capture(...)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def generated_animation_styles
|
|
211
|
+
view_context.tag.style(self.class.animations.css_rules.html_safe)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def canvas_style
|
|
215
|
+
Style.build(
|
|
216
|
+
"position: relative",
|
|
217
|
+
"width: 100%",
|
|
218
|
+
"height: 100%",
|
|
219
|
+
"display: flex",
|
|
220
|
+
"align-items: center",
|
|
221
|
+
"justify-content: center",
|
|
222
|
+
"padding: 40px 80px",
|
|
223
|
+
"box-sizing: border-box",
|
|
224
|
+
"font-family: League Spartan, Arial, Helvetica, sans-serif",
|
|
225
|
+
"background: transparent"
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def animation_var_style
|
|
230
|
+
Style.build(
|
|
231
|
+
"align-items: center",
|
|
232
|
+
"justify-content: center",
|
|
233
|
+
animation_vars
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
module Style
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def build(*rules, **properties)
|
|
6
|
+
property_rules = properties.filter_map do |property, value|
|
|
7
|
+
next if value.nil?
|
|
8
|
+
|
|
9
|
+
"#{property.to_s.tr("_", "-")}: #{value}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
(rules.flatten.compact + property_rules).join("; ")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def vars(**properties)
|
|
16
|
+
properties.filter_map do |property, value|
|
|
17
|
+
next if value.nil?
|
|
18
|
+
|
|
19
|
+
"--#{property.to_s.tr("_", "-")}: #{value}"
|
|
20
|
+
end.join("; ")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class Timeline
|
|
3
|
+
Segment = Data.define(
|
|
4
|
+
:name,
|
|
5
|
+
:from_frame,
|
|
6
|
+
:duration_frames,
|
|
7
|
+
:scene_class,
|
|
8
|
+
:renderer,
|
|
9
|
+
:layout,
|
|
10
|
+
:class_name,
|
|
11
|
+
:style,
|
|
12
|
+
:show_in_timeline,
|
|
13
|
+
:kind,
|
|
14
|
+
:transition,
|
|
15
|
+
:track,
|
|
16
|
+
:source
|
|
17
|
+
) do
|
|
18
|
+
def active_at?(frame)
|
|
19
|
+
return frame >= from_frame if duration_frames.nil?
|
|
20
|
+
|
|
21
|
+
frame >= from_frame && frame < from_frame + duration_frames
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def local_frame(frame)
|
|
25
|
+
frame - from_frame
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :segments
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@segments = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_segment(
|
|
36
|
+
name:,
|
|
37
|
+
from_frame:,
|
|
38
|
+
duration_frames:,
|
|
39
|
+
scene_class: nil,
|
|
40
|
+
renderer: nil,
|
|
41
|
+
layout: :none,
|
|
42
|
+
class_name: nil,
|
|
43
|
+
style: nil,
|
|
44
|
+
show_in_timeline: true,
|
|
45
|
+
kind: :scene,
|
|
46
|
+
transition: nil,
|
|
47
|
+
track: :main,
|
|
48
|
+
source: nil
|
|
49
|
+
)
|
|
50
|
+
segments << Segment.new(
|
|
51
|
+
name,
|
|
52
|
+
from_frame,
|
|
53
|
+
duration_frames,
|
|
54
|
+
scene_class,
|
|
55
|
+
renderer,
|
|
56
|
+
layout,
|
|
57
|
+
class_name,
|
|
58
|
+
style,
|
|
59
|
+
show_in_timeline,
|
|
60
|
+
kind,
|
|
61
|
+
transition,
|
|
62
|
+
track,
|
|
63
|
+
source
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def active_segments(frame, kind: nil)
|
|
68
|
+
result = segments.select { |segment| segment.active_at?(frame) }
|
|
69
|
+
kind ? result.select { |segment| segment.kind == kind } : result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Tracks in declaration order — first time a segment names a track wins.
|
|
73
|
+
def tracks
|
|
74
|
+
segments.map(&:track).uniq
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def segments_for_track(track)
|
|
78
|
+
segments.select { |segment| segment.track == track }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
module Timing
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def interpolate(input, input_range, output_range, easing: :linear, extrapolate_left: :extend,
|
|
6
|
+
extrapolate_right: :extend)
|
|
7
|
+
unless input_range.size == output_range.size
|
|
8
|
+
raise ArgumentError,
|
|
9
|
+
"input_range and output_range must have the same size"
|
|
10
|
+
end
|
|
11
|
+
raise ArgumentError, "interpolation needs at least two points" if input_range.size < 2
|
|
12
|
+
|
|
13
|
+
validate_extrapolation!(extrapolate_left)
|
|
14
|
+
validate_extrapolation!(extrapolate_right)
|
|
15
|
+
|
|
16
|
+
left_index = segment_index(input, input_range)
|
|
17
|
+
right_index = left_index + 1
|
|
18
|
+
input_start = input_range[left_index]
|
|
19
|
+
input_end = input_range[right_index]
|
|
20
|
+
|
|
21
|
+
if input < input_range.first
|
|
22
|
+
return output_range.first if extrapolate_left == :clamp
|
|
23
|
+
return input if extrapolate_left == :identity
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if input > input_range.last
|
|
27
|
+
return output_range.last if extrapolate_right == :clamp
|
|
28
|
+
return input if extrapolate_right == :identity
|
|
29
|
+
end
|
|
30
|
+
return output_range[left_index] if input_end == input_start
|
|
31
|
+
|
|
32
|
+
progress = (input - input_start).to_f / (input_end - input_start)
|
|
33
|
+
eased = Easing.resolve(easing).call(progress)
|
|
34
|
+
|
|
35
|
+
output_start = output_range[left_index]
|
|
36
|
+
output_end = output_range[right_index]
|
|
37
|
+
|
|
38
|
+
output_start + ((output_end - output_start) * eased)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def spring(frame:, fps:, from: 0.0, to: 1.0, stiffness: 100.0, damping: 10.0, mass: 1.0)
|
|
42
|
+
seconds = [frame.to_f / fps, 0].max
|
|
43
|
+
angular_frequency = Math.sqrt(stiffness / mass)
|
|
44
|
+
decay = Math.exp((-damping / (2 * mass)) * seconds)
|
|
45
|
+
progress = 1 - (decay * Math.cos(angular_frequency * seconds))
|
|
46
|
+
|
|
47
|
+
from + ((to - from) * progress)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def segment_index(input, input_range)
|
|
51
|
+
return 0 if input <= input_range.first
|
|
52
|
+
|
|
53
|
+
index = input_range.each_cons(2).find_index { |left, right| input.between?(left, right) }
|
|
54
|
+
index || (input_range.size - 2)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_extrapolation!(mode)
|
|
58
|
+
return if %i[clamp extend identity].include?(mode)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "Unsupported extrapolation mode: #{mode.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
module Units
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
# Time-string regex: accepts "1s", "1.5s", "300ms", "2m", "1m30s".
|
|
6
|
+
TIME_STRING = /\A(?:(\d+)m)?\s*(\d+(?:\.\d+)?)?\s*(s|ms)?\z/
|
|
7
|
+
|
|
8
|
+
def frames(value, fps:)
|
|
9
|
+
case value
|
|
10
|
+
when ActiveSupport::Duration
|
|
11
|
+
(value.to_f * fps).round
|
|
12
|
+
when FrameDuration, Numeric
|
|
13
|
+
value.to_i
|
|
14
|
+
when String
|
|
15
|
+
seconds = parse_time_string(value)
|
|
16
|
+
raise ArgumentError, "Cannot convert #{value.inspect} to frames" if seconds.nil?
|
|
17
|
+
|
|
18
|
+
(seconds * fps).round
|
|
19
|
+
when nil
|
|
20
|
+
nil
|
|
21
|
+
else
|
|
22
|
+
raise ArgumentError, "Cannot convert #{value.inspect} to frames"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Parses "1s", "1.5s", "300ms", "2m", "1m30s" into seconds (Float).
|
|
27
|
+
def parse_time_string(str)
|
|
28
|
+
m = str.strip.match(TIME_STRING)
|
|
29
|
+
return nil if m.nil? || (m[1].nil? && m[2].nil?)
|
|
30
|
+
|
|
31
|
+
minutes = m[1].to_i
|
|
32
|
+
number = m[2].to_f
|
|
33
|
+
unit = m[3] || "s"
|
|
34
|
+
base = unit == "ms" ? number / 1000.0 : number
|
|
35
|
+
(minutes * 60) + base
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|