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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 178ea946f7dfb1a83da1a68ba2ea514984d2ad1ab9792bbcd7d7fadff5380fc5
|
|
4
|
+
data.tar.gz: ca63aee15a52e08db308a17a2094989131bc1c6c6625f52cfb8dd375ef2b71ec
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d4f409c4d620768e021529c8d3b7b2de5d5e8b2be1eaa05165c986c25f10e384434842f71b5c5f0062f482dc228d43efc40d65dc18feae776914a91d19985b5
|
|
7
|
+
data.tar.gz: cdc55f7e5159a4ec9854a61b447be969a4710d95b831ff9024611afe5b88650eb29b8de2ba097cc3cfb0c91ac459fdadf60bb213e5d46ffffc279aadb393d251
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-07-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Initial extraction of AnimateIt into a standalone gem.
|
|
13
|
+
- Declarative composition DSL: `size`, `fps`, `duration`, `beat`, `audio`,
|
|
14
|
+
`audio_loop`, `outputs`, `scene`, `series`.
|
|
15
|
+
- `Scene` render context with animation helpers (`at_global`, `at_act`,
|
|
16
|
+
`beat_frame`, `beat_range`), `fixtures`, and `expose`.
|
|
17
|
+
- Studio UI mounted at `/animate_it` for previewing and rendering compositions.
|
|
18
|
+
- `VideoRenderer`: single-browser Playwright frame capture piped through FFmpeg
|
|
19
|
+
to MP4/WebM/MOV/GIF, with audio mux (`adelay` + `volume` + `amix`).
|
|
20
|
+
- `render_animate_it_video` executable and `animate_it:render` rake task.
|
|
21
|
+
- `animate_it:install` generator.
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/growth-constant/animate_it/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/growth-constant/animate_it/releases/tag/v0.1.0
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 growth-constant
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.svg" alt="AnimateIt" width="360">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Videos as code.</strong> Declarative, frame-driven video compositions for Rails,
|
|
7
|
+
rendered with headless Chromium (Playwright) + FFmpeg.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://github.com/growth-constant/animate_it/actions/workflows/ci.yml"><img src="https://github.com/growth-constant/animate_it/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
12
|
+
<a href="https://rubygems.org/gems/animate_it"><img src="https://img.shields.io/gem/v/animate_it.svg" alt="Gem Version"></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.4-CC342D.svg" alt="Ruby >= 3.4">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
AnimateIt lets you describe a video as a Ruby class and a HAML template, preview it
|
|
19
|
+
frame-by-frame in a bundled **Studio** UI, and render it to MP4/WebM/MOV/GIF. Each
|
|
20
|
+
frame is a real web page — so you get the full power of CSS, your app's own
|
|
21
|
+
components, and real data — captured by a headless browser and stitched with FFmpeg.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Ruby >= 3.4, Rails >= 8.1.3
|
|
26
|
+
- **FFmpeg** on the PATH (rendering)
|
|
27
|
+
- **Node + Playwright** with a Chromium build (rendering): `npx playwright install chromium`
|
|
28
|
+
- **HAML** (composition sidecar templates + Studio views)
|
|
29
|
+
|
|
30
|
+
Playwright and FFmpeg are only needed when you actually render; the gem loads
|
|
31
|
+
Playwright lazily so production boot / asset precompile never touch it.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Gemfile
|
|
37
|
+
gem "animate_it"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bundle install
|
|
42
|
+
bin/rails generate animate_it:install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The generator adds `config/initializers/animate_it.rb` and mounts the Studio in
|
|
46
|
+
`config/routes.rb` (development/test only):
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
mount AnimateIt::Engine, at: AnimateIt.config.mount_path if Rails.env.local?
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Visit **http://localhost:3000/animate_it** to open the Studio.
|
|
53
|
+
|
|
54
|
+
## Writing a composition
|
|
55
|
+
|
|
56
|
+
Compositions live in `app/videos/`. They auto-register (via the `id "..."` DSL)
|
|
57
|
+
and reload in development.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# app/videos/hello_video.rb
|
|
61
|
+
class HelloVideo < AnimateIt::Composition
|
|
62
|
+
id "hello"
|
|
63
|
+
fps 30
|
|
64
|
+
size 1080, 1080
|
|
65
|
+
duration 3.seconds
|
|
66
|
+
|
|
67
|
+
assets_dir "app/assets/images/videos"
|
|
68
|
+
output_basename "hello"
|
|
69
|
+
|
|
70
|
+
outputs do
|
|
71
|
+
mp4
|
|
72
|
+
gif
|
|
73
|
+
png frame: 45
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
beat :intro, at: 0, length: 45
|
|
77
|
+
|
|
78
|
+
class Scene < AnimateIt::Scene
|
|
79
|
+
def body
|
|
80
|
+
expose(vars: animation_vars(title_opacity: at_act(:intro, [0, 30], [0, 1])))
|
|
81
|
+
tag.div(class: "hero-render-canvas") { render_scene_template("canvas") }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
scene Scene
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```haml
|
|
90
|
+
-# app/videos/hello_video/canvas.html.haml
|
|
91
|
+
:css
|
|
92
|
+
.title { opacity: var(--title-opacity, 1); font-size: 6rem; font-weight: 800; }
|
|
93
|
+
.stage{style: @vars}
|
|
94
|
+
.title Hello 👋
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Animation helpers (`at_global`, `at_act`, `beat_frame`, `beat_range`), a per-frame
|
|
98
|
+
CSS-variable bag (`animation_vars`), named `beat`s, and `audio` / `audio_loop`
|
|
99
|
+
tracks are all available. Compositions can render your app's real partials and
|
|
100
|
+
ViewComponents — see `AnimateIt.config.render_stylesheets` below.
|
|
101
|
+
|
|
102
|
+
## Rendering
|
|
103
|
+
|
|
104
|
+
The renderer needs your Rails **server running** (it drives a real browser against
|
|
105
|
+
the Studio's filmstrip endpoint).
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# start the app
|
|
109
|
+
bin/rails server
|
|
110
|
+
|
|
111
|
+
# in another shell — render a composition's declared outputs
|
|
112
|
+
bin/rails 'animate_it:render[hello]'
|
|
113
|
+
bin/rails 'animate_it:render[hello,0..45]' # just a frame range
|
|
114
|
+
|
|
115
|
+
# or the packaged CLI (writes to tmp/animate_it/ by default)
|
|
116
|
+
bundle exec render_animate_it_video hello
|
|
117
|
+
bundle exec render_animate_it_video hello tmp/hello.mp4
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
You can also render from the **Studio** UI: open a composition, scrub the timeline,
|
|
121
|
+
and click **Render Video** to watch progress live.
|
|
122
|
+
|
|
123
|
+
### Host resolution
|
|
124
|
+
|
|
125
|
+
The renderer points the browser at `ANIMATE_IT_HOST` (falling back to the legacy
|
|
126
|
+
`RAILS_MOTION_HOST`, then `RAILS_HOST`, then `http://127.0.0.1:3000`):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
ANIMATE_IT_HOST=http://127.0.0.1:3001 bundle exec render_animate_it_video hello
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# config/initializers/animate_it.rb
|
|
136
|
+
AnimateIt.configure do |config|
|
|
137
|
+
# Where the Studio mounts (development/test only). Default "/animate_it".
|
|
138
|
+
config.mount_path = "/animate_it"
|
|
139
|
+
|
|
140
|
+
# Host stylesheets to inject into every rendered frame. Only needed when your
|
|
141
|
+
# compositions re-use host partials/components that expect their CSS. Names
|
|
142
|
+
# are passed to `stylesheet_link_tag`. Default [].
|
|
143
|
+
config.render_stylesheets = %w[application components/star-ratings]
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Render concurrency for the Studio's background renderer is tunable via
|
|
148
|
+
`ANIMATE_IT_RENDER_CONCURRENCY` and `ANIMATE_IT_RENDER_STARTS_PER_SECOND`.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
bin/setup # bundle install
|
|
154
|
+
bundle exec rspec # unit + request specs (uses spec/dummy)
|
|
155
|
+
bundle exec rubocop
|
|
156
|
+
|
|
157
|
+
# full render smoke test (needs ffmpeg + Playwright chromium)
|
|
158
|
+
RUN_RENDER_SMOKE=1 bundle exec rspec spec/rendering_spec.rb
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Specs run against a minimal host app in `spec/dummy` — no external services, no
|
|
162
|
+
database.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
[MIT](MIT-LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
# Composition sidecar templates live at app/videos/<composition>/*.html.haml
|
|
4
|
+
# in the host app — outside the engine's view path, so add it explicitly.
|
|
5
|
+
prepend_view_path Rails.root.join("app/videos")
|
|
6
|
+
|
|
7
|
+
# Sidecar templates routinely render host-app partials (e.g. the real
|
|
8
|
+
# candidates-page _match_row.haml). Those partials reach for host helpers
|
|
9
|
+
# like `user_status_style`, `full_name_hidden_display`, `add_stylesheet`,
|
|
10
|
+
# etc. — and host route helpers like `expert_profile_client_matches_job_path`.
|
|
11
|
+
# None of those live in the engine, so expose them all here so the engine's
|
|
12
|
+
# view context matches what the host's view context offers.
|
|
13
|
+
helper Rails.application.helpers
|
|
14
|
+
helper Rails.application.routes.url_helpers
|
|
15
|
+
|
|
16
|
+
protect_from_forgery with: :exception
|
|
17
|
+
layout "animate_it/application"
|
|
18
|
+
|
|
19
|
+
before_action :ensure_local_environment
|
|
20
|
+
helper_method :animate_it_path, :current_user, :policy
|
|
21
|
+
|
|
22
|
+
rescue_from AnimateIt::CompositionNotFoundError do
|
|
23
|
+
head :not_found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Host partials reach for Devise's `current_user` and Pundit's `policy(...)`
|
|
27
|
+
# helpers. The engine has neither, so provide permissive null-object
|
|
28
|
+
# versions for hero rendering. Hero canvases are always read-only; the
|
|
29
|
+
# action buttons they incidentally render evaluate every permission to
|
|
30
|
+
# false and become no-ops.
|
|
31
|
+
NullPolicy = Class.new do
|
|
32
|
+
def method_missing(name, *_args)
|
|
33
|
+
name.to_s.end_with?("?") ? false : self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def ensure_local_environment
|
|
44
|
+
head :not_found unless Rails.env.local?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def composition
|
|
48
|
+
AnimateIt.load_compositions!
|
|
49
|
+
@composition ||= AnimateIt.registry.fetch(params[:id])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def preview_props
|
|
53
|
+
raw = params[:props_json].presence || params[:props].presence || "{}"
|
|
54
|
+
parsed = raw.is_a?(String) ? JSON.parse(raw) : raw.to_unsafe_h
|
|
55
|
+
|
|
56
|
+
parsed.deep_symbolize_keys
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
{}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def animate_it_path(path)
|
|
62
|
+
"#{request.script_name}#{path}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def current_user
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def policy(_subject)
|
|
70
|
+
NullPolicy.new
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class AudioController < ApplicationController
|
|
3
|
+
AUDIO_BASE = Rails.root.join("app/audio").freeze
|
|
4
|
+
|
|
5
|
+
def show
|
|
6
|
+
audio_segments = composition.timeline.segments.select { |seg| seg.kind == :audio }
|
|
7
|
+
segment = audio_segments[params[:index].to_i]
|
|
8
|
+
return head :not_found unless segment
|
|
9
|
+
|
|
10
|
+
file_path = resolve_audio_path(segment.source[:path])
|
|
11
|
+
return head :not_found unless file_path && File.file?(file_path)
|
|
12
|
+
|
|
13
|
+
send_file file_path, type: Mime::Type.lookup_by_extension(File.extname(file_path).delete(".")),
|
|
14
|
+
disposition: "inline"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def resolve_audio_path(path)
|
|
20
|
+
return nil if path.blank?
|
|
21
|
+
|
|
22
|
+
pathname = Pathname.new(path)
|
|
23
|
+
return pathname.to_s if pathname.absolute? && pathname.realpath.to_s.start_with?(AUDIO_BASE.to_s)
|
|
24
|
+
|
|
25
|
+
candidate = AUDIO_BASE.join(path).expand_path
|
|
26
|
+
return nil unless candidate.to_s.start_with?(AUDIO_BASE.to_s)
|
|
27
|
+
|
|
28
|
+
candidate.to_s
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class FramesController < ApplicationController
|
|
3
|
+
layout false
|
|
4
|
+
skip_forgery_protection
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
@composition = composition
|
|
8
|
+
@frame = params[:frame].to_i
|
|
9
|
+
@content = @composition.render_frame(view_context, frame: @frame, props: preview_props)
|
|
10
|
+
# `?only=body` returns just the .animate-it-frame element (no <head> /
|
|
11
|
+
# stylesheets) so the studio can morph it into a persistent document
|
|
12
|
+
# instead of reloading the whole frame doc. The full-document render is
|
|
13
|
+
# what the Playwright render pipeline uses — left untouched.
|
|
14
|
+
render(:fragment) if params[:only] == "body"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def filmstrip
|
|
18
|
+
@composition = composition
|
|
19
|
+
@props = preview_props
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class PropsController < ApplicationController
|
|
3
|
+
def update
|
|
4
|
+
@composition = composition
|
|
5
|
+
@props_json = JSON.pretty_generate(@composition.props.resolve(preview_props))
|
|
6
|
+
@frame_url = "#{animate_it_path("/compositions/#{@composition.id}/frame/0")}?#{URI.encode_www_form(pp: "disable",
|
|
7
|
+
props_json: @props_json)}"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class RendersController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
@render_id = params[:id]
|
|
5
|
+
# `params[:id]` here is a render UUID, not a composition id — look up
|
|
6
|
+
# the render record first to learn which composition produced it.
|
|
7
|
+
render_record = RenderStore.find(@render_id)
|
|
8
|
+
return head(:not_found) unless render_record
|
|
9
|
+
|
|
10
|
+
AnimateIt.load_compositions!
|
|
11
|
+
@composition = AnimateIt.registry.fetch(render_record.composition_id)
|
|
12
|
+
@output_path = Pathname(render_record.output_path)
|
|
13
|
+
if params[:download].present? && @output_path.exist?
|
|
14
|
+
send_file @output_path, type: Mime::Type.lookup_by_extension(@output_path.extname.delete(".")),
|
|
15
|
+
disposition: "inline"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@output_relative_path = relative_output_path(@output_path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@composition = composition
|
|
23
|
+
@render_id = SecureRandom.hex(8)
|
|
24
|
+
@output_path = output_path(@render_id)
|
|
25
|
+
@output_path.dirname.mkpath
|
|
26
|
+
@output_relative_path = relative_output_path(@output_path)
|
|
27
|
+
|
|
28
|
+
RenderStore.create!(
|
|
29
|
+
id: @render_id,
|
|
30
|
+
composition_id: @composition.id,
|
|
31
|
+
total_frames: @composition.duration_in_frames,
|
|
32
|
+
output_path: @output_path
|
|
33
|
+
)
|
|
34
|
+
start_render(@render_id, @composition.id, request.base_url, @output_path.to_s, frames_dir(@render_id).to_s,
|
|
35
|
+
preview_props, render_options)
|
|
36
|
+
|
|
37
|
+
render status: :accepted
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cancel
|
|
41
|
+
RenderStore.cancel!(params[:id])
|
|
42
|
+
head :accepted
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def output_path(render_id)
|
|
48
|
+
ext = AnimateIt::VideoRenderer::EXTENSION_FOR_FORMAT.fetch(@composition.output_format) || "mp4"
|
|
49
|
+
Rails.root.join("tmp/animate_it/renders/#{render_id}.#{ext}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def frames_dir(render_id)
|
|
53
|
+
Rails.root.join("tmp/animate_it/renders/#{render_id}_frames")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def relative_output_path(path)
|
|
57
|
+
path&.relative_path_from(Rails.root)&.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_options
|
|
61
|
+
{
|
|
62
|
+
"concurrency" => env_with_legacy("ANIMATE_IT_RENDER_CONCURRENCY", "RAILS_MOTION_RENDER_CONCURRENCY", "3").to_i,
|
|
63
|
+
"starts_per_second" => env_with_legacy("ANIMATE_IT_RENDER_STARTS_PER_SECOND",
|
|
64
|
+
"RAILS_MOTION_RENDER_STARTS_PER_SECOND", "2").to_f
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Prefer the new ANIMATE_IT_* name; fall back to the legacy RAILS_MOTION_*
|
|
69
|
+
# name for one release so existing setups don't break, then the default.
|
|
70
|
+
def env_with_legacy(new_key, legacy_key, default)
|
|
71
|
+
ENV[new_key].presence || ENV[legacy_key].presence || default
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def start_render(render_id, composition_id, host, output_path, frame_dir, props, options)
|
|
75
|
+
Thread.new do
|
|
76
|
+
Rails.application.executor.wrap do
|
|
77
|
+
RenderJob.perform_now(render_id, composition_id, host, output_path, frame_dir, props, options)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class StudioController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
AnimateIt.load_compositions!
|
|
5
|
+
@compositions = AnimateIt.compositions
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def show
|
|
9
|
+
@composition = composition
|
|
10
|
+
@compositions = AnimateIt.compositions
|
|
11
|
+
@frame_base_url = animate_it_path("/compositions/#{@composition.id}/frame")
|
|
12
|
+
@last_frame = @composition.duration_in_frames - 1
|
|
13
|
+
@props_json = JSON.pretty_generate(@composition.props.defaults)
|
|
14
|
+
@renders = RenderStore.all
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class RenderJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
def perform(render_id, composition_id, host, output_path, frames_dir, props = {}, _options = {})
|
|
6
|
+
AnimateIt.load_compositions!
|
|
7
|
+
composition = AnimateIt.registry.fetch(composition_id)
|
|
8
|
+
|
|
9
|
+
RenderStore.start!(render_id)
|
|
10
|
+
VideoRenderer.new(
|
|
11
|
+
composition:,
|
|
12
|
+
host:,
|
|
13
|
+
output_path:,
|
|
14
|
+
frames_dir:
|
|
15
|
+
).render(
|
|
16
|
+
props:,
|
|
17
|
+
cancel_check: -> { RenderStore.cancelled?(render_id) },
|
|
18
|
+
on_progress: ->(frame, total_frames) { RenderStore.progress!(render_id, frame:, total_frames:) }
|
|
19
|
+
)
|
|
20
|
+
RenderStore.complete!(render_id)
|
|
21
|
+
rescue VideoRenderer::CancelledError
|
|
22
|
+
RenderStore.cancel!(render_id)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
RenderStore.fail!(render_id, e)
|
|
25
|
+
raise
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
!!!
|
|
2
|
+
%html{ lang: "en" }
|
|
3
|
+
%head
|
|
4
|
+
%title= "#{@composition.id} filmstrip (#{@composition.duration_in_frames} frames)"
|
|
5
|
+
%meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
|
|
6
|
+
-# Host-declared render stylesheets (AnimateIt.config.render_stylesheets) so
|
|
7
|
+
-# compositions that re-use host partials get the host's component CSS. See
|
|
8
|
+
-# frames/show.html.haml for the rationale.
|
|
9
|
+
- AnimateIt.config.render_stylesheets.each do |sheet|
|
|
10
|
+
= stylesheet_link_tag sheet, "data-turbo-track": "reload"
|
|
11
|
+
:css
|
|
12
|
+
html, body {
|
|
13
|
+
width: #{@composition.width}px;
|
|
14
|
+
height: #{@composition.height}px;
|
|
15
|
+
margin: 0;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
background: transparent;
|
|
18
|
+
}
|
|
19
|
+
body {
|
|
20
|
+
/* See frames/show.html.haml — Composition.zoom magnifies content
|
|
21
|
+
uniformly without changing the output viewport. */
|
|
22
|
+
zoom: #{@composition.zoom};
|
|
23
|
+
}
|
|
24
|
+
body, main, .hero-render-canvas {
|
|
25
|
+
background-color: transparent !important;
|
|
26
|
+
}
|
|
27
|
+
.animate-it-frame {
|
|
28
|
+
position: absolute;
|
|
29
|
+
inset: 0;
|
|
30
|
+
width: #{@composition.width}px;
|
|
31
|
+
height: #{@composition.height}px;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
display: none !important;
|
|
34
|
+
visibility: hidden;
|
|
35
|
+
}
|
|
36
|
+
.animate-it-frame.is-active {
|
|
37
|
+
display: block !important;
|
|
38
|
+
visibility: visible;
|
|
39
|
+
}
|
|
40
|
+
%body
|
|
41
|
+
- (0...@composition.duration_in_frames).each do |frame|
|
|
42
|
+
.animate-it-frame{ class: ('is-active' if frame.zero?), data: { frame: frame } }
|
|
43
|
+
= @composition.render_frame(self, frame: frame, props: @props)
|
|
44
|
+
-# See app/views/animate_it/frames/show.html.haml for why these are at the
|
|
45
|
+
-# bottom of body. Host-app accumulator helpers — emit only when present.
|
|
46
|
+
- if respond_to?(:render_page_stylesheets)
|
|
47
|
+
= render_page_stylesheets
|
|
48
|
+
- if respond_to?(:render_component_stylesheets)
|
|
49
|
+
= render_component_stylesheets
|
|
50
|
+
:javascript
|
|
51
|
+
(() => {
|
|
52
|
+
const frames = Array.from(document.querySelectorAll(".animate-it-frame"));
|
|
53
|
+
let active = frames[0] || null;
|
|
54
|
+
window.__animateIt = {
|
|
55
|
+
totalFrames: frames.length,
|
|
56
|
+
setFrame(n) {
|
|
57
|
+
const target = frames[n];
|
|
58
|
+
if (!target || target === active) return;
|
|
59
|
+
if (active) active.classList.remove("is-active");
|
|
60
|
+
target.classList.add("is-active");
|
|
61
|
+
active = target;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
document.documentElement.dataset.animateItReady = "1";
|
|
65
|
+
})();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-# Bare frame body for the studio's persistent-document preview. Mirrors the
|
|
2
|
+
-# .animate-it-frame element in show.html.haml so it can be morphed in place,
|
|
3
|
+
-# but emits no <head>/stylesheets — those are parsed once by the persistent
|
|
4
|
+
-# document. The full-document show.html.haml stays the render pipeline's path.
|
|
5
|
+
.animate-it-frame{ data: { frame: @frame, composition: @composition.id } }
|
|
6
|
+
= @content
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
!!!
|
|
2
|
+
%html{ lang: "en" }
|
|
3
|
+
%head
|
|
4
|
+
%title= "#{@composition.id} frame #{@frame}"
|
|
5
|
+
%meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
|
|
6
|
+
-# Host-declared render stylesheets (AnimateIt.config.render_stylesheets).
|
|
7
|
+
-# Compositions that re-use host partials (e.g. a real _match_row.haml)
|
|
8
|
+
-# expect the host's component CSS — star-ratings, avatar sizing, match-row
|
|
9
|
+
-# layout, fonts. The host normally queues these via `add_stylesheet(...)` /
|
|
10
|
+
-# `add_component_stylesheet(...)` and emits them with `render_*_stylesheets`,
|
|
11
|
+
-# but that queue lives on the view context, which the engine's sub-render
|
|
12
|
+
-# boundary doesn't carry across — so the host lists the bundles in the
|
|
13
|
+
-# initializer and they're loaded statically here.
|
|
14
|
+
- AnimateIt.config.render_stylesheets.each do |sheet|
|
|
15
|
+
= stylesheet_link_tag sheet, "data-turbo-track": "reload"
|
|
16
|
+
:css
|
|
17
|
+
html, body {
|
|
18
|
+
width: #{@composition.width}px;
|
|
19
|
+
height: #{@composition.height}px;
|
|
20
|
+
margin: 0;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
background: transparent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
/* Apply per-composition browser zoom (Composition.zoom). Default 1.0
|
|
27
|
+
is a no-op. With a smaller `size(...)` viewport, a >1.0 zoom
|
|
28
|
+
magnifies content uniformly without changing the output frame. */
|
|
29
|
+
zoom: #{@composition.zoom};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
body, main, .hero-render-canvas {
|
|
33
|
+
background-color: transparent !important;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.animate-it-frame {
|
|
37
|
+
position: relative;
|
|
38
|
+
width: #{@composition.width}px;
|
|
39
|
+
height: #{@composition.height}px;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
%body
|
|
43
|
+
.animate-it-frame{ data: { frame: @frame, composition: @composition.id } }
|
|
44
|
+
= @content
|
|
45
|
+
-# Host-app stylesheets queued via `add_stylesheet(...)` / ViewComponent
|
|
46
|
+
-# `with_stylesheet` accumulate on the view context during content
|
|
47
|
+
-# rendering. Emit them at the end of body so the host's component CSS
|
|
48
|
+
-# (star-ratings, match-row, etc.) reaches the document. CSS still
|
|
49
|
+
-# applies regardless of position. These accumulator helpers are host-app
|
|
50
|
+
-# provided; emit them only when the host defines them.
|
|
51
|
+
- if respond_to?(:render_page_stylesheets)
|
|
52
|
+
= render_page_stylesheets
|
|
53
|
+
- if respond_to?(:render_component_stylesheets)
|
|
54
|
+
= render_component_stylesheets
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
%turbo-frame#animate_it_props
|
|
2
|
+
= render "form", composition: @composition, props_json: @props_json
|
|
3
|
+
:javascript
|
|
4
|
+
if (window.AnimateItStudio) {
|
|
5
|
+
window.AnimateItStudio.setFrame(window.AnimateItStudio.currentFrame());
|
|
6
|
+
} else {
|
|
7
|
+
document.querySelector("#animate_it_preview iframe")?.setAttribute("src", #{@frame_url.to_json});
|
|
8
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
%turbo-frame#animate_it_render_result
|
|
2
|
+
%p= "Render #{@render_id} queued for #{@composition.id}."
|
|
3
|
+
- if @output_relative_path.present?
|
|
4
|
+
%p
|
|
5
|
+
%strong Output:
|
|
6
|
+
%code= @output_relative_path
|
|
7
|
+
%p{ style: "color: #6b7280;" } Progress will appear in the rendering jobs panel below.
|
|
8
|
+
- else
|
|
9
|
+
%p{ style: "color: #b45309;" } Render queued, but no output path was returned.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
%turbo-frame{ id: "animate_it_render_#{@render_id}" }
|
|
2
|
+
- if @output_path.exist?
|
|
3
|
+
%p= "Render #{@render_id}"
|
|
4
|
+
%p
|
|
5
|
+
%strong Output:
|
|
6
|
+
%code= @output_relative_path
|
|
7
|
+
%p
|
|
8
|
+
= link_to "Open MP4", animate_it_path("/renders/#{@render_id}?download=1"), target: "_blank", rel: "noopener"
|
|
9
|
+
- else
|
|
10
|
+
%p= "Render #{@render_id} was not found."
|