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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72d2eaa2997ddbdf969a2f9abaed9632439891facecc856352a9f24228895d58
4
- data.tar.gz: 78e5d03556d7af69c22179095ef5ffd3d6c87622797e5ff6b114e87ee4411fe5
3
+ metadata.gz: 7ec5ae62f97bd3388e29dfc63ea1b7580a233973e2201873cbbed0ee7e9234ab
4
+ data.tar.gz: 7af5a47d46d966a785d89d13d0fc31dfa2a83945966481c6209beacb286c460d
5
5
  SHA512:
6
- metadata.gz: 5031e03befe521c448ed4a68f4a7ef1774e5609286b6ee1f6f94ca5daf719c74a2adf852b79c18b8d2d297ad91572f7d7300077e1c7641eb46d2e5fefa8fa4ca
7
- data.tar.gz: 29ce4f2eb2d61720a38815190ed173cd8f16bfde8b5c07e32fc5c7c42bfafc3e7b4c06121a574ff7941fe542ac5dc3a528166b486279e7e2d9a6a92f5350904c
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.4.0...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
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/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
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
- 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.
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
- @public_player_options = composition.public_player_options
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: