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
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