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
data/config/routes.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
AnimateIt::Engine.routes.draw do
|
|
2
|
+
root "studio#index"
|
|
3
|
+
|
|
4
|
+
get "compositions/:id", to: "studio#show", as: :composition
|
|
5
|
+
get "compositions/:id/frame/:frame", to: "frames#show", as: :composition_frame, constraints: { frame: /-?\d+/ }
|
|
6
|
+
get "compositions/:id/filmstrip", to: "frames#filmstrip", as: :composition_filmstrip
|
|
7
|
+
get "compositions/:id/audio/:index", to: "audio#show", as: :composition_audio, constraints: { index: /\d+/ }
|
|
8
|
+
patch "compositions/:id/props", to: "props#update", as: :composition_props
|
|
9
|
+
post "compositions/:id/renders", to: "renders#create", as: :composition_renders
|
|
10
|
+
post "renders/:id/cancel", to: "renders#cancel", as: :cancel_render
|
|
11
|
+
get "renders/:id", to: "renders#show", as: :render
|
|
12
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Render an AnimateIt composition: one Playwright browser pointed at the
|
|
5
|
+
# filmstrip endpoint scrubs frames in-page, screenshots each, and FFmpeg
|
|
6
|
+
# stitches them into a video.
|
|
7
|
+
#
|
|
8
|
+
# Run from your Rails app root (the app whose app/videos/ defines the
|
|
9
|
+
# composition), with a Rails server already running at ANIMATE_IT_HOST:
|
|
10
|
+
#
|
|
11
|
+
# bundle exec render_animate_it_video my-composition
|
|
12
|
+
# bundle exec render_animate_it_video my-composition tmp/my-composition.mp4
|
|
13
|
+
#
|
|
14
|
+
# Host resolution: ANIMATE_IT_HOST, then the legacy RAILS_MOTION_HOST, then
|
|
15
|
+
# RAILS_HOST, then http://127.0.0.1:3000.
|
|
16
|
+
|
|
17
|
+
env_path = File.expand_path("config/environment.rb", Dir.pwd)
|
|
18
|
+
unless File.exist?(env_path)
|
|
19
|
+
abort "render_animate_it_video: run this from your Rails app root (no config/environment.rb in #{Dir.pwd})."
|
|
20
|
+
end
|
|
21
|
+
require env_path
|
|
22
|
+
|
|
23
|
+
composition_id = ARGV[0]
|
|
24
|
+
abort "Usage: render_animate_it_video <composition-id> [output-path]" if composition_id.to_s.empty?
|
|
25
|
+
|
|
26
|
+
host = ENV["ANIMATE_IT_HOST"].to_s.dup
|
|
27
|
+
host = ENV["RAILS_MOTION_HOST"].to_s if host.empty?
|
|
28
|
+
host = ENV["RAILS_HOST"].to_s if host.empty?
|
|
29
|
+
host = "http://127.0.0.1:3000" if host.empty?
|
|
30
|
+
|
|
31
|
+
AnimateIt.load_compositions!
|
|
32
|
+
composition = AnimateIt.registry.fetch(composition_id)
|
|
33
|
+
|
|
34
|
+
extension = AnimateIt::VideoRenderer::EXTENSION_FOR_FORMAT.fetch(composition.output_format) || "mp4"
|
|
35
|
+
output_path = ARGV[1] || Rails.root.join("tmp/animate_it/#{composition_id}.#{extension}").to_s
|
|
36
|
+
|
|
37
|
+
puts "Rendering #{composition.id} (#{composition.width}x#{composition.height}, #{composition.fps}fps, #{composition.duration_in_frames} frames)"
|
|
38
|
+
|
|
39
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
40
|
+
output = AnimateIt::VideoRenderer.new(
|
|
41
|
+
composition:,
|
|
42
|
+
host:,
|
|
43
|
+
output_path:
|
|
44
|
+
).render(
|
|
45
|
+
on_progress: lambda do |frame, total|
|
|
46
|
+
print "\r frame #{frame} / #{total}"
|
|
47
|
+
$stdout.flush
|
|
48
|
+
end
|
|
49
|
+
)
|
|
50
|
+
puts ""
|
|
51
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
52
|
+
|
|
53
|
+
puts format("Wrote %<output>s in %<elapsed>.2fs", output: output, elapsed: elapsed)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# ----- Per-animatable property declaration ----------------------------
|
|
3
|
+
# One CSS-driven animation slot on a `data-anim="..."` element. The
|
|
4
|
+
# framework turns these into both:
|
|
5
|
+
# 1. CSS variables on the wrapper (set per frame at render time), and
|
|
6
|
+
# 2. CSS rules of the form `[data-anim="name"] { property: var(--…); }`
|
|
7
|
+
# injected into the rendered HTML so the HAML doesn't need a `:css`
|
|
8
|
+
# block to bind variables to selectors.
|
|
9
|
+
AnimationProperty = Data.define(:css_property, :var_suffix, :unit, :default, :keyframes_to_values) do
|
|
10
|
+
def var_name(animatable_name)
|
|
11
|
+
"--#{animatable_name.to_s.tr("_", "-")}-#{var_suffix.to_s.tr("_", "-")}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
AnimatableElement = Data.define(:name, :properties)
|
|
16
|
+
|
|
17
|
+
class AnimationSet
|
|
18
|
+
attr_reader :elements
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@elements = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add(name, builder_block)
|
|
25
|
+
builder = AnimatableBuilder.new(name)
|
|
26
|
+
builder.instance_eval(&builder_block) if builder_block
|
|
27
|
+
@elements[name.to_sym] = AnimatableElement.new(name: name.to_sym, properties: builder.properties)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Compute the per-frame CSS variable hash for the entire set, given the
|
|
31
|
+
# current local_frame plus a beats registry to resolve symbolic ranges.
|
|
32
|
+
def vars_for(scene)
|
|
33
|
+
vars = {}
|
|
34
|
+
@elements.each_value do |element|
|
|
35
|
+
element.properties.each do |prop|
|
|
36
|
+
value = compute_value(prop, scene)
|
|
37
|
+
vars[var_key(element.name, prop.var_suffix)] = format_value(value, prop.unit)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
vars
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Emit `<style>` block content with `[data-anim="name"] { … }` rules.
|
|
44
|
+
# Generated once per AnimationSet (memoized).
|
|
45
|
+
def css_rules
|
|
46
|
+
@css_rules ||= @elements.values.map { |element| rules_for(element) }.join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def var_key(name, suffix)
|
|
52
|
+
:"#{name}_#{suffix}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rules_for(element)
|
|
56
|
+
transforms = []
|
|
57
|
+
direct_props = []
|
|
58
|
+
element.properties.each do |prop|
|
|
59
|
+
var_ref = "var(#{prop.var_name(element.name)}, #{format_default(prop.default, prop.unit)})"
|
|
60
|
+
case prop.css_property
|
|
61
|
+
when :translate_y then transforms << "translateY(#{var_ref})"
|
|
62
|
+
when :translate_x then transforms << "translateX(#{var_ref})"
|
|
63
|
+
when :scale then transforms << "scale(#{var_ref})"
|
|
64
|
+
when :rotate then transforms << "rotate(#{var_ref})"
|
|
65
|
+
else
|
|
66
|
+
direct_props << "#{prop.css_property.to_s.tr("_", "-")}: #{var_ref};"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
direct_props << "transform: #{transforms.join(" ")};" if transforms.any?
|
|
70
|
+
# HTML data attribute convention is hyphens, not underscores.
|
|
71
|
+
selector_name = element.name.to_s.tr("_", "-")
|
|
72
|
+
"[data-anim=\"#{selector_name}\"] { #{direct_props.join(" ")} transition: none; will-change: opacity, transform; }"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compute_value(prop, scene)
|
|
76
|
+
keyframes = prop.keyframes_to_values.call(scene)
|
|
77
|
+
frames = keyframes.keys
|
|
78
|
+
values = keyframes.values
|
|
79
|
+
scene.interpolate(scene.local_frame, frames, values, easing: :ease_out, extrapolate_left: :clamp,
|
|
80
|
+
extrapolate_right: :clamp).round(4)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_value(value, unit)
|
|
84
|
+
unit ? "#{value}#{unit}" : value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_default(default, unit)
|
|
88
|
+
unit ? "#{default}#{unit}" : default
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ----- Per-element DSL ------------------------------------------------
|
|
93
|
+
class AnimatableBuilder
|
|
94
|
+
attr_reader :properties
|
|
95
|
+
|
|
96
|
+
def initialize(name)
|
|
97
|
+
@name = name
|
|
98
|
+
@properties = []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Opacity 0→1 over a frame range (or beat name, or keyframe hash).
|
|
102
|
+
def fade(during: nil, in: nil, out: nil, keyframes: nil, from: 0, to: 1)
|
|
103
|
+
add_property(:opacity, :opacity, nil, from,
|
|
104
|
+
build_keyframes(during, binding.local_variable_get(:in), out, keyframes, from: from, to: to))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Vertical translate `from`→`to` over a frame range.
|
|
108
|
+
def slide(during: nil, keyframes: nil, from: 16, to: 0, axis: :y, unit: "px")
|
|
109
|
+
css = axis == :y ? :translate_y : :translate_x
|
|
110
|
+
add_property(css, css, unit, from, build_keyframes(during, nil, nil, keyframes, from: from, to: to))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def scale(during: nil, keyframes: nil, from: 1, to: 1.05)
|
|
114
|
+
add_property(:scale, :scale, nil, from, build_keyframes(during, nil, nil, keyframes, from: from, to: to))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Generic CSS property — when the named helpers (fade/slide/scale) don't
|
|
118
|
+
# cover what you need (max-height, height, color, etc.).
|
|
119
|
+
def css(property, from:, during: nil, keyframes: nil, to: nil, unit: nil)
|
|
120
|
+
kf = build_keyframes(during, nil, nil, keyframes, from: from, to: to.nil? ? from : to)
|
|
121
|
+
add_property(property, property, unit, from, kf)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def add_property(css_property, var_suffix, unit, default, keyframes_proc)
|
|
127
|
+
@properties << AnimationProperty.new(
|
|
128
|
+
css_property: css_property,
|
|
129
|
+
var_suffix: var_suffix,
|
|
130
|
+
unit: unit,
|
|
131
|
+
default: default,
|
|
132
|
+
keyframes_to_values: keyframes_proc
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns a Proc that, given a Scene with access to the composition's
|
|
137
|
+
# beats registry, resolves any symbolic ranges (beat names) into a
|
|
138
|
+
# frame→value Hash suitable for interpolation.
|
|
139
|
+
def build_keyframes(during, in_range, out_range, keyframes_arg, from:, to:)
|
|
140
|
+
lambda do |scene|
|
|
141
|
+
if keyframes_arg
|
|
142
|
+
resolve_keyframe_hash(keyframes_arg, scene)
|
|
143
|
+
elsif during
|
|
144
|
+
range = resolve_range(during, scene)
|
|
145
|
+
{ range.first => from, range.last => to }
|
|
146
|
+
elsif in_range && out_range
|
|
147
|
+
a = resolve_range(in_range, scene)
|
|
148
|
+
b = resolve_range(out_range, scene)
|
|
149
|
+
{ a.first => from, a.last => to, b.first => to, b.last => from }
|
|
150
|
+
elsif in_range
|
|
151
|
+
a = resolve_range(in_range, scene)
|
|
152
|
+
{ a.first => from, a.last => to }
|
|
153
|
+
elsif out_range
|
|
154
|
+
b = resolve_range(out_range, scene)
|
|
155
|
+
{ b.first => to, b.last => from }
|
|
156
|
+
else
|
|
157
|
+
{ 0 => from }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def resolve_range(input, scene)
|
|
163
|
+
case input
|
|
164
|
+
when Range
|
|
165
|
+
Range.new(resolve_frame(input.first, scene), resolve_frame(input.last, scene))
|
|
166
|
+
when Symbol
|
|
167
|
+
scene.composition_class.beats.fetch(input).range
|
|
168
|
+
else
|
|
169
|
+
raise Error, "Don't know how to resolve range #{input.inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def resolve_keyframe_hash(hash, scene)
|
|
174
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
175
|
+
out[resolve_frame(k, scene)] = v
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def resolve_frame(value, scene)
|
|
180
|
+
case value
|
|
181
|
+
when Integer then value
|
|
182
|
+
when Float then value.to_i
|
|
183
|
+
when Symbol then scene.composition_class.beats.fetch(value).start_frame
|
|
184
|
+
else
|
|
185
|
+
AnimateIt::Units.frames(value, fps: scene.fps).to_i
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
module AnimationHelpers
|
|
3
|
+
def animation_vars(**properties)
|
|
4
|
+
Style.vars(**properties)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def fade(start_frame, end_frame, from: 0, to: 1, easing: :ease_out)
|
|
8
|
+
interpolate_range([start_frame, end_frame], [from, to], easing:)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def slide(start_frame, end_frame, from: 16, to: 0, easing: :ease_out)
|
|
12
|
+
interpolate_range([start_frame, end_frame], [from, to], easing:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def press(start_frame, middle_frame, end_frame, from: 1.0, to: 1.08, easing: :ease_out)
|
|
16
|
+
interpolate_range([start_frame, middle_frame, end_frame], [from, to, from], easing:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pulse(start_frame, middle_frame, end_frame, from:, to:, back_to: from, easing: :ease_out)
|
|
20
|
+
interpolate_range([start_frame, middle_frame, end_frame], [from, to, back_to], easing:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def interpolate_range(input_range, output_range, easing: :ease_out)
|
|
24
|
+
interpolate(
|
|
25
|
+
local_frame,
|
|
26
|
+
input_range,
|
|
27
|
+
output_range,
|
|
28
|
+
easing:,
|
|
29
|
+
extrapolate_left: :clamp,
|
|
30
|
+
extrapolate_right: :clamp
|
|
31
|
+
).round(4)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def loop_frame(duration, frame: local_frame)
|
|
35
|
+
frame % AnimateIt::Units.frames(duration, fps:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def loop_iteration(duration, frame: local_frame)
|
|
39
|
+
frame / AnimateIt::Units.frames(duration, fps:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def freeze_frame(frozen_frame, active: true)
|
|
43
|
+
active.respond_to?(:call) && !active.call(local_frame) ? local_frame : frozen_frame
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def act_local(beat_name)
|
|
47
|
+
local_frame - composition_class.beats.fetch(beat_name).start_frame
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def at_act(beat_name, ranges, values, easing: :ease_out)
|
|
51
|
+
interpolate(
|
|
52
|
+
act_local(beat_name),
|
|
53
|
+
ranges,
|
|
54
|
+
values,
|
|
55
|
+
easing:,
|
|
56
|
+
extrapolate_left: :clamp,
|
|
57
|
+
extrapolate_right: :clamp
|
|
58
|
+
).round(4)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def at_global(ranges, values, easing: :ease_out)
|
|
62
|
+
interpolate(
|
|
63
|
+
local_frame,
|
|
64
|
+
ranges,
|
|
65
|
+
values,
|
|
66
|
+
easing:,
|
|
67
|
+
extrapolate_left: :clamp,
|
|
68
|
+
extrapolate_right: :clamp
|
|
69
|
+
).round(4)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def beat_frame(beat_name)
|
|
73
|
+
composition_class.beats.fetch(beat_name).start_frame
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def beat_end(beat_name)
|
|
77
|
+
beat = composition_class.beats.fetch(beat_name)
|
|
78
|
+
beat.start_frame + beat.duration_frames
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def beat_range(beat_name)
|
|
82
|
+
beat = composition_class.beats.fetch(beat_name)
|
|
83
|
+
[beat.start_frame, beat.start_frame + beat.duration_frames]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# Drives a composition through every Output it declared, writing each one
|
|
3
|
+
# to its absolute repo-relative path. One VideoRenderer per output (cheap —
|
|
4
|
+
# frames are captured once per output even when the same composition has
|
|
5
|
+
# several formats, because each format wants a different ffmpeg encode).
|
|
6
|
+
module AssetRenderer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def render_composition(composition, host:, on_progress: nil, frame_range: nil)
|
|
10
|
+
raise Error, "Composition #{composition.id} has no `outputs do ... end`" if composition.outputs.empty?
|
|
11
|
+
|
|
12
|
+
composition.outputs.map do |output|
|
|
13
|
+
render_output(composition, output, host: host, on_progress: on_progress, frame_range: frame_range)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render_output(composition, output, host:, on_progress: nil, frame_range: nil)
|
|
18
|
+
effective_range = effective_range_for(output, frame_range)
|
|
19
|
+
return nil if effective_range == :skip
|
|
20
|
+
|
|
21
|
+
destination = output.absolute_path
|
|
22
|
+
FileUtils.mkdir_p(destination.dirname)
|
|
23
|
+
|
|
24
|
+
renderer = VideoRenderer.new(
|
|
25
|
+
composition: composition,
|
|
26
|
+
host: host,
|
|
27
|
+
output_path: destination,
|
|
28
|
+
format: output.format
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
renderer.render(frame_range: effective_range, on_progress: on_progress)
|
|
32
|
+
destination
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A still PNG output always renders its declared single frame; if the
|
|
36
|
+
# caller-supplied range excludes that frame, skip the output entirely.
|
|
37
|
+
# Animated outputs use the caller's range when provided, otherwise
|
|
38
|
+
# render the full composition.
|
|
39
|
+
def effective_range_for(output, frame_range)
|
|
40
|
+
if output.still?
|
|
41
|
+
return :skip if frame_range && !frame_range.cover?(output.frame)
|
|
42
|
+
|
|
43
|
+
output.frame..output.frame
|
|
44
|
+
else
|
|
45
|
+
frame_range
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# A named time marker on a Composition's timeline. Beats let you give
|
|
3
|
+
# frame ranges meaningful names ("intro", "voice_note", "thinking") and
|
|
4
|
+
# reference them from animations:
|
|
5
|
+
#
|
|
6
|
+
# beat :voice_note, at: "5.5s", length: "0.67s"
|
|
7
|
+
# animate :step_2 do
|
|
8
|
+
# fade in: :voice_note
|
|
9
|
+
# end
|
|
10
|
+
Beat = Data.define(:name, :start_frame, :duration_frames) do
|
|
11
|
+
def end_frame
|
|
12
|
+
start_frame + duration_frames
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Inclusive: `range.last` is the last frame the beat covers.
|
|
16
|
+
def range
|
|
17
|
+
start_frame..end_frame
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def first_half
|
|
21
|
+
half = duration_frames / 2
|
|
22
|
+
start_frame..(start_frame + half)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def second_half
|
|
26
|
+
half = duration_frames / 2
|
|
27
|
+
(start_frame + half)..end_frame
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Beats
|
|
32
|
+
def initialize(fps:)
|
|
33
|
+
@fps = fps
|
|
34
|
+
@beats = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def add(name, at:, length:)
|
|
38
|
+
@beats[name.to_sym] = Beat.new(
|
|
39
|
+
name: name.to_sym,
|
|
40
|
+
start_frame: AnimateIt::Units.frames(at, fps: @fps).to_i,
|
|
41
|
+
duration_frames: AnimateIt::Units.frames(length, fps: @fps).to_i
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch(name)
|
|
46
|
+
@beats.fetch(name.to_sym) do
|
|
47
|
+
raise Error, "Unknown beat: #{name.inspect}. Declared: #{@beats.keys.inspect}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def [](name)
|
|
52
|
+
@beats[name.to_sym]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def all
|
|
56
|
+
@beats.values
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
delegate :empty?, to: :@beats
|
|
60
|
+
end
|
|
61
|
+
end
|