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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +131 -19
  4. data/app/controllers/animate_it/frames_controller.rb +7 -0
  5. data/app/controllers/animate_it/public_players_controller.rb +69 -0
  6. data/app/controllers/animate_it/renders_controller.rb +3 -17
  7. data/app/controllers/animate_it/studio_controller.rb +1 -0
  8. data/app/jobs/animate_it/render_job.rb +2 -0
  9. data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
  10. data/app/views/animate_it/frames/player.html.haml +81 -0
  11. data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
  12. data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
  13. data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
  14. data/app/views/animate_it/studio/show.html.haml +10 -3
  15. data/config/routes.rb +5 -0
  16. data/lib/animate_it/asset_manifest.rb +92 -0
  17. data/lib/animate_it/asset_renderer.rb +39 -7
  18. data/lib/animate_it/composition.rb +100 -9
  19. data/lib/animate_it/embed_helper.rb +22 -0
  20. data/lib/animate_it/engine.rb +4 -0
  21. data/lib/animate_it/runtime/runtime.js +385 -0
  22. data/lib/animate_it/runtime.rb +11 -0
  23. data/lib/animate_it/scene.rb +57 -3
  24. data/lib/animate_it/text_effects.rb +104 -0
  25. data/lib/animate_it/track_document_schema.rb +71 -0
  26. data/lib/animate_it/tracks/document.rb +100 -0
  27. data/lib/animate_it/tracks/layer.rb +13 -0
  28. data/lib/animate_it/tracks/recorder.rb +149 -0
  29. data/lib/animate_it/verification.rb +187 -0
  30. data/lib/animate_it/version.rb +1 -1
  31. data/lib/animate_it/video_renderer.rb +89 -26
  32. data/lib/animate_it/view_helpers.rb +11 -1
  33. data/lib/animate_it.rb +9 -0
  34. data/lib/tasks/animate_it_tasks.rake +133 -0
  35. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0675f1e382659072fd53a4b9453d2a44b1bf8a208075bc43974522195540df1f
4
- data.tar.gz: c3804ff10fa75939fd23716e555054b64ea0ba45f1eaa9beb36c8b23e5b7c025
3
+ metadata.gz: 72d2eaa2997ddbdf969a2f9abaed9632439891facecc856352a9f24228895d58
4
+ data.tar.gz: 78e5d03556d7af69c22179095ef5ffd3d6c87622797e5ff6b114e87ee4411fe5
5
5
  SHA512:
6
- metadata.gz: 06fe263c48072dc834609a78c5f3f3f09a2897872740e11d54fd8edcf2dcd986113ae1338ffd941f491077de0c4a3feb100cda5b679ef161052c8cbe123a55f3
7
- data.tar.gz: d2f7f86b39e2aa3308dc98e9e6cd5bc4956716e03e0e81cc2a5857892ac9f1dea584634b13f3632450561285f361dbb3491964ac58a10296118e2a1375499b26
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.3.2...HEAD
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.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,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 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
@@ -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
- expose(vars: animation_vars(title_opacity: at_act(:intro, [0, 30], [0, 1])))
93
- tag.div(class: "hero-render-canvas") { render_scene_template("canvas") }
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{style: @vars}
118
+ .stage
106
119
  .title Hello 👋
107
120
  ```
108
121
 
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.
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
- -# app/videos/hello_video/canvas.html.erb
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" style="<%= @vars %>">
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** (it drives a real browser against
134
- the Studio's filmstrip endpoint).
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
- Render concurrency for the Studio's background renderer is tunable via
177
- `ANIMATE_IT_RENDER_CONCURRENCY` and `ANIMATE_IT_RENDER_STARTS_PER_SECOND`.
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, 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
  })();
@@ -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
- = turbo_stream_from AnimateIt::RenderStore::CHANNEL
13
- %p.animate-it-stream-status Turbo Stream connected for render progress.
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