animate_it 0.3.2 → 0.5.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +212 -20
  4. data/app/controllers/animate_it/embed_assets_controller.rb +25 -0
  5. data/app/controllers/animate_it/frames_controller.rb +10 -0
  6. data/app/controllers/animate_it/public_players_controller.rb +73 -0
  7. data/app/controllers/animate_it/renders_controller.rb +3 -17
  8. data/app/controllers/animate_it/studio_controller.rb +1 -0
  9. data/app/jobs/animate_it/render_job.rb +2 -0
  10. data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
  11. data/app/views/animate_it/frames/player.html.haml +92 -0
  12. data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
  13. data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
  14. data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
  15. data/app/views/animate_it/studio/show.html.haml +10 -3
  16. data/config/routes.rb +10 -0
  17. data/lib/animate_it/asset_manifest.rb +92 -0
  18. data/lib/animate_it/asset_renderer.rb +39 -7
  19. data/lib/animate_it/chapter_navigation.rb +160 -0
  20. data/lib/animate_it/chapters.rb +95 -0
  21. data/lib/animate_it/composition.rb +115 -9
  22. data/lib/animate_it/embed_helper.rb +214 -0
  23. data/lib/animate_it/embed_runtime/embed.js +338 -0
  24. data/lib/animate_it/embed_runtime.rb +13 -0
  25. data/lib/animate_it/embed_styles.rb +126 -0
  26. data/lib/animate_it/engine.rb +7 -0
  27. data/lib/animate_it/player_manifest.rb +29 -0
  28. data/lib/animate_it/runtime/runtime.js +518 -0
  29. data/lib/animate_it/runtime.rb +11 -0
  30. data/lib/animate_it/scene.rb +57 -3
  31. data/lib/animate_it/text_effects.rb +104 -0
  32. data/lib/animate_it/track_document_schema.rb +71 -0
  33. data/lib/animate_it/tracks/document.rb +100 -0
  34. data/lib/animate_it/tracks/layer.rb +13 -0
  35. data/lib/animate_it/tracks/recorder.rb +149 -0
  36. data/lib/animate_it/verification.rb +187 -0
  37. data/lib/animate_it/version.rb +1 -1
  38. data/lib/animate_it/video_renderer.rb +89 -26
  39. data/lib/animate_it/view_helpers.rb +11 -1
  40. data/lib/animate_it.rb +17 -0
  41. data/lib/tasks/animate_it_tasks.rake +133 -0
  42. metadata +25 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0675f1e382659072fd53a4b9453d2a44b1bf8a208075bc43974522195540df1f
4
- data.tar.gz: c3804ff10fa75939fd23716e555054b64ea0ba45f1eaa9beb36c8b23e5b7c025
3
+ metadata.gz: 7ec5ae62f97bd3388e29dfc63ea1b7580a233973e2201873cbbed0ee7e9234ab
4
+ data.tar.gz: 7af5a47d46d966a785d89d13d0fc31dfa2a83945966481c6209beacb286c460d
5
5
  SHA512:
6
- metadata.gz: 06fe263c48072dc834609a78c5f3f3f09a2897872740e11d54fd8edcf2dcd986113ae1338ffd941f491077de0c4a3feb100cda5b679ef161052c8cbe123a55f3
7
- data.tar.gz: d2f7f86b39e2aa3308dc98e9e6cd5bc4956716e03e0e81cc2a5857892ac9f1dea584634b13f3632450561285f361dbb3491964ac58a10296118e2a1375499b26
6
+ metadata.gz: 49f073e22fe939dd1223d91bce55a364da6923e9ad458580f98f4369d618b4d43dd7cb981c5925699ea1a6bab72834482caadfb14a5bf115182358d054926ba3
7
+ data.tar.gz: 2c066b3b7a44093330743a0f4e88a0fdc309388eed285d67dfcb4b65554c0af7722df1a5581fd112ec29eae277f184b33d4f36462c8a0fe62f6e4f129e0a764c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,66 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-08-13
10
+
11
+ ### Added
12
+ - User-navigable `chapter` declarations backed by existing beats, with a
13
+ versioned player manifest and normalized progress/state variables.
14
+ - `animate_it_embed`, a poster-first custom-element embed with responsive
15
+ composition variants, proportional scaling, readiness crossfade, visibility
16
+ playback, offscreen pausing, reduced-motion fallback, and accessible controls.
17
+ - Headless Rails chapter builders for arbitrary controls plus optional desktop
18
+ and mobile-carousel pill presets and canvas-side chapter navigation.
19
+ - Stable transport commands and `ready`, `framechange`, `chapterchange`, `play`,
20
+ `pause`, `ended`, and `error` events over a source-checked same-origin message
21
+ protocol.
22
+ - Versioned, immutable JavaScript and CSS endpoints that work independently of
23
+ Stimulus and the host application's asset pipeline.
24
+
25
+ ### Changed
26
+ - Public player readiness now waits for fonts, eager images, and two paint
27
+ frames. `AnimateItTransport` remains available as a compatibility alias.
28
+ - Repository and package metadata now point at `joinbuildit/animate_it`.
29
+ - Every automated GitHub release now publishes the matching curated changelog
30
+ section as its release description and fails safely when that entry is missing.
31
+
32
+ ### Compatibility
33
+ - `animate_it_player`, track schema v2, existing compositions, Studio playback,
34
+ and video rendering remain backward compatible.
35
+
36
+ ## [0.4.0] - 2026-08-01
37
+
38
+ ### Added
39
+ - Declarative schema-v2 track recording for sampled CSS variables, dynamic
40
+ text, word reveals, and compact keyframe tracks.
41
+ - Browser-driven `/player` playback and export. Client-driven compositions
42
+ render once per structural layer and seek deterministically without a Rails
43
+ request for every frame.
44
+ - `client_driven!`, `structure_epochs`, `track_vars`, `text_track`,
45
+ `verification_props`, and deterministic native CSS/Web Animation seeking.
46
+ - Secure production frontend playback with explicit `public_player!`
47
+ allowlisting, a responsive `animate_it_player` iframe helper, autonomous
48
+ Play/Pause/loop transport, and synchronized byte-range audio.
49
+ - Player-versus-filmstrip verification with RGB and alpha comparison, props
50
+ matrices, structural-boundary sampling, and an every-frame `verify_all` gate.
51
+ - Checksum and provenance preflight for host-owned composition source assets.
52
+
53
+ ### Changed
54
+ - Studio playback and seeking use the same client runtime as export for
55
+ client-driven compositions.
56
+ - Animated outputs preserve declared audio trim, loop, gain, and partial-range
57
+ alignment; GIF and PNG outputs remain silent.
58
+ - PNG sequences are published to their declared destination directory.
59
+ - Declared outputs covering the same frame range now share one browser capture
60
+ instead of repeating every screenshot for each output format.
61
+ - Removed the no-op Studio render-concurrency settings; capture remains
62
+ sequential so ordering, progress, and cancellation are deterministic.
63
+
64
+ ### Compatibility
65
+ - Continued support for Ruby >= 3.3, Rails >= 7.2, HAML >= 6.3, HAML and ERB
66
+ sidecars, the packaged renderer executable, configurable mount paths and host
67
+ stylesheets, and the Rails 7.2 / Rails 8.1 appraisal matrix.
68
+
9
69
  ## [0.3.2] - 2026-07-23
10
70
 
11
71
  ### Fixed
@@ -63,7 +123,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
63
123
  - `render_animate_it_video` executable and `animate_it:render` rake task.
64
124
  - `animate_it:install` generator.
65
125
 
66
- [Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.3.2...HEAD
126
+ [Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.5.0...HEAD
127
+ [0.5.0]: https://github.com/joinbuildit/animate_it/compare/v0.4.0...v0.5.0
128
+ [0.4.0]: https://github.com/joinbuildit/animate_it/compare/v0.3.2...v0.4.0
67
129
  [0.3.2]: https://github.com/joinbuildit/animate_it/compare/v0.3.1...v0.3.2
68
130
  [0.3.1]: https://github.com/joinbuildit/animate_it/compare/v0.3.0...v0.3.1
69
131
  [0.3.0]: https://github.com/growth-constant/animate_it/compare/v0.2.0...v0.3.0
data/README.md CHANGED
@@ -11,9 +11,9 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
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>
14
+ <a href="https://github.com/joinbuildit/animate_it/actions/workflows/ci.yml"><img src="https://github.com/joinbuildit/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.4-CC342D.svg" alt="Ruby >= 3.4">
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,16 +22,27 @@
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 frame-by-frame
26
- in a bundled **Studio** UI, and render it to MP4/WebM/MOV/GIF. Every frame is a real
27
- web page, so you get the full power of CSS and crucially **your app's own
28
- components, styles, fonts, and real data**. A headless browser captures each frame
29
- and FFmpeg stitches them into a video.
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
33
33
  already built, staying in Ruby, and skipping the whole "learn a video editor" detour.
34
34
 
35
+ <p align="center">
36
+ <img src="assets/animate-it-demo.gif" alt="AnimateIt turns a Ruby composition into smooth, seekable browser animation" width="640">
37
+ </p>
38
+
39
+ <p align="center">
40
+ <em>A five-second Ruby composition rendered by AnimateIt's client-driven browser runtime.</em><br>
41
+ <a href="https://github.com/joinbuildit/animate_it/blob/main/spec/dummy/app/videos/readme_demo_video.rb">View the Ruby composition</a>
42
+ ·
43
+ <a href="https://github.com/joinbuildit/animate_it/blob/main/spec/dummy/app/videos/readme_demo_video/canvas.html.erb">View the HTML/CSS template</a>
44
+ </p>
45
+
35
46
  ## Requirements
36
47
 
37
48
  - Ruby >= 3.3, Rails >= 7.2 (tested against Rails 7.2 and 8.1)
@@ -72,10 +83,16 @@ and reload in development.
72
83
  # app/videos/hello_video.rb
73
84
  class HelloVideo < AnimateIt::Composition
74
85
  id "hello"
86
+ client_driven!
75
87
  fps 30
76
88
  size 1080, 1080
77
89
  duration 3.seconds
78
90
 
91
+ props do
92
+ integer :counter_start, default: 0
93
+ end
94
+ verification_props({}, { counter_start: 100 })
95
+
79
96
  assets_dir "app/assets/images/videos"
80
97
  output_basename "hello"
81
98
 
@@ -88,9 +105,16 @@ class HelloVideo < AnimateIt::Composition
88
105
  beat :intro, at: 0, length: 45
89
106
 
90
107
  class Scene < AnimateIt::Scene
108
+ track_vars :root do
109
+ { title_opacity: at_act(:intro, [0, 30], [0, 1]) }
110
+ end
111
+
112
+ text_track(:counter) { (props[:counter_start] + local_frame).to_s }
113
+
91
114
  def body
92
- expose(vars: animation_vars(title_opacity: at_act(:intro, [0, 30], [0, 1])))
93
- tag.div(class: "hero-render-canvas") { render_scene_template("canvas") }
115
+ absolute_fill(vars: :root) do
116
+ safe_join([render_scene_template("canvas"), animate_text(:counter)])
117
+ end
94
118
  end
95
119
  end
96
120
 
@@ -102,14 +126,142 @@ end
102
126
  -# app/videos/hello_video/canvas.html.haml
103
127
  :css
104
128
  .title { opacity: var(--title-opacity, 1); font-size: 6rem; font-weight: 800; }
105
- .stage{style: @vars}
129
+ .stage
106
130
  .title Hello 👋
107
131
  ```
108
132
 
109
- Animation helpers (`at_global`, `at_act`, `beat_frame`, `beat_range`), a per-frame
110
- CSS-variable bag (`animation_vars`), named `beat`s, and `audio` / `audio_loop`
111
- tracks are all available. Compositions can render your app's real partials and
112
- ViewComponents — see `AnimateIt.config.render_stylesheets` below.
133
+ Animation helpers (`at_global`, `at_act`, `beat_frame`, `beat_range`), sampled
134
+ CSS variables (`track_vars`), dynamic text (`text_track`), named `beat`s, and
135
+ `audio` / `audio_loop` tracks are available. Compositions can render your app's
136
+ real partials and ViewComponents — see `AnimateIt.config.render_stylesheets`
137
+ below.
138
+
139
+ ### Browser playback runtime
140
+
141
+ `client_driven!` switches export and Studio playback to `/player`. Rails renders
142
+ each structural layer once and records a schema-v2 track document; then
143
+ `window.__animateIt.setFrame(frame)` updates CSS variables, text, layer
144
+ visibility, word reveals, and native CSS/Web Animations without another frame
145
+ request.
146
+
147
+ Use `structure_epochs` only when a template's DOM shape changes. Each epoch adds
148
+ one pre-rendered layer. Track schema v2 scopes bindings to their timeline segment;
149
+ the player still accepts v1 documents and rejects unknown versions.
150
+
151
+ ```ruby
152
+ structure_epochs 60, 120
153
+
154
+ class Scene < AnimateIt::Scene
155
+ track_vars(:card) { { x: "#{at_global([0, 30], [-20, 0])}px" } }
156
+ text_track(:score) { (progress * 100).round }
157
+
158
+ def body
159
+ absolute_fill(vars: :card) do
160
+ safe_join([render_scene_template("canvas"), animate_text(:score)])
161
+ end
162
+ end
163
+ end
164
+ ```
165
+
166
+ ### Production frontend playback
167
+
168
+ Public playback is opt-in per composition. `public_player!` enables the
169
+ standalone browser clock, Play/Pause control, looping, and synchronized
170
+ music/voice/SFX. It does not expose Studio, frame, filmstrip, props, or render
171
+ endpoints, and public playback always uses the composition's default props.
172
+
173
+ ```ruby
174
+ class HelloVideo < AnimateIt::Composition
175
+ id "hello"
176
+ public_player! autoplay: false, loop: true
177
+ beat :intro, at: 0, length: 45
178
+ beat :details, at: 45, length: 45
179
+ chapter :intro, beat: :intro, label: "Intro"
180
+ chapter :details, beat: :details, label: "Details"
181
+ # ...
182
+ end
183
+ ```
184
+
185
+ Mount the engine in every environment, then embed the allowlisted composition
186
+ from a host view:
187
+
188
+ ```ruby
189
+ # config/routes.rb
190
+ mount AnimateIt::Engine, at: AnimateIt.config.mount_path
191
+ ```
192
+
193
+ ```erb
194
+ <%= animate_it_player "hello", title: "Hello product demo" %>
195
+ ```
196
+
197
+ `animate_it_player` remains the low-level responsive iframe. For a complete
198
+ production embed with accessible chapter navigation, a poster-first handoff,
199
+ visibility playback, reduced-motion behavior, and offscreen pausing, use:
200
+
201
+ ```erb
202
+ <%= animate_it_embed(
203
+ "hello",
204
+ poster: image_path("hello.webp"),
205
+ variants: [
206
+ {
207
+ media: "(max-width: 767px)",
208
+ composition: "hello-mobile",
209
+ poster: image_path("hello-mobile.webp")
210
+ }
211
+ ],
212
+ navigation: { preset: :pills, mobile: :carousel },
213
+ load_when_visible: 0.25,
214
+ play_when_visible: 2.0 / 3
215
+ ) %>
216
+ ```
217
+
218
+ Responsive compositions may use different sizes and chapter frames, but must
219
+ declare the same ordered chapter names and labels. The embed swaps by media
220
+ query and restores the current chapter by name.
221
+
222
+ The pill rail is optional. Build cards, tabs, thumbnails, dots, or custom SVG
223
+ with the headless Rails builder:
224
+
225
+ ```erb
226
+ <%= animate_it_embed("hello", poster: image_path("hello.webp")) do |embed| %>
227
+ <%= embed.chapter_navigation(class: "product-demo-cards") do |chapter| %>
228
+ <%= chapter.button(class: "product-demo-card") do %>
229
+ <strong><%= chapter.label %></strong>
230
+ <% end %>
231
+ <% end %>
232
+ <% end %>
233
+ ```
234
+
235
+ Every control receives `data-chapter-state`, `data-chapter-position`, and the
236
+ normalized CSS variables `--animate-it-chapter-progress`,
237
+ `--animate-it-chapter-active`, and `--animate-it-chapter-complete`. The player
238
+ emits `animateit:ready`, `animateit:framechange`, `animateit:chapterchange`,
239
+ `animateit:play`, `animateit:pause`, `animateit:ended`, and `animateit:error`
240
+ events. Host commands use a source-checked same-origin message
241
+ boundary instead of reaching into iframe globals.
242
+
243
+ For direct player integrations, `window.AnimateItPlayer` exposes `play`,
244
+ `pause`, `toggle`, `seek`, `seekChapter`, `playing`, and `currentFrame`.
245
+ `window.AnimateItTransport` remains an alias for compatibility with 0.4.
246
+
247
+ To include the same chapter visualization in Studio and rendered media:
248
+
249
+ ```erb
250
+ <%= animate_it_chapter_navigation preset: :pills, hide_when_embedded: true %>
251
+ ```
252
+
253
+ The iframe still renders each structural layer once and advances entirely in
254
+ the browser. Audio-capable players fall back to the visible Play button when
255
+ the browser blocks autoplay.
256
+
257
+ ### Migrating a custom iframe controller
258
+
259
+ Replace application-owned iframe scaling, `IntersectionObserver`, poster
260
+ crossfade, breakpoint swapping, frame polling, and `contentWindow` transport
261
+ calls with `animate_it_embed`. Keep application CSS by overriding the documented
262
+ `--animate-it-*` tokens or render completely custom chapter controls through the
263
+ headless builder. Continue using `animate_it_player` when the application truly
264
+ needs to own the entire lifecycle.
113
265
 
114
266
  ### HAML or ERB
115
267
 
@@ -118,9 +270,9 @@ Scene sidecar templates can be authored in **HAML** (`canvas.html.haml`) or
118
270
  mix engines across scenes. The HAML canvas above is equivalent to:
119
271
 
120
272
  ```erb
121
- -# app/videos/hello_video/canvas.html.erb
273
+ <%# app/videos/hello_video/canvas.html.erb %>
122
274
  <style>.title { opacity: var(--title-opacity, 1); font-size: 6rem; font-weight: 800; }</style>
123
- <div class="stage" style="<%= @vars %>">
275
+ <div class="stage">
124
276
  <div class="title">Hello 👋</div>
125
277
  </div>
126
278
  ```
@@ -130,8 +282,9 @@ app to use it — ERB-only apps work out of the box.
130
282
 
131
283
  ## Rendering
132
284
 
133
- The renderer needs your Rails **server running** (it drives a real browser against
134
- the Studio's filmstrip endpoint).
285
+ The renderer needs your Rails **server running**. It drives a real browser
286
+ against `/player` for client-driven compositions and the legacy `/filmstrip`
287
+ endpoint for other compositions.
135
288
 
136
289
  ```bash
137
290
  # start the app
@@ -141,6 +294,10 @@ bin/rails server
141
294
  bin/rails 'animate_it:render[hello]'
142
295
  bin/rails 'animate_it:render[hello,0..45]' # just a frame range
143
296
 
297
+ # compare /player with the legacy /filmstrip (step 1 checks every frame)
298
+ bin/rails 'animate_it:verify[hello,1]'
299
+ bin/rails animate_it:verify_all
300
+
144
301
  # or the packaged CLI (writes to tmp/animate_it/ by default)
145
302
  bundle exec render_animate_it_video hello
146
303
  bundle exec render_animate_it_video hello tmp/hello.mp4
@@ -173,8 +330,42 @@ AnimateIt.configure do |config|
173
330
  end
174
331
  ```
175
332
 
176
- Render concurrency for the Studio's background renderer is tunable via
177
- `ANIMATE_IT_RENDER_CONCURRENCY` and `ANIMATE_IT_RENDER_STARTS_PER_SECOND`.
333
+ When one composition declares multiple formats for the same frame range,
334
+ AnimateIt captures the ordered Chromium frames once and reuses them for each
335
+ encoder. Studio rendering intentionally keeps one sequential capture stream so
336
+ frame ordering, progress, and cancellation stay deterministic.
337
+
338
+ ## Deterministic asset preflight
339
+
340
+ Host applications can record local composition inputs in
341
+ `config/animate_it_assets.yml`. `bin/rails animate_it:preflight` checks that each
342
+ file exists, matches its SHA-256 checksum, and has a provenance provider. Media
343
+ stays in the host app and is not packaged in the gem.
344
+
345
+ ```yaml
346
+ version: 1
347
+ assets:
348
+ - path: app/audio/launch/music.mp3
349
+ kind: music
350
+ compositions: [hello]
351
+ sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
352
+ provenance:
353
+ provider: elevenlabs
354
+ generated_with_ai: true
355
+ model_id: not-recorded
356
+ generation_id: not-recorded
357
+ ```
358
+
359
+ For parameterized compositions, declare `verification_props` or override them
360
+ at the command line:
361
+
362
+ ```bash
363
+ ANIMATE_IT_PROPS_JSON='{"title":"Variant"}' bin/rails 'animate_it:verify[hello,1]'
364
+ ANIMATE_IT_PROPS_MATRIX_JSON='[{}, {"title":"Variant"}]' bin/rails animate_it:verify_all
365
+ ```
366
+
367
+ Verification requires a running server and writes comparison screenshots under
368
+ `tmp/animate_it/verify`.
178
369
 
179
370
  ## Claude skill
180
371
 
@@ -195,6 +386,7 @@ The skill source lives in [`skills/`](skills/).
195
386
  bin/setup # bundle install
196
387
  bundle exec rspec # unit + request specs (uses spec/dummy)
197
388
  bundle exec rubocop
389
+ gem build animate_it.gemspec
198
390
 
199
391
  # full render smoke test (needs ffmpeg + Playwright chromium)
200
392
  RUN_RENDER_SMOKE=1 bundle exec rspec spec/rendering_spec.rb
@@ -0,0 +1,25 @@
1
+ module AnimateIt
2
+ class EmbedAssetsController < ApplicationController
3
+ layout false
4
+ skip_before_action :ensure_local_environment
5
+ skip_forgery_protection
6
+
7
+ def javascript
8
+ serve_asset(EmbedRuntime.javascript, "application/javascript")
9
+ end
10
+
11
+ def stylesheet
12
+ serve_asset(EmbedRuntime.stylesheet, "text/css")
13
+ end
14
+
15
+ private
16
+
17
+ def serve_asset(source, content_type)
18
+ return head :not_found unless params[:version] == AnimateIt::VERSION
19
+
20
+ expires_in 1.year, public: true, immutable: true
21
+ response.set_header("X-Content-Type-Options", "nosniff")
22
+ render plain: source, content_type:
23
+ end
24
+ end
25
+ end
@@ -18,5 +18,15 @@ 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
+ @player_manifest = @composition.player_manifest
28
+ @embedded_player = false
29
+ @host_navigation = false
30
+ end
21
31
  end
22
32
  end
@@ -0,0 +1,73 @@
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
+ @player_manifest = composition.player_manifest
17
+ @audio_segments = audio_segments
18
+ @public_player = true
19
+ @embedded_player = params[:embedded] == "1"
20
+ @host_navigation = params[:host_navigation] == "1"
21
+ @public_player_options = composition.public_player_options.merge(autoplay: false) if @embedded_player
22
+ @public_player_options ||= composition.public_player_options
23
+ render "animate_it/frames/player"
24
+ end
25
+
26
+ def audio
27
+ segment = audio_segments[params[:index].to_i]
28
+ return head :not_found unless segment
29
+
30
+ file_path = resolve_audio_path(segment.source[:path])
31
+ return head :not_found unless file_path
32
+
33
+ serve_with_byte_ranges(file_path)
34
+ end
35
+
36
+ private
37
+
38
+ def ensure_public_player
39
+ head :not_found unless composition.public_player? && composition.client_driven?
40
+ end
41
+
42
+ def audio_segments
43
+ @audio_segments ||= composition.timeline.segments.select { |segment| segment.kind == :audio }
44
+ end
45
+
46
+ def public_audio_path(index)
47
+ animate_it_path("/public/compositions/#{composition.id}/audio/#{index}")
48
+ end
49
+ helper_method :public_audio_path
50
+
51
+ def serve_with_byte_ranges(file_path)
52
+ status, headers, body = Rack::Files.new(file_path.dirname.to_s).serving(request, file_path.to_s)
53
+ headers["content-type"] = Mime::Type.lookup_by_extension(file_path.extname.delete(".")).to_s
54
+ headers["content-disposition"] = "inline"
55
+ headers["accept-ranges"] = "bytes"
56
+ self.status = status
57
+ self.response_body = body
58
+ headers.each { |key, value| response.headers[key] = value }
59
+ end
60
+
61
+ def resolve_audio_path(path)
62
+ return if path.blank?
63
+
64
+ base = AUDIO_BASE.realpath
65
+ candidate = Pathname.new(path)
66
+ candidate = base.join(candidate) unless candidate.absolute?
67
+ resolved = candidate.realpath
68
+ resolved if resolved.to_s.start_with?("#{base}#{File::SEPARATOR}")
69
+ rescue Errno::ENOENT, Errno::EACCES
70
+ nil
71
+ end
72
+ end
73
+ 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, render_options)
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 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)
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, options)
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 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;
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
  })();