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,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: []
|