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
@@ -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
@@ -14,6 +14,9 @@ module AnimateIt
14
14
  subclass.instance_variable_set(:@zoom, 1.0)
15
15
  subclass.instance_variable_set(:@duration_in_frames, 30)
16
16
  subclass.instance_variable_set(:@output_format, :webm)
17
+ subclass.instance_variable_set(:@verification_props, [{}].freeze)
18
+ subclass.instance_variable_set(:@public_player_options, nil)
19
+ subclass.instance_variable_set(:@chapters, Chapters.new(subclass))
17
20
  super
18
21
  end
19
22
 
@@ -65,6 +68,81 @@ module AnimateIt
65
68
  AnimateIt.register(self)
66
69
  end
67
70
 
71
+ # Opt in to the seekable browser runtime. Legacy compositions continue
72
+ # rendering individual frames on the server.
73
+ def client_driven!
74
+ @client_driven = true
75
+ end
76
+
77
+ def client_driven?
78
+ @client_driven == true
79
+ end
80
+
81
+ # Explicitly expose this composition through the production-safe public
82
+ # player endpoint. Studio, frame, filmstrip, props, and render endpoints
83
+ # remain local-only. Public playback always uses schema-default props.
84
+ def public_player!(autoplay: false, loop: true)
85
+ client_driven!
86
+ @public_player_options = { autoplay: autoplay == true, loop: loop == true }.freeze
87
+ end
88
+
89
+ def public_player?
90
+ @public_player_options.present?
91
+ end
92
+
93
+ def public_player_options
94
+ @public_player_options || { autoplay: false, loop: true }.freeze
95
+ end
96
+
97
+ def verification_props(*variants)
98
+ return @verification_props if variants.empty?
99
+
100
+ raise ArgumentError, "verification_props entries must be hashes" unless variants.all?(Hash)
101
+
102
+ @verification_props = variants.map(&:deep_symbolize_keys).freeze
103
+ end
104
+
105
+ def structure_epochs(*frames)
106
+ return @structure_epochs || [] if frames.empty?
107
+
108
+ @structure_epochs = frames.map(&:to_i).sort.uniq
109
+ end
110
+
111
+ def structure_layers
112
+ timeline.segments.each_with_index.flat_map do |segment, segment_index|
113
+ next [] unless segment.kind == :scene
114
+
115
+ segment_end = segment.duration_frames ? segment.from_frame + segment.duration_frames : duration_in_frames
116
+ epochs = structure_epochs.select { |frame| frame > segment.from_frame && frame < segment_end }
117
+ bounds = ([segment.from_frame] + epochs).sort.uniq
118
+ bounds.each_with_index.map do |from_frame, index|
119
+ Tracks::Layer.new(
120
+ segment:,
121
+ segment_index:,
122
+ from_frame:,
123
+ to_frame: bounds[index + 1] || segment_end
124
+ )
125
+ end
126
+ end
127
+ end
128
+
129
+ def render_structure(view_context, props: {})
130
+ resolved_props = props_schema.resolve(props)
131
+ rendered_layers = structure_layers.map do |layer|
132
+ context = frame_context(frame: layer.from_frame, props: resolved_props, segment: layer.segment)
133
+ view_context.tag.div(
134
+ render_segment(view_context, layer.segment, context),
135
+ class: "animate-it-layer",
136
+ data: { animate_layer: layer.key }
137
+ )
138
+ end
139
+ view_context.safe_join(rendered_layers)
140
+ end
141
+
142
+ def track_document(props: {})
143
+ Tracks::Recorder.new(self, props:).call
144
+ end
145
+
68
146
  def fps(value = nil)
69
147
  return @fps if value.nil?
70
148
 
@@ -114,6 +192,20 @@ module AnimateIt
114
192
  @beats ||= Beats.new(fps: fps)
115
193
  end
116
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
+
117
209
  # ----- Outputs path helpers --------------------------------------
118
210
  # Declare a directory + basename so `outputs do ... end` entries can
119
211
  # be specified by format alone:
@@ -140,12 +232,16 @@ module AnimateIt
140
232
  # ----- Audio DSL --------------------------------------------------
141
233
  # Loop a track for the full duration (or a slice). Background music.
142
234
  def audio_loop(path, duration: nil, from: 0, name: nil, gain: 1.0, track: :background)
143
- # Simplest implementation: place the same source once at `from`
144
- # for the requested length. The renderer's audio mixer handles
145
- # truncation if the underlying file is longer than the segment.
146
235
  length = duration || (@duration_in_frames - Units.frames(from, fps: fps))
147
- audio(path, duration: length, from: from, name: name || "loop-#{File.basename(path.to_s)}", track: track,
148
- gain: gain)
236
+ audio(
237
+ path,
238
+ duration: length,
239
+ from:,
240
+ name: name || "loop-#{File.basename(path.to_s)}",
241
+ track:,
242
+ gain:,
243
+ loop: true
244
+ )
149
245
  end
150
246
 
151
247
  # Play a voice-over clip at a named beat (or time/frame). The clip's
@@ -212,14 +308,21 @@ module AnimateIt
212
308
  # Place an audio segment on its own track. The renderer doesn't draw
213
309
  # audio; the studio plays it via <audio> tags synced to the scrubber, and
214
310
  # ffmpeg muxes it into the final encode.
215
- def audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0)
311
+ def audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0, loop: false)
312
+ begin
313
+ gain = Float(gain)
314
+ rescue TypeError, ArgumentError
315
+ raise ArgumentError, "Audio gain must be a number between 0.0 and 1.0"
316
+ end
317
+ raise ArgumentError, "Audio gain must be between 0.0 and 1.0" unless gain.between?(0.0, 1.0)
318
+
216
319
  timeline.add_segment(
217
320
  name: name || File.basename(path.to_s),
218
321
  from_frame: Units.frames(start_at || from, fps:),
219
322
  duration_frames: Units.frames(duration, fps:),
220
323
  kind: :audio,
221
324
  track:,
222
- source: { path: path.to_s, gain: gain.to_f },
325
+ source: { path: path.to_s, gain:, loop: },
223
326
  show_in_timeline: true
224
327
  )
225
328
  end
@@ -246,14 +349,17 @@ module AnimateIt
246
349
  )
247
350
  end
248
351
 
249
- def render_frame(view_context, frame:, props: {})
352
+ def render_frame(view_context, frame:, props: {}, segment_origins: false)
250
353
  resolved_props = props_schema.resolve(props)
251
354
  segments = timeline.active_segments(frame.to_i, kind: :scene)
252
355
  return "" if segments.empty?
253
356
 
254
357
  rendered_segments = segments.map do |segment|
255
358
  context = frame_context(frame:, props: resolved_props, segment:)
256
- render_segment(view_context, segment, context)
359
+ content = render_segment(view_context, segment, context)
360
+ next content unless segment_origins
361
+
362
+ view_context.tag.div(content, data: { animate_it_segment_origin: segment.from_frame })
257
363
  end
258
364
  view_context.safe_join(rendered_segments)
259
365
  end
@@ -0,0 +1,214 @@
1
+ require "json"
2
+
3
+ module AnimateIt
4
+ class EmbedBuilder
5
+ attr_reader :composition
6
+
7
+ def initialize(view, composition, navigation: {})
8
+ @view = view
9
+ @composition = composition
10
+ @navigation = navigation
11
+ end
12
+
13
+ def chapter_navigation(**attributes, &block)
14
+ custom = block.present?
15
+ preset = if attributes.key?(:preset)
16
+ attributes.delete(:preset)
17
+ elsif !custom
18
+ @navigation.fetch(:preset, :pills)
19
+ end
20
+ mobile = if attributes.key?(:mobile)
21
+ attributes.delete(:mobile)
22
+ elsif !custom
23
+ @navigation[:mobile]
24
+ end
25
+ style = ["--animate-it-chapter-count: #{@composition.chapters.count}", attributes.delete(:style)].compact.join(";")
26
+ ChapterNavigationBuilder.new(@view, @composition, interactive: true, preset:, mobile:, frame: 0)
27
+ .render(**attributes, style:, &block)
28
+ end
29
+ end
30
+
31
+ module EmbedHelper
32
+ def animate_it_player(composition_id, title: nil, **attributes)
33
+ composition = public_animate_it_composition!(composition_id)
34
+ defaults = {
35
+ src: animate_it_public_player_path(composition),
36
+ title: title || composition.id,
37
+ loading: "lazy",
38
+ allow: "autoplay; fullscreen",
39
+ allowfullscreen: true,
40
+ style: "border:0;width:100%;aspect-ratio:#{composition.width}/#{composition.height}"
41
+ }
42
+ tag.iframe(**defaults, **attributes)
43
+ end
44
+
45
+ def animate_it_embed(
46
+ composition_id,
47
+ poster:,
48
+ variants: [],
49
+ navigation: { preset: :pills },
50
+ load_when_visible: 0.25,
51
+ play_when_visible: 2.0 / 3,
52
+ pause_offscreen: true,
53
+ reduced_motion: :poster,
54
+ autoplay: true,
55
+ title: nil,
56
+ **attributes,
57
+ &block
58
+ )
59
+ composition = public_animate_it_composition!(composition_id)
60
+ navigation = navigation == false ? false : (navigation || {}).to_h.deep_symbolize_keys
61
+ resolved_variants = resolve_animate_it_variants(composition, poster, variants)
62
+ validate_animate_it_variant_chapters!(resolved_variants)
63
+ manifest = animate_it_embed_manifest(
64
+ title: title || composition.id,
65
+ variants: resolved_variants,
66
+ load_when_visible:,
67
+ play_when_visible:,
68
+ pause_offscreen:,
69
+ reduced_motion:,
70
+ autoplay:
71
+ )
72
+ builder = EmbedBuilder.new(self, composition, navigation: navigation || {})
73
+ navigation_html = if navigation
74
+ block ? capture(builder, &block) : builder.chapter_navigation(class: "animate-it-embed__navigation")
75
+ end
76
+ classes = ["animate-it-embed", attributes.delete(:class)].compact.join(" ")
77
+ data = (attributes.delete(:data) || {}).merge(animate_it_embed: true)
78
+
79
+ safe_join(
80
+ [
81
+ stylesheet_link_tag(animate_it_embed_asset_path("embed.css"), data: { animate_it_embed_asset: "style" }),
82
+ javascript_include_tag(
83
+ animate_it_embed_asset_path("embed.js"), defer: true, data: { animate_it_embed_asset: "script" }
84
+ ),
85
+ tag.public_send("animate-it-embed", **attributes, class: classes, data:) do
86
+ safe_join([
87
+ navigation_html,
88
+ animate_it_embed_viewport(resolved_variants, title || composition.id),
89
+ tag.script(ERB::Util.json_escape(JSON.generate(manifest)).html_safe,
90
+ type: "application/json", data: { animate_it_embed_manifest: true })
91
+ ].compact)
92
+ end
93
+ ]
94
+ )
95
+ end
96
+
97
+ private
98
+
99
+ def public_animate_it_composition!(composition_id)
100
+ AnimateIt.load_compositions!
101
+ composition = AnimateIt.registry.fetch(composition_id)
102
+ raise ArgumentError, "AnimateIt composition #{composition_id.inspect} is not public" unless composition.public_player?
103
+
104
+ composition
105
+ end
106
+
107
+ def animate_it_mount_prefix
108
+ respond_to?(:request) && request ? request.script_name.to_s : ""
109
+ end
110
+
111
+ def animate_it_public_player_path(composition)
112
+ id = ERB::Util.url_encode(composition.id)
113
+ "#{animate_it_mount_prefix}#{AnimateIt.config.mount_path}/public/compositions/#{id}/player"
114
+ end
115
+
116
+ def animate_it_embed_asset_path(filename)
117
+ version = ERB::Util.url_encode(AnimateIt::VERSION)
118
+ "#{animate_it_mount_prefix}#{AnimateIt.config.mount_path}/assets/#{version}/#{filename}"
119
+ end
120
+
121
+ def resolve_animate_it_variants(composition, poster, variants)
122
+ raise ArgumentError, "animate_it_embed requires a poster" if poster.blank?
123
+
124
+ primary = animate_it_variant_hash(composition, poster:, media: nil)
125
+ responsive = Array(variants).map do |variant|
126
+ attributes = variant.to_h.deep_symbolize_keys
127
+ target = public_animate_it_composition!(attributes.fetch(:composition))
128
+ media = attributes.fetch(:media).to_s
129
+ raise ArgumentError, "AnimateIt variant media query must not be blank" if media.blank?
130
+
131
+ animate_it_variant_hash(target, poster: attributes.fetch(:poster), media:)
132
+ end
133
+ duplicate_media = responsive.group_by { |variant| variant.fetch("media") }.select { |_media, group| group.many? }.keys
134
+ raise ArgumentError, "AnimateIt variant media queries must be unique: #{duplicate_media.join(", ")}" if duplicate_media.any?
135
+
136
+ [primary, *responsive]
137
+ end
138
+
139
+ def animate_it_variant_hash(composition, poster:, media:)
140
+ raise ArgumentError, "AnimateIt variant poster must not be blank" if poster.blank?
141
+
142
+ manifest = composition.player_manifest.as_json
143
+ {
144
+ "media" => media,
145
+ "composition" => manifest,
146
+ "poster" => poster.to_s,
147
+ "source" => animate_it_public_player_path(composition)
148
+ }
149
+ end
150
+
151
+ def validate_animate_it_variant_chapters!(variants)
152
+ expected = variants.first.dig("composition", "chapters").map { |chapter| chapter.values_at("name", "label") }
153
+ variants.drop(1).each do |variant|
154
+ actual = variant.dig("composition", "chapters").map { |chapter| chapter.values_at("name", "label") }
155
+ next if actual == expected
156
+
157
+ raise ArgumentError, "AnimateIt responsive variants must expose the same ordered chapter names and labels"
158
+ end
159
+ end
160
+
161
+ def animate_it_embed_manifest(title:, variants:, load_when_visible:, play_when_visible:, pause_offscreen:, reduced_motion:, autoplay:)
162
+ load_ratio = Float(load_when_visible)
163
+ play_ratio = Float(play_when_visible)
164
+ unless load_ratio.between?(0, 1) && play_ratio.between?(0, 1)
165
+ raise ArgumentError, "AnimateIt visibility thresholds must be between 0 and 1"
166
+ end
167
+ raise ArgumentError, "AnimateIt reduced_motion must be :poster" unless reduced_motion.to_s == "poster"
168
+
169
+ {
170
+ "version" => 1,
171
+ "title" => title,
172
+ "variants" => variants,
173
+ "options" => {
174
+ "loadWhenVisible" => load_ratio,
175
+ "playWhenVisible" => play_ratio,
176
+ "pauseOffscreen" => pause_offscreen == true,
177
+ "reducedMotion" => reduced_motion.to_s,
178
+ "autoplay" => autoplay == true,
179
+ "readyTimeout" => 5000,
180
+ "crossfadeDuration" => 120
181
+ }
182
+ }
183
+ rescue TypeError, ArgumentError => e
184
+ raise e if e.message.start_with?("AnimateIt")
185
+
186
+ raise ArgumentError, "AnimateIt visibility thresholds must be numbers between 0 and 1"
187
+ end
188
+
189
+ def animate_it_embed_viewport(variants, title)
190
+ primary = variants.first
191
+ sources = variants.drop(1).map do |variant|
192
+ tag.source(media: variant.fetch("media"), srcset: variant.fetch("poster"))
193
+ end
194
+ poster = tag.picture(
195
+ safe_join([*sources, image_tag(primary.fetch("poster"), alt: title, loading: "eager")]),
196
+ class: "animate-it-embed__poster", data: { animate_it_embed_poster: true }
197
+ )
198
+ viewport = tag.div(class: "animate-it-embed__viewport", data: { animate_it_embed_viewport: true }) do
199
+ safe_join(
200
+ [
201
+ poster,
202
+ tag.div(tag.div("", class: "animate-it-embed__frame", data: { animate_it_embed_frame: true }),
203
+ class: "animate-it-embed__shell", data: { animate_it_embed_shell: true }),
204
+ tag.button(
205
+ "Play", type: "button", class: "animate-it-embed__control", hidden: true,
206
+ data: { animate_it_embed_control: true }, aria: { label: "Play animation", pressed: "false" }
207
+ )
208
+ ]
209
+ )
210
+ end
211
+ safe_join([viewport, tag.noscript(image_tag(primary.fetch("poster"), alt: title))])
212
+ end
213
+ end
214
+ end