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,219 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "open3"
4
+ require "shellwords"
5
+ require "tempfile"
6
+ require "uri"
7
+ # `playwright` is a development-only gem (deploy image runs `bundle install
8
+ # --without development test`). Defer loading until a render is actually
9
+ # requested so production boot / asset precompile can load this file
10
+ # without the gem present.
11
+
12
+ module AnimateIt
13
+ class VideoRenderer
14
+ DEFAULT_PLAYWRIGHT_CLI = "npx playwright".freeze
15
+
16
+ # Lazy: Rails.root is nil at gem-load time (Bundler.require runs before
17
+ # Rails::Application is fully initialized).
18
+ def self.audio_base
19
+ @audio_base ||= Rails.root.join("app/audio").freeze
20
+ end
21
+
22
+ EXTENSION_FOR_FORMAT = {
23
+ mp4: "mp4",
24
+ webm: "webm",
25
+ mov: "mov",
26
+ gif: "gif",
27
+ png_sequence: nil,
28
+ png: "png"
29
+ }.freeze
30
+
31
+ AUDIO_INCAPABLE_FORMATS = %i[gif png_sequence png].freeze
32
+
33
+ attr_reader :composition, :host, :output_path, :frames_dir, :playwright_cli, :output_format
34
+
35
+ def initialize(composition:, host:, output_path:, frames_dir: nil, playwright_cli: nil, format: nil)
36
+ @composition = composition
37
+ @output_format = format || composition.output_format
38
+ @host = host.delete_suffix("/")
39
+ @output_path = Pathname(output_path)
40
+ @frames_dir = Pathname(frames_dir || Rails.root.join("tmp/animate_it/#{composition.id}"))
41
+ @playwright_cli = playwright_cli || ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", DEFAULT_PLAYWRIGHT_CLI)
42
+
43
+ return unless audio_segments.any? && AUDIO_INCAPABLE_FORMATS.include?(@output_format)
44
+
45
+ raise Error,
46
+ "Composition #{composition.id} declares audio but format=#{@output_format} doesn't support audio. Use :webm/:mp4/:mov."
47
+ end
48
+
49
+ class CancelledError < AnimateIt::Error; end
50
+
51
+ def render(frame_range: nil, every_nth_frame: 1, props: {}, on_progress: nil, cancel_check: nil)
52
+ FileUtils.mkdir_p(frames_dir)
53
+ FileUtils.mkdir_p(output_path.dirname)
54
+
55
+ frame_list = frames(frame_range:, every_nth_frame:)
56
+ capture_status = capture_frames(frame_list, props:, on_progress:, cancel_check:)
57
+
58
+ if capture_status == :cancelled || cancel_check&.call
59
+ frame_count = contiguous_frame_count
60
+ encode_video(frame_count:) if frame_count.positive?
61
+ raise CancelledError, "Render cancelled"
62
+ end
63
+
64
+ encode_video
65
+ output_path
66
+ end
67
+
68
+ private
69
+
70
+ def frames(frame_range:, every_nth_frame:)
71
+ range = frame_range || (0...composition.duration_in_frames)
72
+ range.step(every_nth_frame).to_a
73
+ end
74
+
75
+ # One Playwright browser, one navigation, N screenshots — all frames are
76
+ # rendered in a single filmstrip page and we just toggle which one is
77
+ # visible between captures via window.__animateIt.setFrame(n).
78
+ def capture_frames(frame_list, props:, on_progress:, cancel_check:)
79
+ require "playwright" # development-only gem; lazy-loaded so deploy image boot doesn't fail
80
+ cancelled = false
81
+
82
+ Playwright.create(playwright_cli_executable_path: playwright_cli) do |pw|
83
+ browser = pw.chromium.launch(headless: true, args: ["--disable-web-security"])
84
+ begin
85
+ context = browser.new_context(
86
+ viewport: { width: composition.width, height: composition.height }
87
+ )
88
+ page = context.new_page
89
+
90
+ page.goto(filmstrip_url(props:), waitUntil: "networkidle")
91
+ page.wait_for_function('document.documentElement.dataset.animateItReady === "1"')
92
+
93
+ frame_list.each_with_index do |frame, index|
94
+ if cancel_check&.call
95
+ cancelled = true
96
+ break
97
+ end
98
+
99
+ page.evaluate("(n) => window.__animateIt.setFrame(n)", arg: frame)
100
+ screenshot_path = frames_dir.join(format("frame-%05d.png", index))
101
+ page.screenshot(path: screenshot_path.to_s, omitBackground: true)
102
+
103
+ on_progress&.call(frame + 1, frame_list.size)
104
+ end
105
+ ensure
106
+ browser&.close
107
+ end
108
+ end
109
+
110
+ cancelled || cancel_check&.call ? :cancelled : :complete
111
+ end
112
+
113
+ def filmstrip_url(props:)
114
+ query = { pp: "disable" }
115
+ query[:props_json] = JSON.generate(props) if props.present?
116
+
117
+ "#{host}#{AnimateIt.config.mount_path}/compositions/#{composition.id}/filmstrip?#{URI.encode_www_form(query)}"
118
+ end
119
+
120
+ def encode_video(frame_count: nil)
121
+ return if output_format == :png_sequence # frames already on disk; nothing to encode
122
+
123
+ if output_format == :png
124
+ # Single-frame still: copy the captured PNG straight to output_path.
125
+ # frame_range: (n..n) was passed to capture_frames so frame-00000.png
126
+ # is the only one we need.
127
+ FileUtils.cp(frames_dir.join("frame-00000.png"), output_path)
128
+ return
129
+ end
130
+
131
+ command = ["ffmpeg", "-y", "-framerate", composition.fps.to_s,
132
+ "-i", frames_dir.join("frame-%05d.png").to_s]
133
+
134
+ audios = audio_segments
135
+ audios.each { |seg| command += ["-i", resolve_audio_path!(seg.source[:path])] }
136
+
137
+ command += video_codec_args
138
+ command += ["-frames:v", frame_count.to_s] if frame_count
139
+
140
+ if audios.any?
141
+ command += ["-filter_complex", audio_filter_graph(audios), "-map", "0:v", "-map", "[aout]", "-shortest"]
142
+ command += audio_codec_args
143
+ else
144
+ command += ["-an"] # explicit no-audio so output containers like .mov stay clean
145
+ end
146
+
147
+ command << output_path.to_s
148
+
149
+ run!(command)
150
+ end
151
+
152
+ def video_codec_args
153
+ case output_format
154
+ when :mp4
155
+ ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
156
+ when :webm
157
+ ["-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "30", "-row-mt", "1"]
158
+ when :mov
159
+ ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le"]
160
+ when :gif
161
+ ["-filter_complex",
162
+ "split[s0][s1];[s0]palettegen=reserve_transparent=1[p];[s1][p]paletteuse=alpha_threshold=128"]
163
+ else
164
+ raise Error, "Unsupported format #{output_format.inspect}"
165
+ end
166
+ end
167
+
168
+ def audio_codec_args
169
+ case output_format
170
+ when :mp4 then ["-c:a", "aac", "-b:a", "192k"]
171
+ when :webm then ["-c:a", "libopus", "-b:a", "160k"]
172
+ when :mov then ["-c:a", "pcm_s16le"]
173
+ else []
174
+ end
175
+ end
176
+
177
+ # Build an `adelay=...|...,volume=g[aN]` chain per audio input, then mix.
178
+ def audio_filter_graph(audios)
179
+ ms_per_frame = 1000.0 / composition.fps
180
+ legs = audios.each_with_index.map do |seg, i|
181
+ delay_ms = (seg.from_frame * ms_per_frame).round
182
+ gain = seg.source[:gain] || 1.0
183
+ "[#{i + 1}:a]adelay=#{delay_ms}|#{delay_ms},volume=#{gain}[a#{i}]"
184
+ end
185
+ mix = "#{audios.length.times.map { |i| "[a#{i}]" }.join}amix=inputs=#{audios.length}:duration=longest[aout]"
186
+ [legs, mix].flatten.join(";")
187
+ end
188
+
189
+ def audio_segments
190
+ composition.timeline.segments.select { |seg| seg.kind == :audio }
191
+ end
192
+
193
+ def resolve_audio_path!(path)
194
+ candidate = Pathname.new(path).absolute? ? Pathname.new(path) : self.class.audio_base.join(path)
195
+ raise Error, "Audio file not found: #{path}" unless candidate.file?
196
+
197
+ candidate.to_s
198
+ end
199
+
200
+ def contiguous_frame_count
201
+ count = 0
202
+ loop do
203
+ path = frames_dir.join(format("frame-%05d.png", count))
204
+ break unless path.exist?
205
+
206
+ count += 1
207
+ end
208
+ count
209
+ end
210
+
211
+ def run!(command)
212
+ stdout, stderr, status = Open3.capture3(*command)
213
+ return if status.success?
214
+
215
+ quoted = command.shelljoin
216
+ raise Error, "Command failed: #{quoted}\n#{stdout}\n#{stderr}"
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,84 @@
1
+ module AnimateIt
2
+ module ViewHelpers
3
+ def style(*rules, **properties)
4
+ Style.build(*rules, **properties)
5
+ end
6
+
7
+ def absolute_fill(class_name: nil, style: nil, **attributes, &block)
8
+ content = block.call
9
+
10
+ tag.div(
11
+ **attributes,
12
+ class: ["animate-it-absolute-fill", class_name].compact,
13
+ style: absolute_fill_style(style)
14
+ ) do
15
+ content
16
+ end
17
+ end
18
+
19
+ def render_template(template, assigns: {}, **)
20
+ view_context.render(template:, assigns:, layout: false, **)
21
+ end
22
+
23
+ def render_scene_template(name, assigns: {}, **)
24
+ render_template("#{sidecar_template_root}/#{name}", assigns:, **)
25
+ end
26
+
27
+ def render_partial(partial, locals: {}, **)
28
+ view_context.render(partial:, locals:, **)
29
+ end
30
+
31
+ def motion_asset(path)
32
+ view_context.asset_path(path)
33
+ end
34
+
35
+ def motion_image(path, **attributes)
36
+ tag.img(**attributes, src: motion_asset(path))
37
+ end
38
+
39
+ def build_factory(name, *traits, **attributes)
40
+ raise AnimateIt::Error, "FactoryBot is not available" unless defined?(FactoryBot)
41
+
42
+ FactoryBot.build(name, *traits, **attributes)
43
+ end
44
+
45
+ def build_stubbed(name, *traits, **attributes)
46
+ raise AnimateIt::Error, "FactoryBot is not available" unless defined?(FactoryBot)
47
+
48
+ FactoryBot.build_stubbed(name, *traits, **attributes)
49
+ end
50
+
51
+ # Define singleton methods on `object` from a {name => value} hash.
52
+ # Non-callable values are auto-wrapped in a lambda that ignores its
53
+ # args, so `stub_methods(match, finished_state?: false)` works the
54
+ # same way as `stub_methods(match, finished_state?: -> { false })`.
55
+ # The wrapping lambda accepts `*args` so arity-bearing methods like
56
+ # `user_has_reviewed?(user)` don't raise ArgumentError on the
57
+ # value-shortcut form.
58
+ def stub_methods(object, **stubs)
59
+ stubs.each do |name, value|
60
+ body = value.respond_to?(:call) ? value : ->(*) { value }
61
+ object.define_singleton_method(name) { |*args| body.call(*args) }
62
+ end
63
+ object
64
+ end
65
+
66
+ private
67
+
68
+ def sidecar_template_root
69
+ context.composition.name.underscore
70
+ end
71
+
72
+ def absolute_fill_style(extra_style)
73
+ style(
74
+ "position: absolute",
75
+ "inset: 0",
76
+ "width: 100%",
77
+ "height: 100%",
78
+ "display: flex",
79
+ "flex-direction: column",
80
+ extra_style
81
+ )
82
+ end
83
+ end
84
+ end
data/lib/animate_it.rb ADDED
@@ -0,0 +1,93 @@
1
+ require_relative "animate_it/version"
2
+ require_relative "animate_it/errors"
3
+ require_relative "animate_it/frame_duration"
4
+ require_relative "animate_it/units"
5
+ require_relative "animate_it/style"
6
+ require_relative "animate_it/easing"
7
+ require_relative "animate_it/timing"
8
+ require_relative "animate_it/animation_helpers"
9
+ require_relative "animate_it/view_helpers"
10
+ require_relative "animate_it/frame_context"
11
+ require_relative "animate_it/props_schema"
12
+ require_relative "animate_it/timeline"
13
+ require_relative "animate_it/registry"
14
+ require_relative "animate_it/render_store"
15
+ require_relative "animate_it/beats"
16
+ require_relative "animate_it/animation"
17
+ require_relative "animate_it/scene"
18
+ require_relative "animate_it/configuration"
19
+ require_relative "animate_it/composition"
20
+ require_relative "animate_it/output"
21
+ require_relative "animate_it/video_renderer"
22
+ require_relative "animate_it/asset_renderer"
23
+ # Controllers (app/controllers/animate_it/*) and AnimateIt::RenderJob
24
+ # (app/jobs/animate_it/render_job.rb) autoload via Rails — the engine's
25
+ # `isolate_namespace AnimateIt` wires `app/` paths into the autoloader.
26
+
27
+ module AnimateIt
28
+ class << self
29
+ delegate :register, to: :registry
30
+
31
+ def config
32
+ @config ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield(config)
37
+ end
38
+
39
+ def registry
40
+ @registry ||= Registry.new
41
+ end
42
+
43
+ def compositions
44
+ registry.all
45
+ end
46
+
47
+ def reset!
48
+ @registry = Registry.new
49
+ end
50
+
51
+ # Boot-time + dev-reload entry point. Wired by the engine's
52
+ # `to_prepare` initializer, which fires once at boot and on every
53
+ # autoloader reload after a watched file changes.
54
+ #
55
+ # The host's `app/videos` is in `config.autoload_paths` (set by the
56
+ # engine), so Zeitwerk owns the constants. On reload Zeitwerk has
57
+ # already `remove_const`'d every composition by the time we run, so
58
+ # `eager_load_dir` re-defines them fresh and the `inherited` hook
59
+ # fires — guaranteeing a clean Timeline / @beats / @outputs per
60
+ # reload (no stale segments accumulating across edits).
61
+ def load_compositions!
62
+ return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
63
+
64
+ videos_dir = Rails.root.join("app/videos")
65
+ return unless videos_dir.directory?
66
+
67
+ if defined?(Rails.autoloaders) && Rails.autoloaders.main.respond_to?(:eager_load_dir)
68
+ Rails.autoloaders.main.eager_load_dir(videos_dir.to_s)
69
+ else
70
+ # Fallback for environments without Zeitwerk eager_load_dir
71
+ # (older Rails / non-Zeitwerk autoloaders). `load` re-runs the
72
+ # file each time which is fine here because compositions are
73
+ # idempotent re-openable classes.
74
+ videos_dir.glob("**/*.rb").sort.each { |path| load(path.to_s) }
75
+ end
76
+
77
+ # After every composition class is loaded, give each one a chance
78
+ # to auto-mount its single nested Scene class (the single-scene
79
+ # shorthand). Idempotent: skips comps that already declared a
80
+ # `scene`/`series`/`audio`.
81
+ compositions.each(&:auto_mount_single_scene!)
82
+ end
83
+
84
+ # Dev-reload alias — clears the registry so a renamed or deleted
85
+ # composition id doesn't linger, then re-loads everything.
86
+ def reload_compositions!
87
+ registry.reset!
88
+ load_compositions!
89
+ end
90
+ end
91
+ end
92
+
93
+ require_relative "animate_it/engine" if defined?(Rails::Engine)
@@ -0,0 +1,40 @@
1
+ require "rails/generators/base"
2
+
3
+ module AnimateIt
4
+ module Generators
5
+ # Installs AnimateIt into the host app:
6
+ # bin/rails generate animate_it:install
7
+ #
8
+ # 1. Copies an opinionated initializer that owns engine config.
9
+ # 2. Inserts a `mount AnimateIt::Engine` line into config/routes.rb,
10
+ # placed AFTER `devise_for` if Devise is in use (so warden serializers
11
+ # register correctly — see config/routes.rb in the host app).
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Install AnimateIt: copy initializer and mount the engine in routes"
16
+
17
+ def copy_initializer
18
+ template "animate_it.rb", "config/routes.rb".sub("routes.rb", "initializers/animate_it.rb")
19
+ end
20
+
21
+ def add_route
22
+ routes_path = Rails.root.join("config/routes.rb")
23
+ contents = File.exist?(routes_path) ? File.read(routes_path) : ""
24
+
25
+ return say_status(:exists, "AnimateIt::Engine already mounted in config/routes.rb") if contents.include?("AnimateIt::Engine")
26
+
27
+ snippet = <<~ROUTE.chomp
28
+ \n # AnimateIt engine — mount AFTER devise_for so warden serializers register correctly.
29
+ mount AnimateIt::Engine, at: AnimateIt.config.mount_path if Rails.env.local?
30
+ ROUTE
31
+
32
+ if contents.include?("devise_for")
33
+ inject_into_file "config/routes.rb", snippet, after: /devise_for[^\n]*\n/
34
+ else
35
+ route "mount AnimateIt::Engine, at: AnimateIt.config.mount_path if Rails.env.local?"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,8 @@
1
+ # AnimateIt configuration. The engine is loaded by Bundler from the
2
+ # `gem "animate_it"` line in the Gemfile, so no manual require here.
3
+ #
4
+ # Change `mount_path` to relocate the Studio UI; `config/routes.rb` mounts
5
+ # the engine at `AnimateIt.config.mount_path` (development/test only).
6
+ AnimateIt.configure do |config|
7
+ config.mount_path = "/animate_it"
8
+ end
@@ -0,0 +1,83 @@
1
+ def animate_it_render_host
2
+ # Prefer ANIMATE_IT_HOST; fall back to the legacy RAILS_MOTION_HOST, then
3
+ # RAILS_HOST, then localhost.
4
+ ENV["ANIMATE_IT_HOST"].presence ||
5
+ ENV["RAILS_MOTION_HOST"].presence ||
6
+ ENV["RAILS_HOST"].presence ||
7
+ "http://127.0.0.1:3000"
8
+ end
9
+
10
+ namespace :animate_it do
11
+ desc <<~DESC
12
+ Render a single composition's declared outputs to their asset paths.
13
+
14
+ Usage:
15
+ bin/rails 'animate_it:render[<composition-id>]'
16
+ bin/rails 'animate_it:render[<composition-id>,<start>..<end>]' # render a subset of frames
17
+ bin/rails 'animate_it:render[<composition-id>,<start>-<end>]' # same, dash-separated
18
+
19
+ When a frame range is given, animated outputs (mp4/webm/gif) are
20
+ encoded from just those frames — the result is a clipped preview
21
+ starting at frame <start>. Still PNG outputs still render at their
22
+ declared `frame:` and are skipped if that frame is outside the range.
23
+ DESC
24
+ task :render, %i[id frames] => :environment do |_t, args|
25
+ raise "Usage: bin/rails 'animate_it:render[<composition-id>,(<start>..<end>)?]'" if args[:id].blank?
26
+
27
+ AnimateIt.load_compositions!
28
+ composition = AnimateIt.registry.fetch(args[:id])
29
+ host = animate_it_render_host
30
+ frame_range = parse_frame_range(args[:frames], composition)
31
+ puts "Rendering frames #{frame_range.first}..#{frame_range.last} of #{composition.duration_in_frames}" if frame_range
32
+ written = AnimateIt::AssetRenderer.render_composition(
33
+ composition, host: host, on_progress: progress_logger, frame_range: frame_range
34
+ )
35
+ puts ""
36
+ written.compact.each { |path| puts " → #{path.relative_path_from(Rails.root)}" }
37
+ end
38
+
39
+ desc "Render every registered composition's declared outputs"
40
+ task render_all: :environment do
41
+ AnimateIt.load_compositions!
42
+ host = animate_it_render_host
43
+
44
+ AnimateIt.compositions.each do |composition|
45
+ next if composition.outputs.empty?
46
+
47
+ label = "output#{"s" unless composition.outputs.size == 1}"
48
+ puts "Rendering #{composition.id} (#{composition.outputs.size} #{label})"
49
+ written = AnimateIt::AssetRenderer.render_composition(composition, host: host, on_progress: progress_logger)
50
+ puts ""
51
+ written.each { |path| puts " → #{path.relative_path_from(Rails.root)}" }
52
+ end
53
+ end
54
+
55
+ # Parse `30..120` / `30-120` / `30` (single frame) into a Range. Returns
56
+ # nil for blank input, meaning "use the whole composition".
57
+ def parse_frame_range(raw, composition)
58
+ return nil if raw.blank?
59
+
60
+ if raw.match?(/\A\d+\z/)
61
+ n = raw.to_i
62
+ return n..n
63
+ end
64
+
65
+ match = raw.match(/\A(\d+)\s*(?:\.\.|-)\s*(\d+)\z/)
66
+ raise "Invalid frame range #{raw.inspect}. Use `start..end`, `start-end`, or a single frame number." unless match
67
+
68
+ start_frame = match[1].to_i
69
+ end_frame = match[2].to_i
70
+ raise "Frame range start (#{start_frame}) must be <= end (#{end_frame})." if start_frame > end_frame
71
+
72
+ max = composition.duration_in_frames - 1
73
+ end_frame = max if end_frame > max
74
+ start_frame..end_frame
75
+ end
76
+
77
+ def progress_logger
78
+ lambda do |frame, total|
79
+ print "\r frame #{frame} / #{total}"
80
+ $stdout.flush
81
+ end
82
+ end
83
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: animate_it
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - growth-constant
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.1.3
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.1.3
26
+ description: |
27
+ AnimateIt is a mountable Rails engine for building videos as code. Declare a
28
+ composition (size, fps, duration, beats, audio), describe each frame with a
29
+ Ruby + HAML scene, preview it in the bundled Studio UI, and render it to
30
+ MP4/WebM/MOV/GIF with a headless Chromium (Playwright) frame capture piped
31
+ through FFmpeg.
32
+ executables:
33
+ - render_animate_it_video
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - MIT-LICENSE
39
+ - README.md
40
+ - app/controllers/animate_it/application_controller.rb
41
+ - app/controllers/animate_it/audio_controller.rb
42
+ - app/controllers/animate_it/frames_controller.rb
43
+ - app/controllers/animate_it/props_controller.rb
44
+ - app/controllers/animate_it/renders_controller.rb
45
+ - app/controllers/animate_it/studio_controller.rb
46
+ - app/jobs/animate_it/application_job.rb
47
+ - app/jobs/animate_it/render_job.rb
48
+ - app/views/animate_it/frames/filmstrip.html.haml
49
+ - app/views/animate_it/frames/fragment.html.haml
50
+ - app/views/animate_it/frames/show.html.haml
51
+ - app/views/animate_it/props/_form.html.haml
52
+ - app/views/animate_it/props/update.html.haml
53
+ - app/views/animate_it/renders/create.html.haml
54
+ - app/views/animate_it/renders/show.html.haml
55
+ - app/views/animate_it/studio/_composition_list.html.haml
56
+ - app/views/animate_it/studio/_preview_pane.html.haml
57
+ - app/views/animate_it/studio/_props_pane.html.haml
58
+ - app/views/animate_it/studio/_studio_script.html.haml
59
+ - app/views/animate_it/studio/_studio_styles.html.haml
60
+ - app/views/animate_it/studio/_timeline_lanes.html.haml
61
+ - app/views/animate_it/studio/index.html.haml
62
+ - app/views/animate_it/studio/show.html.haml
63
+ - app/views/layouts/animate_it/application.html.haml
64
+ - config/routes.rb
65
+ - exe/render_animate_it_video
66
+ - lib/animate_it.rb
67
+ - lib/animate_it/animation.rb
68
+ - lib/animate_it/animation_helpers.rb
69
+ - lib/animate_it/asset_renderer.rb
70
+ - lib/animate_it/beats.rb
71
+ - lib/animate_it/composition.rb
72
+ - lib/animate_it/configuration.rb
73
+ - lib/animate_it/easing.rb
74
+ - lib/animate_it/engine.rb
75
+ - lib/animate_it/errors.rb
76
+ - lib/animate_it/frame_context.rb
77
+ - lib/animate_it/frame_duration.rb
78
+ - lib/animate_it/output.rb
79
+ - lib/animate_it/props_schema.rb
80
+ - lib/animate_it/registry.rb
81
+ - lib/animate_it/render_store.rb
82
+ - lib/animate_it/scene.rb
83
+ - lib/animate_it/style.rb
84
+ - lib/animate_it/timeline.rb
85
+ - lib/animate_it/timing.rb
86
+ - lib/animate_it/units.rb
87
+ - lib/animate_it/version.rb
88
+ - lib/animate_it/video_renderer.rb
89
+ - lib/animate_it/view_helpers.rb
90
+ - lib/generators/animate_it/install/install_generator.rb
91
+ - lib/generators/animate_it/install/templates/animate_it.rb
92
+ - lib/tasks/animate_it_tasks.rake
93
+ homepage: https://github.com/growth-constant/animate_it
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/growth-constant/animate_it
98
+ source_code_uri: https://github.com/growth-constant/animate_it
99
+ changelog_uri: https://github.com/growth-constant/animate_it/blob/main/CHANGELOG.md
100
+ bug_tracker_uri: https://github.com/growth-constant/animate_it/issues
101
+ rubygems_mfa_required: 'true'
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.4'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.6.9
117
+ specification_version: 4
118
+ summary: Declarative, frame-driven video compositions for Rails, rendered with Playwright
119
+ + FFmpeg
120
+ test_files: []