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,104 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# Declarative word-by-word headline reveals that can be recorded as compact
|
|
3
|
+
# keyframe tracks while retaining server-rendered fallback values.
|
|
4
|
+
module TextEffects
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def word_reveal(key, text, start:, offset: 0, stagger: 4, dur: 12, rise: 18)
|
|
11
|
+
own_word_reveals[key.to_sym] = { kind: :rise, text:, start:, offset:, stagger:, dur:, rise: }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def punch_reveal(key, text, start:, offset: 0, stagger: 5, dur: 10)
|
|
15
|
+
own_word_reveals[key.to_sym] = { kind: :punch, text:, start:, offset:, stagger:, dur: }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def word_reveals_registry
|
|
19
|
+
inherited = superclass.respond_to?(:word_reveals_registry) ? superclass.word_reveals_registry : {}
|
|
20
|
+
inherited.merge(own_word_reveals)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def own_word_reveals
|
|
24
|
+
@own_word_reveals ||= {}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def word_reveal_tracks(key)
|
|
29
|
+
spec = self.class.word_reveals_registry.fetch(key.to_sym)
|
|
30
|
+
base = resolve_reveal_start(spec)
|
|
31
|
+
spec[:text].split.each_with_index.flat_map do |_word, index|
|
|
32
|
+
from = base + (index * spec[:stagger])
|
|
33
|
+
to = from + spec[:dur]
|
|
34
|
+
if spec[:kind] == :punch
|
|
35
|
+
[
|
|
36
|
+
{ var: "#{key}-w#{index}-op", frames: [from, to], values: [0, 1], unit: "" },
|
|
37
|
+
{
|
|
38
|
+
var: "#{key}-w#{index}-sc",
|
|
39
|
+
frames: [from, from + (spec[:dur] * 0.6).round, to],
|
|
40
|
+
values: [1.3, 1.06, 1.0],
|
|
41
|
+
unit: ""
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
else
|
|
45
|
+
[
|
|
46
|
+
{ var: "#{key}-w#{index}-op", frames: [from, to], values: [0, 1], unit: "" },
|
|
47
|
+
{ var: "#{key}-w#{index}-y", frames: [from, to], values: [spec[:rise], 0], unit: "px" }
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reveal_words(key)
|
|
54
|
+
spec = self.class.word_reveals_registry.fetch(key.to_sym)
|
|
55
|
+
static = word_reveal_tracks(key).to_h do |track|
|
|
56
|
+
value = interpolate(
|
|
57
|
+
local_frame,
|
|
58
|
+
track[:frames],
|
|
59
|
+
track[:values],
|
|
60
|
+
easing: :ease_out,
|
|
61
|
+
extrapolate_left: :clamp,
|
|
62
|
+
extrapolate_right: :clamp
|
|
63
|
+
).round(4)
|
|
64
|
+
[track[:var].to_sym, "#{value}#{track[:unit]}"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
spans = spec[:text].split.each_with_index.map do |word, index|
|
|
68
|
+
tag.span(word, style: reveal_word_style(spec, key, index))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
tag.span(
|
|
72
|
+
safe_join(spans, " "),
|
|
73
|
+
data: { animate_vars: reveal_group(key) },
|
|
74
|
+
style: Style.build("display: contents", Style.vars(**static))
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reveal_plain_words(key)
|
|
79
|
+
spec = self.class.word_reveals_registry.fetch(key.to_sym)
|
|
80
|
+
safe_join(spec[:text].split.map { |word| tag.span(word) }, " ")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reveal_group(key)
|
|
84
|
+
"textfx-#{key.to_s.tr("_", "-")}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def resolve_reveal_start(spec)
|
|
90
|
+
base = spec[:start].is_a?(Symbol) ? beat_frame(spec[:start]) : spec[:start]
|
|
91
|
+
base + spec[:offset]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reveal_word_style(spec, key, index)
|
|
95
|
+
if spec[:kind] == :punch
|
|
96
|
+
"display:inline-block; opacity: var(--#{key}-w#{index}-op, 0); " \
|
|
97
|
+
"transform: scale(var(--#{key}-w#{index}-sc, 1.3));"
|
|
98
|
+
else
|
|
99
|
+
"display:inline-block; opacity: var(--#{key}-w#{index}-op, 0); " \
|
|
100
|
+
"transform: translateY(var(--#{key}-w#{index}-y, #{spec[:rise]}px));"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
# Validates the server/browser track-document boundary before embedding it.
|
|
3
|
+
module TrackDocumentSchema
|
|
4
|
+
CURRENT_VERSION = 2
|
|
5
|
+
SUPPORTED_VERSIONS = [1, CURRENT_VERSION].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def validate!(document)
|
|
10
|
+
data = document.respond_to?(:as_json) ? document.as_json : document
|
|
11
|
+
raise Error, "AnimateIt track document must be a JSON object" unless data.is_a?(Hash)
|
|
12
|
+
|
|
13
|
+
version = data["v"]
|
|
14
|
+
unless SUPPORTED_VERSIONS.include?(version)
|
|
15
|
+
raise Error,
|
|
16
|
+
"Unsupported AnimateIt track schema #{version.inspect}; " \
|
|
17
|
+
"supported versions are #{SUPPORTED_VERSIONS.join(", ")}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
validate_positive_number!(data, "fps")
|
|
21
|
+
validate_positive_integer!(data, "duration")
|
|
22
|
+
validate_hash!(data, "groups")
|
|
23
|
+
validate_hash!(data, "texts")
|
|
24
|
+
validate_array!(data, "layers")
|
|
25
|
+
validate_v2!(data) if version == CURRENT_VERSION
|
|
26
|
+
data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_v2!(data)
|
|
30
|
+
validate_hash!(data, "groupSelectors")
|
|
31
|
+
validate_hash!(data, "textSelectors")
|
|
32
|
+
validate_selector_keys!(data["groups"], data["groupSelectors"], "group")
|
|
33
|
+
validate_selector_keys!(data["texts"], data["textSelectors"], "text")
|
|
34
|
+
end
|
|
35
|
+
private_class_method :validate_v2!
|
|
36
|
+
|
|
37
|
+
def validate_selector_keys!(tracks, selectors, kind)
|
|
38
|
+
extra = selectors.keys - tracks.keys
|
|
39
|
+
return if extra.empty?
|
|
40
|
+
|
|
41
|
+
raise Error, "AnimateIt v2 #{kind} selectors reference missing tracks: #{extra.join(", ")}"
|
|
42
|
+
end
|
|
43
|
+
private_class_method :validate_selector_keys!
|
|
44
|
+
|
|
45
|
+
def validate_positive_number!(data, key)
|
|
46
|
+
value = data[key]
|
|
47
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
48
|
+
|
|
49
|
+
raise Error, "AnimateIt track document #{key} must be a positive number"
|
|
50
|
+
end
|
|
51
|
+
private_class_method :validate_positive_number!
|
|
52
|
+
|
|
53
|
+
def validate_positive_integer!(data, key)
|
|
54
|
+
value = data[key]
|
|
55
|
+
return if value.is_a?(Integer) && value.positive?
|
|
56
|
+
|
|
57
|
+
raise Error, "AnimateIt track document #{key} must be a positive integer"
|
|
58
|
+
end
|
|
59
|
+
private_class_method :validate_positive_integer!
|
|
60
|
+
|
|
61
|
+
def validate_hash!(data, key)
|
|
62
|
+
raise Error, "AnimateIt track document #{key} must be an object" unless data[key].is_a?(Hash)
|
|
63
|
+
end
|
|
64
|
+
private_class_method :validate_hash!
|
|
65
|
+
|
|
66
|
+
def validate_array!(data, key)
|
|
67
|
+
raise Error, "AnimateIt track document #{key} must be an array" unless data[key].is_a?(Array)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :validate_array!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AnimateIt
|
|
4
|
+
module Tracks
|
|
5
|
+
# Serializable track document consumed by the client runtime.
|
|
6
|
+
class Document
|
|
7
|
+
attr_reader :fps, :duration
|
|
8
|
+
|
|
9
|
+
def initialize(fps:, duration:)
|
|
10
|
+
@fps = fps
|
|
11
|
+
@duration = duration
|
|
12
|
+
@samples = Hash.new { |hash, group| hash[group] = {} }
|
|
13
|
+
@keyframes = Hash.new { |hash, group| hash[group] = {} }
|
|
14
|
+
@text_samples = {}
|
|
15
|
+
@group_selectors = {}
|
|
16
|
+
@text_selectors = {}
|
|
17
|
+
@layers = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_sample(group, var, frame, value, selector: nil)
|
|
21
|
+
@group_selectors[group.to_s] = selector if selector
|
|
22
|
+
track = @samples[group.to_s][normalize_var(var)] ||= Array.new(duration)
|
|
23
|
+
track[frame] = value&.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_keyframe_track(group, var, frames:, values:, easing: :ease_out, unit: nil, selector: nil)
|
|
27
|
+
@group_selectors[group.to_s] = selector if selector
|
|
28
|
+
@keyframes[group.to_s][normalize_var(var)] = {
|
|
29
|
+
"t" => "kf",
|
|
30
|
+
"k" => frames.map(&:to_i).zip(values),
|
|
31
|
+
"e" => easing.to_s,
|
|
32
|
+
"u" => unit.to_s
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def record_text(key, frame, value, selector: nil)
|
|
37
|
+
@text_selectors[key.to_s] = selector if selector
|
|
38
|
+
track = @text_samples[key.to_s] ||= Array.new(duration)
|
|
39
|
+
track[frame] = value.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_layer(key, from_frame, to_frame, origin_frame:)
|
|
43
|
+
@layers << {
|
|
44
|
+
"sel" => %([data-animate-layer="#{key}"]),
|
|
45
|
+
"from" => from_frame,
|
|
46
|
+
"to" => to_frame,
|
|
47
|
+
"origin" => origin_frame
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def as_json
|
|
52
|
+
groups = @samples.transform_values { |vars| vars.transform_values { |track| rle(track) } }
|
|
53
|
+
@keyframes.each { |group, vars| (groups[group] ||= {}).merge!(vars) }
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
"v" => 2,
|
|
57
|
+
"fps" => fps,
|
|
58
|
+
"duration" => duration,
|
|
59
|
+
"groups" => groups,
|
|
60
|
+
"groupSelectors" => @group_selectors,
|
|
61
|
+
"texts" => @text_samples.transform_values { |track| rle(fill(track)) },
|
|
62
|
+
"textSelectors" => @text_selectors,
|
|
63
|
+
"layers" => @layers
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_json(*)
|
|
68
|
+
JSON.generate(as_json)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def normalize_var(var)
|
|
74
|
+
name = var.to_s.tr("_", "-")
|
|
75
|
+
name.start_with?("--") ? name : "--#{name}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Text values hold across inactive gaps. CSS variable gaps stay nil so
|
|
79
|
+
# the browser can remove a property instead of leaking an earlier value.
|
|
80
|
+
def fill(track)
|
|
81
|
+
last = nil
|
|
82
|
+
forward = track.map { |value| last = value || last }
|
|
83
|
+
first = forward.find { |value| value } || ""
|
|
84
|
+
forward.map { |value| value || first }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def rle(track)
|
|
88
|
+
runs = []
|
|
89
|
+
track.each do |value|
|
|
90
|
+
if runs.any? && runs.last[0] == value
|
|
91
|
+
runs.last[1] += 1
|
|
92
|
+
else
|
|
93
|
+
runs << [value, 1]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
{ "t" => "rle", "r" => runs }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
module Tracks
|
|
3
|
+
# One structural slice of a composition: a scene segment's DOM rendered
|
|
4
|
+
# at `from_frame`, shown by the client runtime for frames in
|
|
5
|
+
# [from_frame, to_frame). Layers are the union of timeline-segment
|
|
6
|
+
# windows and `structure_epochs` boundaries.
|
|
7
|
+
Layer = Data.define(:segment, :segment_index, :from_frame, :to_frame) do
|
|
8
|
+
def key
|
|
9
|
+
"s#{segment_index}/e#{from_frame}"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
module AnimateIt
|
|
2
|
+
module Tracks
|
|
3
|
+
# Samples declared variable/text tracks and serializes compact keyframes.
|
|
4
|
+
class Recorder
|
|
5
|
+
ANIMATE_GROUP = "animate".freeze
|
|
6
|
+
|
|
7
|
+
def initialize(composition, props: {})
|
|
8
|
+
@composition = composition
|
|
9
|
+
@props = composition.props_schema.resolve(props)
|
|
10
|
+
@scene_segments = composition.timeline.segments.each_with_index.filter_map do |segment, index|
|
|
11
|
+
[segment, index] if segment.kind == :scene
|
|
12
|
+
end
|
|
13
|
+
@layers_by_segment = composition.structure_layers.group_by(&:segment_index)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
document = Document.new(fps: @composition.fps, duration: @composition.duration_in_frames)
|
|
18
|
+
@composition.structure_layers.each do |layer|
|
|
19
|
+
document.add_layer(layer.key, layer.from_frame, layer.to_frame, origin_frame: layer.segment.from_frame)
|
|
20
|
+
end
|
|
21
|
+
record_animate_dsl(document)
|
|
22
|
+
record_word_reveals(document)
|
|
23
|
+
record_samples(document)
|
|
24
|
+
document
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def record_samples(document)
|
|
30
|
+
@composition.duration_in_frames.times do |frame|
|
|
31
|
+
@scene_segments.each do |segment, segment_index|
|
|
32
|
+
next unless segment.active_at?(frame)
|
|
33
|
+
|
|
34
|
+
scene_class = segment.scene_class
|
|
35
|
+
next unless scene_class
|
|
36
|
+
next if scene_class.var_groups.empty? && scene_class.text_tracks.empty?
|
|
37
|
+
|
|
38
|
+
scene = build_scene(segment, frame)
|
|
39
|
+
record_frame_vars(document, scene, segment_index, frame)
|
|
40
|
+
record_frame_texts(document, scene, segment_index, frame)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def record_frame_vars(document, scene, segment_index, frame)
|
|
46
|
+
scene.class.var_groups.each_key do |group|
|
|
47
|
+
vars = evaluate(scene, "track_vars :#{group}", frame) { scene.evaluate_var_group(group) }
|
|
48
|
+
vars.each do |var, value|
|
|
49
|
+
document.record_sample(
|
|
50
|
+
binding_key(segment_index, group), var, frame, value,
|
|
51
|
+
selector: group_selector(segment_index, group)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record_frame_texts(document, scene, segment_index, frame)
|
|
58
|
+
scene.class.text_tracks.each_key do |key|
|
|
59
|
+
value = evaluate(scene, "text_track :#{key}", frame) { scene.evaluate_text_track(key) }
|
|
60
|
+
document.record_text(
|
|
61
|
+
binding_key(segment_index, key), frame, value,
|
|
62
|
+
selector: text_selector(segment_index, key)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def record_animate_dsl(document)
|
|
68
|
+
@scene_segments.each do |segment, segment_index|
|
|
69
|
+
scene_class = segment.scene_class
|
|
70
|
+
next unless scene_class
|
|
71
|
+
next if scene_class.animations.elements.empty?
|
|
72
|
+
|
|
73
|
+
scene = build_scene(segment, segment.from_frame)
|
|
74
|
+
scene_class.animations.elements.each_value do |element|
|
|
75
|
+
element.properties.each do |prop|
|
|
76
|
+
keyframes = prop.keyframes_to_values.call(scene)
|
|
77
|
+
document.add_keyframe_track(
|
|
78
|
+
binding_key(segment_index, ANIMATE_GROUP),
|
|
79
|
+
prop.var_name(element.name),
|
|
80
|
+
frames: global_frames(segment, keyframes.keys),
|
|
81
|
+
values: keyframes.values,
|
|
82
|
+
unit: prop.unit,
|
|
83
|
+
selector: group_selector(segment_index, ANIMATE_GROUP)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_word_reveals(document)
|
|
91
|
+
@scene_segments.each do |segment, segment_index|
|
|
92
|
+
scene_class = segment.scene_class
|
|
93
|
+
next unless scene_class
|
|
94
|
+
next if scene_class.word_reveals_registry.empty?
|
|
95
|
+
|
|
96
|
+
scene = build_scene(segment, segment.from_frame)
|
|
97
|
+
scene_class.word_reveals_registry.each_key do |key|
|
|
98
|
+
scene.word_reveal_tracks(key).each do |track|
|
|
99
|
+
document.add_keyframe_track(
|
|
100
|
+
binding_key(segment_index, scene.reveal_group(key)),
|
|
101
|
+
track[:var],
|
|
102
|
+
frames: global_frames(segment, track[:frames]),
|
|
103
|
+
values: track[:values],
|
|
104
|
+
unit: track[:unit],
|
|
105
|
+
selector: group_selector(segment_index, scene.reveal_group(key))
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_scene(segment, frame)
|
|
113
|
+
context = @composition.frame_context(frame:, props: @props, segment:)
|
|
114
|
+
segment.scene_class.current_frame = context.local_frame
|
|
115
|
+
segment.scene_class.new(context:, props: context.props)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def binding_key(segment_index, name)
|
|
119
|
+
"s#{segment_index}:#{name}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def group_selector(segment_index, group)
|
|
123
|
+
layer_descendant_selector(segment_index, %([data-animate-vars="#{group}"]))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def text_selector(segment_index, key)
|
|
127
|
+
layer_descendant_selector(segment_index, %([data-animate-text="#{key}"]))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def layer_descendant_selector(segment_index, descendant)
|
|
131
|
+
@layers_by_segment.fetch(segment_index).map do |layer|
|
|
132
|
+
%([data-animate-layer="#{layer.key}"] #{descendant})
|
|
133
|
+
end.join(",")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def global_frames(segment, frames)
|
|
137
|
+
frames.map { |frame| segment.from_frame + frame }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def evaluate(scene, label, frame)
|
|
141
|
+
yield
|
|
142
|
+
rescue NoMethodError => e
|
|
143
|
+
raise Error, "#{label} on #{scene.class} raised at frame #{frame}: #{e.message}. " \
|
|
144
|
+
"Track blocks run without a view context — keep them to pure var math " \
|
|
145
|
+
"and move rendering concerns into the template."
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module AnimateIt
|
|
6
|
+
# Pixel-diff harness comparing the client player with legacy filmstrip.
|
|
7
|
+
class Verification
|
|
8
|
+
Result = Data.define(:frame, :rgb_psnr, :alpha_psnr, :passed) do
|
|
9
|
+
def psnr
|
|
10
|
+
[rgb_psnr, alpha_psnr].min
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :composition, :host, :step, :threshold, :alpha_threshold, :output_dir, :props, :ready_timeout
|
|
15
|
+
|
|
16
|
+
def initialize(
|
|
17
|
+
composition:,
|
|
18
|
+
host:,
|
|
19
|
+
step: 10,
|
|
20
|
+
threshold: 40.0,
|
|
21
|
+
alpha_threshold: nil,
|
|
22
|
+
output_dir: nil,
|
|
23
|
+
playwright_cli: nil,
|
|
24
|
+
props: {},
|
|
25
|
+
ready_timeout: 30_000
|
|
26
|
+
)
|
|
27
|
+
@composition = composition
|
|
28
|
+
@host = host.delete_suffix("/")
|
|
29
|
+
@step = [step.to_i, 1].max
|
|
30
|
+
@threshold = threshold.to_f
|
|
31
|
+
@alpha_threshold = alpha_threshold.nil? ? @threshold : alpha_threshold.to_f
|
|
32
|
+
@output_dir = Pathname(output_dir || Rails.root.join("tmp/animate_it/verify/#{composition.id}"))
|
|
33
|
+
@playwright_cli = playwright_cli || ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", "npx playwright")
|
|
34
|
+
@props = props.to_h
|
|
35
|
+
@ready_timeout = [ready_timeout.to_i, 1].max
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
require "playwright"
|
|
40
|
+
FileUtils.mkdir_p(output_dir)
|
|
41
|
+
results = []
|
|
42
|
+
|
|
43
|
+
Playwright.create(playwright_cli_executable_path: @playwright_cli) do |playwright|
|
|
44
|
+
browser = playwright.chromium.launch(
|
|
45
|
+
headless: true,
|
|
46
|
+
args: [
|
|
47
|
+
"--disable-web-security",
|
|
48
|
+
"--disable-lcd-text",
|
|
49
|
+
"--disable-gpu-compositing",
|
|
50
|
+
"--force-color-profile=srgb"
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
begin
|
|
54
|
+
context = browser.new_context(viewport: { width: composition.width, height: composition.height })
|
|
55
|
+
legacy = open_page(context, "filmstrip")
|
|
56
|
+
candidate = open_page(context, "player")
|
|
57
|
+
|
|
58
|
+
sample_frames.each do |frame|
|
|
59
|
+
legacy_shot = screenshot(legacy, frame, "legacy")
|
|
60
|
+
candidate_shot = screenshot(candidate, frame, "player")
|
|
61
|
+
results << result_for(frame, legacy_shot, candidate_shot)
|
|
62
|
+
end
|
|
63
|
+
ensure
|
|
64
|
+
browser&.close
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
results
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sample_frames
|
|
72
|
+
max = composition.duration_in_frames - 1
|
|
73
|
+
frames = (0..max).step(step).to_a
|
|
74
|
+
composition.structure_layers.each do |layer|
|
|
75
|
+
[layer.from_frame, layer.to_frame].each do |edge|
|
|
76
|
+
frames.push(edge - 1, edge, edge + 1)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
frames.grep(0..max).sort.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def open_page(context, endpoint)
|
|
85
|
+
page = context.new_page
|
|
86
|
+
url = page_url(endpoint)
|
|
87
|
+
response = page.goto(url, waitUntil: "networkidle", timeout: ready_timeout)
|
|
88
|
+
unless response&.ok?
|
|
89
|
+
status = response ? "#{response.status} #{response.status_text}" : "no HTTP response"
|
|
90
|
+
raise Error, "Could not open AnimateIt #{endpoint} page at #{url}: #{status}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
page.wait_for_function(
|
|
94
|
+
'document.documentElement.dataset.animateItReady === "1"',
|
|
95
|
+
timeout: ready_timeout
|
|
96
|
+
)
|
|
97
|
+
page
|
|
98
|
+
rescue Playwright::TimeoutError => e
|
|
99
|
+
raise Error,
|
|
100
|
+
"Timed out after #{ready_timeout}ms waiting for the AnimateIt #{endpoint} page at #{url}. " \
|
|
101
|
+
"Confirm the server is reachable and that the page sets data-animate-it-ready=\"1\". " \
|
|
102
|
+
"(#{e.message})"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def screenshot(page, frame, label)
|
|
106
|
+
page.evaluate("(n) => window.__animateIt.setFrame(n)", arg: frame)
|
|
107
|
+
if label == "player"
|
|
108
|
+
page.evaluate(<<~JS)
|
|
109
|
+
document.querySelectorAll(".animate-it-layer.is-active").forEach((el) => {
|
|
110
|
+
el.classList.remove("is-active");
|
|
111
|
+
void el.offsetHeight;
|
|
112
|
+
el.classList.add("is-active");
|
|
113
|
+
});
|
|
114
|
+
JS
|
|
115
|
+
page.evaluate("(n) => window.__animateIt.setFrame(n)", arg: frame)
|
|
116
|
+
end
|
|
117
|
+
page.evaluate("() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
|
|
118
|
+
path = output_dir.join(format("%<label>s-%<frame>05d.png", label:, frame:))
|
|
119
|
+
page.screenshot(path: path.to_s, omitBackground: true)
|
|
120
|
+
path
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def page_url(endpoint)
|
|
124
|
+
query = { pp: "disable" }
|
|
125
|
+
query[:props_json] = JSON.generate(props) unless props.empty?
|
|
126
|
+
"#{host}#{AnimateIt.config.mount_path}/compositions/#{composition.id}/#{endpoint}?#{URI.encode_www_form(query)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def psnr_between(reference, candidate)
|
|
130
|
+
psnr_between_rgba(decoded_rgba(reference), decoded_rgba(candidate))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def psnr_between_rgba(reference_rgba, candidate_rgba)
|
|
134
|
+
unless reference_rgba.bytesize == candidate_rgba.bytesize
|
|
135
|
+
raise Error,
|
|
136
|
+
"Cannot compare screenshots with different decoded sizes " \
|
|
137
|
+
"(#{reference_rgba.bytesize} vs #{candidate_rgba.bytesize} RGBA bytes)"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
rgb_error = 0.0
|
|
141
|
+
alpha_error = 0.0
|
|
142
|
+
offset = 0
|
|
143
|
+
while offset < reference_rgba.bytesize
|
|
144
|
+
reference_alpha = reference_rgba.getbyte(offset + 3)
|
|
145
|
+
candidate_alpha = candidate_rgba.getbyte(offset + 3)
|
|
146
|
+
alpha_error += (reference_alpha - candidate_alpha)**2
|
|
147
|
+
|
|
148
|
+
3.times do |channel|
|
|
149
|
+
reference_value = reference_rgba.getbyte(offset + channel) * reference_alpha / 255.0
|
|
150
|
+
candidate_value = candidate_rgba.getbyte(offset + channel) * candidate_alpha / 255.0
|
|
151
|
+
rgb_error += (reference_value - candidate_value)**2
|
|
152
|
+
end
|
|
153
|
+
offset += 4
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
pixels = reference_rgba.bytesize / 4
|
|
157
|
+
[psnr(rgb_error, pixels * 3), psnr(alpha_error, pixels)]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def result_for(frame, reference, candidate)
|
|
161
|
+
rgb_psnr, alpha_psnr = psnr_between(reference, candidate)
|
|
162
|
+
result_for_scores(frame, rgb_psnr, alpha_psnr)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def result_for_scores(frame, rgb_psnr, alpha_psnr)
|
|
166
|
+
passed = rgb_psnr >= threshold && alpha_psnr >= alpha_threshold
|
|
167
|
+
Result.new(frame:, rgb_psnr:, alpha_psnr:, passed:)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def decoded_rgba(path)
|
|
171
|
+
stdout, stderr, status = Open3.capture3(
|
|
172
|
+
"ffmpeg", "-v", "error", "-i", path.to_s,
|
|
173
|
+
"-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgba", "-"
|
|
174
|
+
)
|
|
175
|
+
raise Error, "ffmpeg could not decode #{path}: #{stderr}" unless status.success?
|
|
176
|
+
|
|
177
|
+
stdout
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def psnr(squared_error, samples)
|
|
181
|
+
return Float::INFINITY if squared_error.zero?
|
|
182
|
+
|
|
183
|
+
mean_squared_error = squared_error / samples
|
|
184
|
+
10 * Math.log10((255.0**2) / mean_squared_error)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/animate_it/version.rb
CHANGED