animate_it 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +166 -0
  5. data/app/controllers/animate_it/application_controller.rb +73 -0
  6. data/app/controllers/animate_it/audio_controller.rb +31 -0
  7. data/app/controllers/animate_it/frames_controller.rb +22 -0
  8. data/app/controllers/animate_it/props_controller.rb +10 -0
  9. data/app/controllers/animate_it/renders_controller.rb +82 -0
  10. data/app/controllers/animate_it/studio_controller.rb +17 -0
  11. data/app/jobs/animate_it/application_job.rb +6 -0
  12. data/app/jobs/animate_it/render_job.rb +28 -0
  13. data/app/views/animate_it/frames/filmstrip.html.haml +65 -0
  14. data/app/views/animate_it/frames/fragment.html.haml +6 -0
  15. data/app/views/animate_it/frames/show.html.haml +54 -0
  16. data/app/views/animate_it/props/_form.html.haml +3 -0
  17. data/app/views/animate_it/props/update.html.haml +8 -0
  18. data/app/views/animate_it/renders/create.html.haml +9 -0
  19. data/app/views/animate_it/renders/show.html.haml +10 -0
  20. data/app/views/animate_it/studio/_composition_list.html.haml +7 -0
  21. data/app/views/animate_it/studio/_preview_pane.html.haml +35 -0
  22. data/app/views/animate_it/studio/_props_pane.html.haml +14 -0
  23. data/app/views/animate_it/studio/_studio_script.html.haml +273 -0
  24. data/app/views/animate_it/studio/_studio_styles.html.haml +284 -0
  25. data/app/views/animate_it/studio/_timeline_lanes.html.haml +34 -0
  26. data/app/views/animate_it/studio/index.html.haml +8 -0
  27. data/app/views/animate_it/studio/show.html.haml +23 -0
  28. data/app/views/layouts/animate_it/application.html.haml +17 -0
  29. data/config/routes.rb +12 -0
  30. data/exe/render_animate_it_video +53 -0
  31. data/lib/animate_it/animation.rb +189 -0
  32. data/lib/animate_it/animation_helpers.rb +86 -0
  33. data/lib/animate_it/asset_renderer.rb +49 -0
  34. data/lib/animate_it/beats.rb +61 -0
  35. data/lib/animate_it/composition.rb +354 -0
  36. data/lib/animate_it/configuration.rb +27 -0
  37. data/lib/animate_it/easing.rb +40 -0
  38. data/lib/animate_it/engine.rb +31 -0
  39. data/lib/animate_it/errors.rb +4 -0
  40. data/lib/animate_it/frame_context.rb +28 -0
  41. data/lib/animate_it/frame_duration.rb +13 -0
  42. data/lib/animate_it/output.rb +57 -0
  43. data/lib/animate_it/props_schema.rb +49 -0
  44. data/lib/animate_it/registry.rb +30 -0
  45. data/lib/animate_it/render_store.rb +189 -0
  46. data/lib/animate_it/scene.rb +237 -0
  47. data/lib/animate_it/style.rb +23 -0
  48. data/lib/animate_it/timeline.rb +81 -0
  49. data/lib/animate_it/timing.rb +63 -0
  50. data/lib/animate_it/units.rb +38 -0
  51. data/lib/animate_it/version.rb +3 -0
  52. data/lib/animate_it/video_renderer.rb +219 -0
  53. data/lib/animate_it/view_helpers.rb +84 -0
  54. data/lib/animate_it.rb +93 -0
  55. data/lib/generators/animate_it/install/install_generator.rb +40 -0
  56. data/lib/generators/animate_it/install/templates/animate_it.rb +8 -0
  57. data/lib/tasks/animate_it_tasks.rake +83 -0
  58. metadata +120 -0
@@ -0,0 +1,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
@@ -0,0 +1,3 @@
1
+ module AnimateIt
2
+ VERSION = "0.1.0".freeze
3
+ end