animate_it 0.4.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +84 -4
- data/app/controllers/animate_it/embed_assets_controller.rb +25 -0
- data/app/controllers/animate_it/frames_controller.rb +3 -0
- data/app/controllers/animate_it/public_players_controller.rb +5 -1
- data/app/views/animate_it/frames/player.html.haml +12 -1
- data/config/routes.rb +5 -0
- data/lib/animate_it/chapter_navigation.rb +160 -0
- data/lib/animate_it/chapters.rb +95 -0
- data/lib/animate_it/composition.rb +15 -0
- data/lib/animate_it/embed_helper.rb +200 -8
- data/lib/animate_it/embed_runtime/embed.js +338 -0
- data/lib/animate_it/embed_runtime.rb +13 -0
- data/lib/animate_it/embed_styles.rb +126 -0
- data/lib/animate_it/engine.rb +4 -1
- data/lib/animate_it/player_manifest.rb +29 -0
- data/lib/animate_it/runtime/runtime.js +138 -5
- data/lib/animate_it/version.rb +1 -1
- data/lib/animate_it.rb +8 -0
- metadata +13 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ec5ae62f97bd3388e29dfc63ea1b7580a233973e2201873cbbed0ee7e9234ab
|
|
4
|
+
data.tar.gz: 7af5a47d46d966a785d89d13d0fc31dfa2a83945966481c6209beacb286c460d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49f073e22fe939dd1223d91bce55a364da6923e9ad458580f98f4369d618b4d43dd7cb981c5925699ea1a6bab72834482caadfb14a5bf115182358d054926ba3
|
|
7
|
+
data.tar.gz: 2c066b3b7a44093330743a0f4e88a0fdc309388eed285d67dfcb4b65554c0af7722df1a5581fd112ec29eae277f184b33d4f36462c8a0fe62f6e4f129e0a764c
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ 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
|
+
|
|
9
36
|
## [0.4.0] - 2026-08-01
|
|
10
37
|
|
|
11
38
|
### Added
|
|
@@ -96,7 +123,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
96
123
|
- `render_animate_it_video` executable and `animate_it:render` rake task.
|
|
97
124
|
- `animate_it:install` generator.
|
|
98
125
|
|
|
99
|
-
[Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.
|
|
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
|
|
100
128
|
[0.4.0]: https://github.com/joinbuildit/animate_it/compare/v0.3.2...v0.4.0
|
|
101
129
|
[0.3.2]: https://github.com/joinbuildit/animate_it/compare/v0.3.1...v0.3.2
|
|
102
130
|
[0.3.1]: https://github.com/joinbuildit/animate_it/compare/v0.3.0...v0.3.1
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
|
-
<a href="https://github.com/
|
|
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
16
|
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D.svg" alt="Ruby >= 3.3">
|
|
17
17
|
</p>
|
|
@@ -32,6 +32,17 @@ It's built for indie hackers and Rails developers who want to promote their proj
|
|
|
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)
|
|
@@ -163,6 +174,10 @@ endpoints, and public playback always uses the composition's default props.
|
|
|
163
174
|
class HelloVideo < AnimateIt::Composition
|
|
164
175
|
id "hello"
|
|
165
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"
|
|
166
181
|
# ...
|
|
167
182
|
end
|
|
168
183
|
```
|
|
@@ -179,9 +194,74 @@ mount AnimateIt::Engine, at: AnimateIt.config.mount_path
|
|
|
179
194
|
<%= animate_it_player "hello", title: "Hello product demo" %>
|
|
180
195
|
```
|
|
181
196
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
185
265
|
|
|
186
266
|
### HAML or ERB
|
|
187
267
|
|
|
@@ -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
|
|
@@ -24,6 +24,9 @@ module AnimateIt
|
|
|
24
24
|
@props = preview_props
|
|
25
25
|
@track_document = @composition.track_document(props: @props)
|
|
26
26
|
TrackDocumentSchema.validate!(@track_document)
|
|
27
|
+
@player_manifest = @composition.player_manifest
|
|
28
|
+
@embedded_player = false
|
|
29
|
+
@host_navigation = false
|
|
27
30
|
end
|
|
28
31
|
end
|
|
29
32
|
end
|
|
@@ -13,9 +13,13 @@ module AnimateIt
|
|
|
13
13
|
@props = {}
|
|
14
14
|
@track_document = composition.track_document
|
|
15
15
|
TrackDocumentSchema.validate!(@track_document)
|
|
16
|
+
@player_manifest = composition.player_manifest
|
|
16
17
|
@audio_segments = audio_segments
|
|
17
18
|
@public_player = true
|
|
18
|
-
@
|
|
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
|
|
19
23
|
render "animate_it/frames/player"
|
|
20
24
|
end
|
|
21
25
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
!!!
|
|
2
|
-
%html{ lang: "en"
|
|
2
|
+
%html{ lang: "en", data: {
|
|
3
|
+
animate_it_embedded: @embedded_player ? "true" : "false",
|
|
4
|
+
animate_it_host_navigation: @host_navigation ? "true" : "false"
|
|
5
|
+
} }
|
|
3
6
|
%head
|
|
4
7
|
%title= "#{@composition.id} player (#{@composition.duration_in_frames} frames)"
|
|
5
8
|
%meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
|
|
@@ -52,6 +55,12 @@
|
|
|
52
55
|
font: 600 14px/1 system-ui, sans-serif;
|
|
53
56
|
cursor: pointer;
|
|
54
57
|
}
|
|
58
|
+
html[data-animate-it-host-navigation="true"] [data-animate-it-hide-when-embedded="true"] {
|
|
59
|
+
display: none !important;
|
|
60
|
+
}
|
|
61
|
+
html[data-animate-it-embedded="true"] .animate-it-public-play {
|
|
62
|
+
display: none !important;
|
|
63
|
+
}
|
|
55
64
|
%body
|
|
56
65
|
.animate-it-stage
|
|
57
66
|
= @composition.render_structure(self, props: @props)
|
|
@@ -78,4 +87,6 @@
|
|
|
78
87
|
animate_it_loop: @public_player_options&.fetch(:loop, true) ? "true" : "false"
|
|
79
88
|
} }
|
|
80
89
|
!= ERB::Util.json_escape(@track_document.to_json)
|
|
90
|
+
%script{ type: "application/json", data: { animate_it_manifest: true } }
|
|
91
|
+
!= ERB::Util.json_escape(@player_manifest.as_json.to_json)
|
|
81
92
|
= javascript_tag AnimateIt::Runtime.source.html_safe
|
data/config/routes.rb
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
AnimateIt::Engine.routes.draw do
|
|
2
|
+
get "assets/:version/embed.js", to: "embed_assets#javascript", as: :embed_javascript,
|
|
3
|
+
constraints: { version: /[0-9A-Za-z._-]+/ }
|
|
4
|
+
get "assets/:version/embed.css", to: "embed_assets#stylesheet", as: :embed_stylesheet,
|
|
5
|
+
constraints: { version: /[0-9A-Za-z._-]+/ }
|
|
6
|
+
|
|
2
7
|
root "studio#index"
|
|
3
8
|
|
|
4
9
|
get "public/compositions/:id/player", to: "public_players#show", as: :public_composition_player
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
class ChapterPresenter
|
|
3
|
+
attr_reader :chapter
|
|
4
|
+
|
|
5
|
+
delegate :name, :label, :start_frame, :duration_frames, :end_frame, :metadata, to: :chapter
|
|
6
|
+
|
|
7
|
+
def initialize(view, chapter, interactive:, state:)
|
|
8
|
+
@view = view
|
|
9
|
+
@chapter = chapter
|
|
10
|
+
@interactive = interactive
|
|
11
|
+
@state = state
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def thumbnail
|
|
15
|
+
metadata[:thumbnail] || metadata["thumbnail"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def button(content = nil, **attributes, &block)
|
|
19
|
+
body = block ? @view.capture(self, &block) : (content || label)
|
|
20
|
+
classes = ["animate-it-chapter", attributes.delete(:class)].compact.join(" ")
|
|
21
|
+
attributes, data = stateful_attributes(attributes)
|
|
22
|
+
attributes[:type] ||= "button"
|
|
23
|
+
attributes[:"aria-label"] ||= "Jump to #{label}"
|
|
24
|
+
@view.tag.button(body, **attributes, class: classes, data:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def element(content = nil, tag: :div, **attributes, &block)
|
|
28
|
+
body = block ? @view.capture(self, &block) : (content || label)
|
|
29
|
+
classes = ["animate-it-chapter", attributes.delete(:class)].compact.join(" ")
|
|
30
|
+
attributes, data = stateful_attributes(attributes)
|
|
31
|
+
@view.tag.public_send(tag, body, **attributes, class: classes, data:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def default_control(preset: :pills)
|
|
35
|
+
progress = @view.tag.svg(
|
|
36
|
+
@view.tag.rect(x: 1, y: 1, width: 98, height: 38, rx: 19, pathLength: 1),
|
|
37
|
+
class: "animate-it-chapter__progress", viewBox: "0 0 100 40", preserveAspectRatio: "none", aria: { hidden: true }
|
|
38
|
+
)
|
|
39
|
+
body = @view.safe_join([progress, @view.tag.span(label, class: "animate-it-chapter__label")])
|
|
40
|
+
classes = "animate-it-chapter animate-it-chapter--#{preset}"
|
|
41
|
+
if @interactive
|
|
42
|
+
button(body, class: classes.delete_prefix("animate-it-chapter "))
|
|
43
|
+
else
|
|
44
|
+
element(body, class: classes.delete_prefix("animate-it-chapter "))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def stateful_attributes(attributes)
|
|
51
|
+
attributes = attributes.dup
|
|
52
|
+
data = (attributes.delete(:data) || {}).merge(
|
|
53
|
+
animate_it_chapter: name,
|
|
54
|
+
chapter_state: @state.fetch(:state),
|
|
55
|
+
chapter_position: @state.fetch(:position)
|
|
56
|
+
)
|
|
57
|
+
variables = [
|
|
58
|
+
"--animate-it-chapter-progress: #{@state.fetch(:progress)}",
|
|
59
|
+
"--animate-it-chapter-active: #{@state.fetch(:active)}",
|
|
60
|
+
"--animate-it-chapter-complete: #{@state.fetch(:complete)}",
|
|
61
|
+
attributes.delete(:style)
|
|
62
|
+
].compact.join(";")
|
|
63
|
+
attributes[:style] = variables
|
|
64
|
+
attributes[:"aria-current"] = "step" if @interactive && @state.fetch(:state) == "current"
|
|
65
|
+
[attributes, data]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class ChapterNavigationBuilder
|
|
70
|
+
def initialize(view, composition, interactive:, preset: :pills, mobile: nil, frame: 0)
|
|
71
|
+
@view = view
|
|
72
|
+
@composition = composition
|
|
73
|
+
@interactive = interactive
|
|
74
|
+
@preset = preset&.to_sym
|
|
75
|
+
@mobile = mobile&.to_sym
|
|
76
|
+
@frame = frame.to_i
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render(**attributes, &block)
|
|
80
|
+
current_index = current_chapter_index
|
|
81
|
+
chapters = @composition.chapters.each_with_index.map do |chapter, index|
|
|
82
|
+
presenter = ChapterPresenter.new(@view, chapter, interactive: @interactive, state: chapter_state(chapter, index, current_index))
|
|
83
|
+
block ? @view.capture(presenter, &block) : presenter.default_control(preset: @preset || :pills)
|
|
84
|
+
end
|
|
85
|
+
classes = ["animate-it-chapters", @preset && "animate-it-chapters--#{@preset}",
|
|
86
|
+
@mobile && "animate-it-chapters--mobile-#{@mobile}", attributes.delete(:class)].compact.join(" ")
|
|
87
|
+
data = (attributes.delete(:data) || {}).merge(animate_it_chapter_navigation: true)
|
|
88
|
+
attributes[:"aria-label"] ||= "Animation chapters" if @interactive
|
|
89
|
+
tag = @interactive ? :nav : :div
|
|
90
|
+
@view.tag.public_send(tag, @view.safe_join(chapters), **attributes, class: classes, data:)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def current_chapter_index
|
|
96
|
+
@composition.chapters.to_a.rindex { |chapter| @frame >= chapter.start_frame } || -1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def chapter_state(chapter, index, current_index)
|
|
100
|
+
state = if current_index.negative? || index > current_index
|
|
101
|
+
"upcoming"
|
|
102
|
+
elsif index < current_index
|
|
103
|
+
"completed"
|
|
104
|
+
else
|
|
105
|
+
"current"
|
|
106
|
+
end
|
|
107
|
+
progress = if state == "completed"
|
|
108
|
+
1.0
|
|
109
|
+
elsif state == "current"
|
|
110
|
+
chapter_progress(chapter)
|
|
111
|
+
else
|
|
112
|
+
0.0
|
|
113
|
+
end
|
|
114
|
+
{
|
|
115
|
+
state:,
|
|
116
|
+
position: chapter_position(index, current_index),
|
|
117
|
+
progress:,
|
|
118
|
+
active: state == "current" ? 1 : 0,
|
|
119
|
+
complete: state == "completed" ? 1 : 0
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def chapter_progress(chapter)
|
|
124
|
+
return 1.0 if chapter.duration_frames == 1
|
|
125
|
+
|
|
126
|
+
(@frame - chapter.start_frame).fdiv(chapter.duration_frames - 1).clamp(0, 1)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def chapter_position(index, current_index)
|
|
130
|
+
return "hidden" if current_index.negative?
|
|
131
|
+
return "current" if index == current_index
|
|
132
|
+
return "previous" if index == current_index - 1
|
|
133
|
+
return "next" if index == current_index + 1
|
|
134
|
+
|
|
135
|
+
"hidden"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
module ChapterNavigationHelper
|
|
140
|
+
def animate_it_chapter_navigation(
|
|
141
|
+
composition: nil, preset: nil, mobile: nil, hide_when_embedded: false, frame: nil, **attributes, &
|
|
142
|
+
)
|
|
143
|
+
target = composition || instance_variable_get(:@composition)
|
|
144
|
+
raise ArgumentError, "animate_it_chapter_navigation requires a composition" unless target
|
|
145
|
+
|
|
146
|
+
target.chapters.validate!
|
|
147
|
+
data = (attributes.delete(:data) || {}).merge(animate_it_hide_when_embedded: hide_when_embedded ? "true" : "false")
|
|
148
|
+
attributes[:style] = ["--animate-it-chapter-count: #{target.chapters.count}", attributes[:style]].compact.join(";")
|
|
149
|
+
builder = ChapterNavigationBuilder.new(
|
|
150
|
+
self, target, interactive: false, preset:, mobile:, frame: frame || instance_variable_get(:@frame) || 0
|
|
151
|
+
)
|
|
152
|
+
safe_join(
|
|
153
|
+
[
|
|
154
|
+
tag.style(AnimateIt::EmbedStyles.chapter_source.html_safe, data: { animate_it_chapter_styles: true }),
|
|
155
|
+
builder.render(**attributes, data:, &)
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AnimateIt
|
|
4
|
+
Chapter = Data.define(:name, :label, :beat_name, :start_frame, :duration_frames, :metadata) do
|
|
5
|
+
def end_frame
|
|
6
|
+
start_frame + duration_frames
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def as_json(*)
|
|
10
|
+
{
|
|
11
|
+
"name" => name.to_s,
|
|
12
|
+
"label" => label,
|
|
13
|
+
"beat" => beat_name.to_s,
|
|
14
|
+
"startFrame" => start_frame,
|
|
15
|
+
"durationFrames" => duration_frames,
|
|
16
|
+
"endFrame" => end_frame,
|
|
17
|
+
"metadata" => metadata
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class Chapters
|
|
23
|
+
include Enumerable
|
|
24
|
+
|
|
25
|
+
def initialize(composition)
|
|
26
|
+
@composition = composition
|
|
27
|
+
@chapters = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add(name, beat:, label:, metadata: {})
|
|
31
|
+
key = name.to_sym
|
|
32
|
+
raise ArgumentError, "AnimateIt chapter names must be unique: #{name.inspect}" if @chapters.any? { |chapter| chapter.name == key }
|
|
33
|
+
raise ArgumentError, "AnimateIt chapter labels must not be blank" if label.to_s.strip.empty?
|
|
34
|
+
raise ArgumentError, "AnimateIt chapter metadata must be a hash" unless metadata.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
JSON.generate(metadata)
|
|
37
|
+
|
|
38
|
+
beat_record = @composition.beats.fetch(beat)
|
|
39
|
+
chapter = Chapter.new(
|
|
40
|
+
name: key,
|
|
41
|
+
label: label.to_s,
|
|
42
|
+
beat_name: beat_record.name,
|
|
43
|
+
start_frame: beat_record.start_frame,
|
|
44
|
+
duration_frames: beat_record.duration_frames,
|
|
45
|
+
metadata: metadata.freeze
|
|
46
|
+
)
|
|
47
|
+
validate_after!(@chapters.last, chapter)
|
|
48
|
+
validate_bounds!(chapter)
|
|
49
|
+
@chapters << chapter
|
|
50
|
+
chapter
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fetch(name)
|
|
54
|
+
@chapters.find { |chapter| chapter.name == name.to_sym } ||
|
|
55
|
+
raise(Error, "Unknown chapter: #{name.inspect}. Declared: #{@chapters.map(&:name).inspect}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def each(&)
|
|
59
|
+
@chapters.each(&)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
delegate :empty?, to: :@chapters
|
|
63
|
+
|
|
64
|
+
def as_json(*)
|
|
65
|
+
validate!
|
|
66
|
+
@chapters.map(&:as_json)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate!
|
|
70
|
+
@chapters.each_with_index do |chapter, index|
|
|
71
|
+
validate_after!(@chapters[index - 1], chapter) if index.positive?
|
|
72
|
+
validate_bounds!(chapter)
|
|
73
|
+
end
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def validate_after!(previous, chapter)
|
|
80
|
+
return unless previous
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "AnimateIt chapters must have strictly increasing start frames" if chapter.start_frame <= previous.start_frame
|
|
83
|
+
return if chapter.start_frame >= previous.end_frame
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "AnimateIt chapters must not overlap: #{previous.name.inspect} and #{chapter.name.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_bounds!(chapter)
|
|
89
|
+
return if chapter.start_frame >= 0 && chapter.duration_frames.positive? && chapter.end_frame <= @composition.duration_in_frames
|
|
90
|
+
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"AnimateIt chapter #{chapter.name.inspect} must fit within 0...#{@composition.duration_in_frames} frames"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -16,6 +16,7 @@ module AnimateIt
|
|
|
16
16
|
subclass.instance_variable_set(:@output_format, :webm)
|
|
17
17
|
subclass.instance_variable_set(:@verification_props, [{}].freeze)
|
|
18
18
|
subclass.instance_variable_set(:@public_player_options, nil)
|
|
19
|
+
subclass.instance_variable_set(:@chapters, Chapters.new(subclass))
|
|
19
20
|
super
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -191,6 +192,20 @@ module AnimateIt
|
|
|
191
192
|
@beats ||= Beats.new(fps: fps)
|
|
192
193
|
end
|
|
193
194
|
|
|
195
|
+
# Public, user-navigable moments. Chapters reference existing beats so
|
|
196
|
+
# animation timing stays the single source of truth.
|
|
197
|
+
def chapter(name, beat:, label:, metadata: {})
|
|
198
|
+
chapters.add(name, beat:, label:, metadata:)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def chapters
|
|
202
|
+
@chapters ||= Chapters.new(self)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def player_manifest
|
|
206
|
+
PlayerManifest.new(self)
|
|
207
|
+
end
|
|
208
|
+
|
|
194
209
|
# ----- Outputs path helpers --------------------------------------
|
|
195
210
|
# Declare a directory + basename so `outputs do ... end` entries can
|
|
196
211
|
# be specified by format alone:
|