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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/README.md +212 -20
- data/app/controllers/animate_it/embed_assets_controller.rb +25 -0
- data/app/controllers/animate_it/frames_controller.rb +10 -0
- data/app/controllers/animate_it/public_players_controller.rb +73 -0
- data/app/controllers/animate_it/renders_controller.rb +3 -17
- data/app/controllers/animate_it/studio_controller.rb +1 -0
- data/app/jobs/animate_it/render_job.rb +2 -0
- data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
- data/app/views/animate_it/frames/player.html.haml +92 -0
- data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
- data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
- data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
- data/app/views/animate_it/studio/show.html.haml +10 -3
- data/config/routes.rb +10 -0
- data/lib/animate_it/asset_manifest.rb +92 -0
- data/lib/animate_it/asset_renderer.rb +39 -7
- data/lib/animate_it/chapter_navigation.rb +160 -0
- data/lib/animate_it/chapters.rb +95 -0
- data/lib/animate_it/composition.rb +115 -9
- data/lib/animate_it/embed_helper.rb +214 -0
- 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 +7 -0
- data/lib/animate_it/player_manifest.rb +29 -0
- data/lib/animate_it/runtime/runtime.js +518 -0
- data/lib/animate_it/runtime.rb +11 -0
- data/lib/animate_it/scene.rb +57 -3
- data/lib/animate_it/text_effects.rb +104 -0
- data/lib/animate_it/track_document_schema.rb +71 -0
- data/lib/animate_it/tracks/document.rb +100 -0
- data/lib/animate_it/tracks/layer.rb +13 -0
- data/lib/animate_it/tracks/recorder.rb +149 -0
- data/lib/animate_it/verification.rb +187 -0
- data/lib/animate_it/version.rb +1 -1
- data/lib/animate_it/video_renderer.rb +89 -26
- data/lib/animate_it/view_helpers.rb +11 -1
- data/lib/animate_it.rb +17 -0
- data/lib/tasks/animate_it_tasks.rake +133 -0
- 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(
|
|
148
|
-
|
|
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:
|
|
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
|