animate_it 0.3.2 → 0.4.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 +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +131 -19
- data/app/controllers/animate_it/frames_controller.rb +7 -0
- data/app/controllers/animate_it/public_players_controller.rb +69 -0
- data/app/controllers/animate_it/renders_controller.rb +3 -17
- data/app/controllers/animate_it/studio_controller.rb +1 -0
- data/app/jobs/animate_it/render_job.rb +2 -0
- data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
- data/app/views/animate_it/frames/player.html.haml +81 -0
- data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
- data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
- data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
- data/app/views/animate_it/studio/show.html.haml +10 -3
- data/config/routes.rb +5 -0
- data/lib/animate_it/asset_manifest.rb +92 -0
- data/lib/animate_it/asset_renderer.rb +39 -7
- data/lib/animate_it/composition.rb +100 -9
- data/lib/animate_it/embed_helper.rb +22 -0
- data/lib/animate_it/engine.rb +4 -0
- data/lib/animate_it/runtime/runtime.js +385 -0
- data/lib/animate_it/runtime.rb +11 -0
- data/lib/animate_it/scene.rb +57 -3
- data/lib/animate_it/text_effects.rb +104 -0
- data/lib/animate_it/track_document_schema.rb +71 -0
- data/lib/animate_it/tracks/document.rb +100 -0
- data/lib/animate_it/tracks/layer.rb +13 -0
- data/lib/animate_it/tracks/recorder.rb +149 -0
- data/lib/animate_it/verification.rb +187 -0
- data/lib/animate_it/version.rb +1 -1
- data/lib/animate_it/video_renderer.rb +89 -26
- data/lib/animate_it/view_helpers.rb +11 -1
- data/lib/animate_it.rb +9 -0
- data/lib/tasks/animate_it_tasks.rake +133 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72d2eaa2997ddbdf969a2f9abaed9632439891facecc856352a9f24228895d58
|
|
4
|
+
data.tar.gz: 78e5d03556d7af69c22179095ef5ffd3d6c87622797e5ff6b114e87ee4411fe5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5031e03befe521c448ed4a68f4a7ef1774e5609286b6ee1f6f94ca5daf719c74a2adf852b79c18b8d2d297ad91572f7d7300077e1c7641eb46d2e5fefa8fa4ca
|
|
7
|
+
data.tar.gz: 29ce4f2eb2d61720a38815190ed173cd8f16bfde8b5c07e32fc5c7c42bfafc3e7b4c06121a574ff7941fe542ac5dc3a528166b486279e7e2d9a6a92f5350904c
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-08-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Declarative schema-v2 track recording for sampled CSS variables, dynamic
|
|
13
|
+
text, word reveals, and compact keyframe tracks.
|
|
14
|
+
- Browser-driven `/player` playback and export. Client-driven compositions
|
|
15
|
+
render once per structural layer and seek deterministically without a Rails
|
|
16
|
+
request for every frame.
|
|
17
|
+
- `client_driven!`, `structure_epochs`, `track_vars`, `text_track`,
|
|
18
|
+
`verification_props`, and deterministic native CSS/Web Animation seeking.
|
|
19
|
+
- Secure production frontend playback with explicit `public_player!`
|
|
20
|
+
allowlisting, a responsive `animate_it_player` iframe helper, autonomous
|
|
21
|
+
Play/Pause/loop transport, and synchronized byte-range audio.
|
|
22
|
+
- Player-versus-filmstrip verification with RGB and alpha comparison, props
|
|
23
|
+
matrices, structural-boundary sampling, and an every-frame `verify_all` gate.
|
|
24
|
+
- Checksum and provenance preflight for host-owned composition source assets.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Studio playback and seeking use the same client runtime as export for
|
|
28
|
+
client-driven compositions.
|
|
29
|
+
- Animated outputs preserve declared audio trim, loop, gain, and partial-range
|
|
30
|
+
alignment; GIF and PNG outputs remain silent.
|
|
31
|
+
- PNG sequences are published to their declared destination directory.
|
|
32
|
+
- Declared outputs covering the same frame range now share one browser capture
|
|
33
|
+
instead of repeating every screenshot for each output format.
|
|
34
|
+
- Removed the no-op Studio render-concurrency settings; capture remains
|
|
35
|
+
sequential so ordering, progress, and cancellation are deterministic.
|
|
36
|
+
|
|
37
|
+
### Compatibility
|
|
38
|
+
- Continued support for Ruby >= 3.3, Rails >= 7.2, HAML >= 6.3, HAML and ERB
|
|
39
|
+
sidecars, the packaged renderer executable, configurable mount paths and host
|
|
40
|
+
stylesheets, and the Rails 7.2 / Rails 8.1 appraisal matrix.
|
|
41
|
+
|
|
9
42
|
## [0.3.2] - 2026-07-23
|
|
10
43
|
|
|
11
44
|
### Fixed
|
|
@@ -63,7 +96,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
63
96
|
- `render_animate_it_video` executable and `animate_it:render` rake task.
|
|
64
97
|
- `animate_it:install` generator.
|
|
65
98
|
|
|
66
|
-
[Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.
|
|
99
|
+
[Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.4.0...HEAD
|
|
100
|
+
[0.4.0]: https://github.com/joinbuildit/animate_it/compare/v0.3.2...v0.4.0
|
|
67
101
|
[0.3.2]: https://github.com/joinbuildit/animate_it/compare/v0.3.1...v0.3.2
|
|
68
102
|
[0.3.1]: https://github.com/joinbuildit/animate_it/compare/v0.3.0...v0.3.1
|
|
69
103
|
[0.3.0]: https://github.com/growth-constant/animate_it/compare/v0.2.0...v0.3.0
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<p align="center">
|
|
14
14
|
<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>
|
|
15
15
|
<a href="https://rubygems.org/gems/animate_it"><img src="https://img.shields.io/gem/v/animate_it.svg" alt="Gem Version"></a>
|
|
16
|
-
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.
|
|
16
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D.svg" alt="Ruby >= 3.3">
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
19
|
---
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
**AnimateIt brings the same idea to Ruby on Rails** — without React, a JavaScript
|
|
23
23
|
project, or a video editor.
|
|
24
24
|
|
|
25
|
-
You describe a video as a Ruby class and a HAML template, preview it
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
and
|
|
25
|
+
You describe a video as a Ruby class and a HAML or ERB template, preview it in a
|
|
26
|
+
bundled **Studio** UI, and render it to MP4/WebM/MOV/GIF. The declarative runtime
|
|
27
|
+
renders the template once per structural layer, records compact animation tracks,
|
|
28
|
+
and seeks those tracks in the browser. You still get your app's own components,
|
|
29
|
+
styles, fonts, and real data without asking Rails to render every frame.
|
|
30
30
|
|
|
31
31
|
It's built for indie hackers and Rails developers who want to promote their projects
|
|
32
32
|
with polished product demos, launch clips, and social ads — reusing the UI they've
|
|
@@ -72,10 +72,16 @@ and reload in development.
|
|
|
72
72
|
# app/videos/hello_video.rb
|
|
73
73
|
class HelloVideo < AnimateIt::Composition
|
|
74
74
|
id "hello"
|
|
75
|
+
client_driven!
|
|
75
76
|
fps 30
|
|
76
77
|
size 1080, 1080
|
|
77
78
|
duration 3.seconds
|
|
78
79
|
|
|
80
|
+
props do
|
|
81
|
+
integer :counter_start, default: 0
|
|
82
|
+
end
|
|
83
|
+
verification_props({}, { counter_start: 100 })
|
|
84
|
+
|
|
79
85
|
assets_dir "app/assets/images/videos"
|
|
80
86
|
output_basename "hello"
|
|
81
87
|
|
|
@@ -88,9 +94,16 @@ class HelloVideo < AnimateIt::Composition
|
|
|
88
94
|
beat :intro, at: 0, length: 45
|
|
89
95
|
|
|
90
96
|
class Scene < AnimateIt::Scene
|
|
97
|
+
track_vars :root do
|
|
98
|
+
{ title_opacity: at_act(:intro, [0, 30], [0, 1]) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
text_track(:counter) { (props[:counter_start] + local_frame).to_s }
|
|
102
|
+
|
|
91
103
|
def body
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
absolute_fill(vars: :root) do
|
|
105
|
+
safe_join([render_scene_template("canvas"), animate_text(:counter)])
|
|
106
|
+
end
|
|
94
107
|
end
|
|
95
108
|
end
|
|
96
109
|
|
|
@@ -102,14 +115,73 @@ end
|
|
|
102
115
|
-# app/videos/hello_video/canvas.html.haml
|
|
103
116
|
:css
|
|
104
117
|
.title { opacity: var(--title-opacity, 1); font-size: 6rem; font-weight: 800; }
|
|
105
|
-
.stage
|
|
118
|
+
.stage
|
|
106
119
|
.title Hello 👋
|
|
107
120
|
```
|
|
108
121
|
|
|
109
|
-
Animation helpers (`at_global`, `at_act`, `beat_frame`, `beat_range`),
|
|
110
|
-
CSS
|
|
111
|
-
tracks are
|
|
112
|
-
ViewComponents — see `AnimateIt.config.render_stylesheets`
|
|
122
|
+
Animation helpers (`at_global`, `at_act`, `beat_frame`, `beat_range`), sampled
|
|
123
|
+
CSS variables (`track_vars`), dynamic text (`text_track`), named `beat`s, and
|
|
124
|
+
`audio` / `audio_loop` tracks are available. Compositions can render your app's
|
|
125
|
+
real partials and ViewComponents — see `AnimateIt.config.render_stylesheets`
|
|
126
|
+
below.
|
|
127
|
+
|
|
128
|
+
### Browser playback runtime
|
|
129
|
+
|
|
130
|
+
`client_driven!` switches export and Studio playback to `/player`. Rails renders
|
|
131
|
+
each structural layer once and records a schema-v2 track document; then
|
|
132
|
+
`window.__animateIt.setFrame(frame)` updates CSS variables, text, layer
|
|
133
|
+
visibility, word reveals, and native CSS/Web Animations without another frame
|
|
134
|
+
request.
|
|
135
|
+
|
|
136
|
+
Use `structure_epochs` only when a template's DOM shape changes. Each epoch adds
|
|
137
|
+
one pre-rendered layer. Track schema v2 scopes bindings to their timeline segment;
|
|
138
|
+
the player still accepts v1 documents and rejects unknown versions.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
structure_epochs 60, 120
|
|
142
|
+
|
|
143
|
+
class Scene < AnimateIt::Scene
|
|
144
|
+
track_vars(:card) { { x: "#{at_global([0, 30], [-20, 0])}px" } }
|
|
145
|
+
text_track(:score) { (progress * 100).round }
|
|
146
|
+
|
|
147
|
+
def body
|
|
148
|
+
absolute_fill(vars: :card) do
|
|
149
|
+
safe_join([render_scene_template("canvas"), animate_text(:score)])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Production frontend playback
|
|
156
|
+
|
|
157
|
+
Public playback is opt-in per composition. `public_player!` enables the
|
|
158
|
+
standalone browser clock, Play/Pause control, looping, and synchronized
|
|
159
|
+
music/voice/SFX. It does not expose Studio, frame, filmstrip, props, or render
|
|
160
|
+
endpoints, and public playback always uses the composition's default props.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
class HelloVideo < AnimateIt::Composition
|
|
164
|
+
id "hello"
|
|
165
|
+
public_player! autoplay: false, loop: true
|
|
166
|
+
# ...
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Mount the engine in every environment, then embed the allowlisted composition
|
|
171
|
+
from a host view:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# config/routes.rb
|
|
175
|
+
mount AnimateIt::Engine, at: AnimateIt.config.mount_path
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```erb
|
|
179
|
+
<%= animate_it_player "hello", title: "Hello product demo" %>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The iframe renders each structural layer once and advances entirely in the
|
|
183
|
+
browser. Audio-capable players fall back to the visible Play button when the
|
|
184
|
+
browser blocks autoplay.
|
|
113
185
|
|
|
114
186
|
### HAML or ERB
|
|
115
187
|
|
|
@@ -118,9 +190,9 @@ Scene sidecar templates can be authored in **HAML** (`canvas.html.haml`) or
|
|
|
118
190
|
mix engines across scenes. The HAML canvas above is equivalent to:
|
|
119
191
|
|
|
120
192
|
```erb
|
|
121
|
-
|
|
193
|
+
<%# app/videos/hello_video/canvas.html.erb %>
|
|
122
194
|
<style>.title { opacity: var(--title-opacity, 1); font-size: 6rem; font-weight: 800; }</style>
|
|
123
|
-
<div class="stage"
|
|
195
|
+
<div class="stage">
|
|
124
196
|
<div class="title">Hello 👋</div>
|
|
125
197
|
</div>
|
|
126
198
|
```
|
|
@@ -130,8 +202,9 @@ app to use it — ERB-only apps work out of the box.
|
|
|
130
202
|
|
|
131
203
|
## Rendering
|
|
132
204
|
|
|
133
|
-
The renderer needs your Rails **server running
|
|
134
|
-
the
|
|
205
|
+
The renderer needs your Rails **server running**. It drives a real browser
|
|
206
|
+
against `/player` for client-driven compositions and the legacy `/filmstrip`
|
|
207
|
+
endpoint for other compositions.
|
|
135
208
|
|
|
136
209
|
```bash
|
|
137
210
|
# start the app
|
|
@@ -141,6 +214,10 @@ bin/rails server
|
|
|
141
214
|
bin/rails 'animate_it:render[hello]'
|
|
142
215
|
bin/rails 'animate_it:render[hello,0..45]' # just a frame range
|
|
143
216
|
|
|
217
|
+
# compare /player with the legacy /filmstrip (step 1 checks every frame)
|
|
218
|
+
bin/rails 'animate_it:verify[hello,1]'
|
|
219
|
+
bin/rails animate_it:verify_all
|
|
220
|
+
|
|
144
221
|
# or the packaged CLI (writes to tmp/animate_it/ by default)
|
|
145
222
|
bundle exec render_animate_it_video hello
|
|
146
223
|
bundle exec render_animate_it_video hello tmp/hello.mp4
|
|
@@ -173,8 +250,42 @@ AnimateIt.configure do |config|
|
|
|
173
250
|
end
|
|
174
251
|
```
|
|
175
252
|
|
|
176
|
-
|
|
177
|
-
|
|
253
|
+
When one composition declares multiple formats for the same frame range,
|
|
254
|
+
AnimateIt captures the ordered Chromium frames once and reuses them for each
|
|
255
|
+
encoder. Studio rendering intentionally keeps one sequential capture stream so
|
|
256
|
+
frame ordering, progress, and cancellation stay deterministic.
|
|
257
|
+
|
|
258
|
+
## Deterministic asset preflight
|
|
259
|
+
|
|
260
|
+
Host applications can record local composition inputs in
|
|
261
|
+
`config/animate_it_assets.yml`. `bin/rails animate_it:preflight` checks that each
|
|
262
|
+
file exists, matches its SHA-256 checksum, and has a provenance provider. Media
|
|
263
|
+
stays in the host app and is not packaged in the gem.
|
|
264
|
+
|
|
265
|
+
```yaml
|
|
266
|
+
version: 1
|
|
267
|
+
assets:
|
|
268
|
+
- path: app/audio/launch/music.mp3
|
|
269
|
+
kind: music
|
|
270
|
+
compositions: [hello]
|
|
271
|
+
sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
272
|
+
provenance:
|
|
273
|
+
provider: elevenlabs
|
|
274
|
+
generated_with_ai: true
|
|
275
|
+
model_id: not-recorded
|
|
276
|
+
generation_id: not-recorded
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
For parameterized compositions, declare `verification_props` or override them
|
|
280
|
+
at the command line:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
ANIMATE_IT_PROPS_JSON='{"title":"Variant"}' bin/rails 'animate_it:verify[hello,1]'
|
|
284
|
+
ANIMATE_IT_PROPS_MATRIX_JSON='[{}, {"title":"Variant"}]' bin/rails animate_it:verify_all
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Verification requires a running server and writes comparison screenshots under
|
|
288
|
+
`tmp/animate_it/verify`.
|
|
178
289
|
|
|
179
290
|
## Claude skill
|
|
180
291
|
|
|
@@ -195,6 +306,7 @@ The skill source lives in [`skills/`](skills/).
|
|
|
195
306
|
bin/setup # bundle install
|
|
196
307
|
bundle exec rspec # unit + request specs (uses spec/dummy)
|
|
197
308
|
bundle exec rubocop
|
|
309
|
+
gem build animate_it.gemspec
|
|
198
310
|
|
|
199
311
|
# full render smoke test (needs ffmpeg + Playwright chromium)
|
|
200
312
|
RUN_RENDER_SMOKE=1 bundle exec rspec spec/rendering_spec.rb
|
|
@@ -18,5 +18,12 @@ module AnimateIt
|
|
|
18
18
|
@composition = composition
|
|
19
19
|
@props = preview_props
|
|
20
20
|
end
|
|
21
|
+
|
|
22
|
+
def player
|
|
23
|
+
@composition = composition
|
|
24
|
+
@props = preview_props
|
|
25
|
+
@track_document = @composition.track_document(props: @props)
|
|
26
|
+
TrackDocumentSchema.validate!(@track_document)
|
|
27
|
+
end
|
|
21
28
|
end
|
|
22
29
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# The only production-accessible AnimateIt surface. Compositions must opt in
|
|
3
|
+
# explicitly with `public_player!`; all Studio and rendering controllers keep
|
|
4
|
+
# their local-environment guard.
|
|
5
|
+
class PublicPlayersController < ApplicationController
|
|
6
|
+
AUDIO_BASE = Rails.root.join("app/audio").freeze
|
|
7
|
+
|
|
8
|
+
layout false
|
|
9
|
+
skip_before_action :ensure_local_environment
|
|
10
|
+
before_action :ensure_public_player
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@props = {}
|
|
14
|
+
@track_document = composition.track_document
|
|
15
|
+
TrackDocumentSchema.validate!(@track_document)
|
|
16
|
+
@audio_segments = audio_segments
|
|
17
|
+
@public_player = true
|
|
18
|
+
@public_player_options = composition.public_player_options
|
|
19
|
+
render "animate_it/frames/player"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def audio
|
|
23
|
+
segment = audio_segments[params[:index].to_i]
|
|
24
|
+
return head :not_found unless segment
|
|
25
|
+
|
|
26
|
+
file_path = resolve_audio_path(segment.source[:path])
|
|
27
|
+
return head :not_found unless file_path
|
|
28
|
+
|
|
29
|
+
serve_with_byte_ranges(file_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def ensure_public_player
|
|
35
|
+
head :not_found unless composition.public_player? && composition.client_driven?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def audio_segments
|
|
39
|
+
@audio_segments ||= composition.timeline.segments.select { |segment| segment.kind == :audio }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def public_audio_path(index)
|
|
43
|
+
animate_it_path("/public/compositions/#{composition.id}/audio/#{index}")
|
|
44
|
+
end
|
|
45
|
+
helper_method :public_audio_path
|
|
46
|
+
|
|
47
|
+
def serve_with_byte_ranges(file_path)
|
|
48
|
+
status, headers, body = Rack::Files.new(file_path.dirname.to_s).serving(request, file_path.to_s)
|
|
49
|
+
headers["content-type"] = Mime::Type.lookup_by_extension(file_path.extname.delete(".")).to_s
|
|
50
|
+
headers["content-disposition"] = "inline"
|
|
51
|
+
headers["accept-ranges"] = "bytes"
|
|
52
|
+
self.status = status
|
|
53
|
+
self.response_body = body
|
|
54
|
+
headers.each { |key, value| response.headers[key] = value }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_audio_path(path)
|
|
58
|
+
return if path.blank?
|
|
59
|
+
|
|
60
|
+
base = AUDIO_BASE.realpath
|
|
61
|
+
candidate = Pathname.new(path)
|
|
62
|
+
candidate = base.join(candidate) unless candidate.absolute?
|
|
63
|
+
resolved = candidate.realpath
|
|
64
|
+
resolved if resolved.to_s.start_with?("#{base}#{File::SEPARATOR}")
|
|
65
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -32,7 +32,7 @@ module AnimateIt
|
|
|
32
32
|
output_path: @output_path
|
|
33
33
|
)
|
|
34
34
|
start_render(@render_id, @composition.id, request.base_url, @output_path.to_s, frames_dir(@render_id).to_s,
|
|
35
|
-
preview_props
|
|
35
|
+
preview_props)
|
|
36
36
|
|
|
37
37
|
render status: :accepted
|
|
38
38
|
end
|
|
@@ -57,24 +57,10 @@ module AnimateIt
|
|
|
57
57
|
path&.relative_path_from(Rails.root)&.to_s
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def
|
|
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)
|
|
60
|
+
def start_render(render_id, composition_id, host, output_path, frame_dir, props)
|
|
75
61
|
Thread.new do
|
|
76
62
|
Rails.application.executor.wrap do
|
|
77
|
-
RenderJob.perform_now(render_id, composition_id, host, output_path, frame_dir, props
|
|
63
|
+
RenderJob.perform_now(render_id, composition_id, host, output_path, frame_dir, props)
|
|
78
64
|
end
|
|
79
65
|
end
|
|
80
66
|
end
|
|
@@ -9,6 +9,7 @@ module AnimateIt
|
|
|
9
9
|
@composition = composition
|
|
10
10
|
@compositions = AnimateIt.compositions
|
|
11
11
|
@frame_base_url = animate_it_path("/compositions/#{@composition.id}/frame")
|
|
12
|
+
@player_url = animate_it_path("/compositions/#{@composition.id}/player")
|
|
12
13
|
@last_frame = @composition.duration_in_frames - 1
|
|
13
14
|
@props_json = JSON.pretty_generate(@composition.props.defaults)
|
|
14
15
|
@renders = RenderStore.all
|
|
@@ -2,6 +2,8 @@ module AnimateIt
|
|
|
2
2
|
class RenderJob < ApplicationJob
|
|
3
3
|
queue_as :default
|
|
4
4
|
|
|
5
|
+
# `_options` remains as a compatibility slot for render jobs serialized by
|
|
6
|
+
# releases that exposed concurrency settings but never applied them.
|
|
5
7
|
def perform(render_id, composition_id, host, output_path, frames_dir, props = {}, _options = {})
|
|
6
8
|
AnimateIt.load_compositions!
|
|
7
9
|
composition = AnimateIt.registry.fetch(composition_id)
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
body, main, .hero-render-canvas {
|
|
25
25
|
background-color: transparent !important;
|
|
26
26
|
}
|
|
27
|
+
*, *::before, *::after {
|
|
28
|
+
transition: none !important;
|
|
29
|
+
}
|
|
27
30
|
.animate-it-frame {
|
|
28
31
|
position: absolute;
|
|
29
32
|
inset: 0;
|
|
@@ -40,26 +43,54 @@
|
|
|
40
43
|
%body
|
|
41
44
|
- (0...@composition.duration_in_frames).each do |frame|
|
|
42
45
|
.animate-it-frame{ class: ('is-active' if frame.zero?), data: { frame: frame } }
|
|
43
|
-
= @composition.render_frame(self, frame: frame, props: @props)
|
|
46
|
+
= @composition.render_frame(self, frame: frame, props: @props, segment_origins: true)
|
|
44
47
|
-# See app/views/animate_it/frames/show.html.haml for why these are at the
|
|
45
48
|
-# bottom of body. Host-app accumulator helpers — emit only when present.
|
|
46
49
|
- if respond_to?(:render_page_stylesheets)
|
|
47
50
|
= render_page_stylesheets
|
|
48
51
|
- if respond_to?(:render_component_stylesheets)
|
|
49
52
|
= render_component_stylesheets
|
|
53
|
+
= javascript_tag AnimateIt::Runtime.source.html_safe
|
|
50
54
|
:javascript
|
|
51
55
|
(() => {
|
|
52
56
|
const frames = Array.from(document.querySelectorAll(".animate-it-frame"));
|
|
57
|
+
const segmentRoots = new WeakMap();
|
|
58
|
+
frames.forEach((frame) => {
|
|
59
|
+
const segments = Array.from(frame.querySelectorAll(":scope > [data-animate-it-segment-origin]"));
|
|
60
|
+
segmentRoots.set(frame, segments.map((segment) => {
|
|
61
|
+
const entry = {
|
|
62
|
+
origin: Number(segment.dataset.animateItSegmentOrigin) || 0,
|
|
63
|
+
roots: Array.from(segment.children)
|
|
64
|
+
};
|
|
65
|
+
segment.replaceWith(...segment.childNodes);
|
|
66
|
+
return entry;
|
|
67
|
+
}));
|
|
68
|
+
});
|
|
53
69
|
let active = frames[0] || null;
|
|
54
70
|
window.__animateIt = {
|
|
55
71
|
totalFrames: frames.length,
|
|
56
72
|
setFrame(n) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
target
|
|
61
|
-
|
|
73
|
+
const frame = Math.max(0, Math.min(frames.length - 1, Math.round(Number(n) || 0)));
|
|
74
|
+
const target = frames[frame];
|
|
75
|
+
if (!target) return frame;
|
|
76
|
+
if (target !== active) {
|
|
77
|
+
if (active) active.classList.remove("is-active");
|
|
78
|
+
target.classList.add("is-active");
|
|
79
|
+
active = target;
|
|
80
|
+
}
|
|
81
|
+
const segments = segmentRoots.get(target) || [];
|
|
82
|
+
if (segments.length) {
|
|
83
|
+
segments.forEach((segment) => {
|
|
84
|
+
segment.roots.forEach((root) => {
|
|
85
|
+
window.AnimateItRuntimeApi.seekAnimations(root, frame - segment.origin, #{@composition.fps});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
window.AnimateItRuntimeApi.seekAnimations(target, frame, #{@composition.fps});
|
|
90
|
+
}
|
|
91
|
+
return frame;
|
|
62
92
|
}
|
|
63
93
|
};
|
|
94
|
+
window.__animateIt.setFrame(0);
|
|
64
95
|
document.documentElement.dataset.animateItReady = "1";
|
|
65
96
|
})();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
!!!
|
|
2
|
+
%html{ lang: "en" }
|
|
3
|
+
%head
|
|
4
|
+
%title= "#{@composition.id} player (#{@composition.duration_in_frames} frames)"
|
|
5
|
+
%meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
|
|
6
|
+
- AnimateIt.config.render_stylesheets.each do |sheet|
|
|
7
|
+
= stylesheet_link_tag sheet, "data-turbo-track": "reload"
|
|
8
|
+
:css
|
|
9
|
+
html, body {
|
|
10
|
+
width: #{@composition.width}px;
|
|
11
|
+
height: #{@composition.height}px;
|
|
12
|
+
margin: 0;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
background: transparent;
|
|
15
|
+
}
|
|
16
|
+
body {
|
|
17
|
+
zoom: #{@composition.zoom};
|
|
18
|
+
}
|
|
19
|
+
body, main, .hero-render-canvas {
|
|
20
|
+
background-color: transparent !important;
|
|
21
|
+
}
|
|
22
|
+
*, *::before, *::after {
|
|
23
|
+
transition: none !important;
|
|
24
|
+
}
|
|
25
|
+
.animate-it-stage {
|
|
26
|
+
position: absolute;
|
|
27
|
+
inset: 0;
|
|
28
|
+
width: #{@composition.width}px;
|
|
29
|
+
height: #{@composition.height}px;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
}
|
|
32
|
+
.animate-it-layer {
|
|
33
|
+
position: absolute;
|
|
34
|
+
inset: 0;
|
|
35
|
+
display: none !important;
|
|
36
|
+
visibility: hidden;
|
|
37
|
+
}
|
|
38
|
+
.animate-it-layer.is-active {
|
|
39
|
+
display: block !important;
|
|
40
|
+
visibility: visible;
|
|
41
|
+
}
|
|
42
|
+
.animate-it-public-play {
|
|
43
|
+
position: fixed;
|
|
44
|
+
right: 16px;
|
|
45
|
+
bottom: 16px;
|
|
46
|
+
z-index: 2147483647;
|
|
47
|
+
border: 0;
|
|
48
|
+
border-radius: 999px;
|
|
49
|
+
padding: 10px 18px;
|
|
50
|
+
background: rgba(0, 0, 0, 0.78);
|
|
51
|
+
color: #fff;
|
|
52
|
+
font: 600 14px/1 system-ui, sans-serif;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
%body
|
|
56
|
+
.animate-it-stage
|
|
57
|
+
= @composition.render_structure(self, props: @props)
|
|
58
|
+
- if respond_to?(:render_page_stylesheets)
|
|
59
|
+
= render_page_stylesheets
|
|
60
|
+
- if respond_to?(:render_component_stylesheets)
|
|
61
|
+
= render_component_stylesheets
|
|
62
|
+
- if @public_player
|
|
63
|
+
- @audio_segments.each_with_index do |segment, index|
|
|
64
|
+
%audio{ data: {
|
|
65
|
+
from_frame: segment.from_frame,
|
|
66
|
+
duration_frames: segment.duration_frames || (@composition.duration_in_frames - segment.from_frame),
|
|
67
|
+
gain: segment.source[:gain],
|
|
68
|
+
loop: segment.source[:loop] ? "true" : "false"
|
|
69
|
+
},
|
|
70
|
+
preload: "auto",
|
|
71
|
+
src: public_audio_path(index),
|
|
72
|
+
style: "display:none" }
|
|
73
|
+
%button.animate-it-public-play{ type: "button", data: { animate_it_play: true }, aria: { pressed: "false" } } Play
|
|
74
|
+
%script{ type: "application/json", data: {
|
|
75
|
+
animate_it_tracks: true,
|
|
76
|
+
animate_it_transport: @public_player ? "true" : "false",
|
|
77
|
+
animate_it_autoplay: @public_player_options&.fetch(:autoplay, false) ? "true" : "false",
|
|
78
|
+
animate_it_loop: @public_player_options&.fetch(:loop, true) ? "true" : "false"
|
|
79
|
+
} }
|
|
80
|
+
!= ERB::Util.json_escape(@track_document.to_json)
|
|
81
|
+
= javascript_tag AnimateIt::Runtime.source.html_safe
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
%turbo-frame#animate_it_preview.animate-it-preview
|
|
11
11
|
%iframe#animate_it_frame{
|
|
12
12
|
title: "#{composition.id} frame preview",
|
|
13
|
-
src: "#{frame_base_url}/0?pp=disable",
|
|
13
|
+
src: composition.client_driven? ? "#{player_url}?pp=disable" : "#{frame_base_url}/0?pp=disable",
|
|
14
14
|
style: "width: min(100%, #{composition.width}px); aspect-ratio: #{composition.width} / #{composition.height};"
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
%section.animate-it-renders
|
|
11
11
|
%h3 Rendering jobs
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
- if respond_to?(:turbo_stream_from)
|
|
13
|
+
= turbo_stream_from AnimateIt::RenderStore::CHANNEL
|
|
14
|
+
%p.animate-it-stream-status Turbo Stream connected for render progress.
|
|
14
15
|
= AnimateIt::RenderStore.html.html_safe
|