animate_it 0.3.2 → 0.4.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 +35 -1
- data/README.md +131 -19
- data/app/controllers/animate_it/frames_controller.rb +7 -0
- data/app/controllers/animate_it/public_players_controller.rb +69 -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 +81 -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 +5 -0
- data/lib/animate_it/asset_manifest.rb +92 -0
- data/lib/animate_it/asset_renderer.rb +39 -7
- data/lib/animate_it/composition.rb +100 -9
- data/lib/animate_it/embed_helper.rb +22 -0
- data/lib/animate_it/engine.rb +4 -0
- data/lib/animate_it/runtime/runtime.js +385 -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 +9 -0
- data/lib/tasks/animate_it_tasks.rake +133 -0
- metadata +13 -1
|
@@ -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
|
@@ -39,34 +39,49 @@ module AnimateIt
|
|
|
39
39
|
@output_path = Pathname(output_path)
|
|
40
40
|
@frames_dir = Pathname(frames_dir || Rails.root.join("tmp/animate_it/#{composition.id}"))
|
|
41
41
|
@playwright_cli = playwright_cli || ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", DEFAULT_PLAYWRIGHT_CLI)
|
|
42
|
-
|
|
43
|
-
return unless audio_segments.any? && AUDIO_INCAPABLE_FORMATS.include?(@output_format)
|
|
44
|
-
|
|
45
|
-
raise Error,
|
|
46
|
-
"Composition #{composition.id} declares audio but format=#{@output_format} doesn't support audio. Use :webm/:mp4/:mov."
|
|
47
42
|
end
|
|
48
43
|
|
|
49
44
|
class CancelledError < AnimateIt::Error; end
|
|
50
45
|
|
|
51
|
-
def render(frame_range: nil, every_nth_frame: 1, props: {}, on_progress: nil, cancel_check: nil
|
|
46
|
+
def render(frame_range: nil, every_nth_frame: 1, props: {}, on_progress: nil, cancel_check: nil,
|
|
47
|
+
reuse_captured_frames: false)
|
|
52
48
|
FileUtils.mkdir_p(frames_dir)
|
|
53
49
|
FileUtils.mkdir_p(output_path.dirname)
|
|
54
50
|
|
|
55
51
|
frame_list = frames(frame_range:, every_nth_frame:)
|
|
56
|
-
|
|
52
|
+
if reuse_captured_frames
|
|
53
|
+
validate_captured_frames!(frame_list.size)
|
|
54
|
+
capture_status = :complete
|
|
55
|
+
else
|
|
56
|
+
clear_captured_frames!
|
|
57
|
+
capture_status = capture_frames(frame_list, props:, on_progress:, cancel_check:)
|
|
58
|
+
end
|
|
57
59
|
|
|
58
60
|
if capture_status == :cancelled || cancel_check&.call
|
|
59
61
|
frame_count = contiguous_frame_count
|
|
60
|
-
encode_video(frame_count:) if frame_count.positive?
|
|
62
|
+
encode_video(frame_count:, start_frame: frame_list.first) if frame_count.positive?
|
|
61
63
|
raise CancelledError, "Render cancelled"
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
encode_video
|
|
66
|
+
encode_video(frame_count: frame_list.size, start_frame: frame_list.first)
|
|
65
67
|
output_path
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
private
|
|
69
71
|
|
|
72
|
+
def clear_captured_frames!
|
|
73
|
+
Dir.glob(frames_dir.join("frame-*.png")).each { |path| FileUtils.rm_f(path) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_captured_frames!(frame_count)
|
|
77
|
+
missing = frame_count.times.find do |index|
|
|
78
|
+
!frames_dir.join(format("frame-%05d.png", index)).file?
|
|
79
|
+
end
|
|
80
|
+
return unless missing
|
|
81
|
+
|
|
82
|
+
raise Error, "Captured frame not found: #{frames_dir.join(format("frame-%05d.png", missing))}"
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
def frames(frame_range:, every_nth_frame:)
|
|
71
86
|
range = frame_range || (0...composition.duration_in_frames)
|
|
72
87
|
range.step(every_nth_frame).to_a
|
|
@@ -87,7 +102,7 @@ module AnimateIt
|
|
|
87
102
|
)
|
|
88
103
|
page = context.new_page
|
|
89
104
|
|
|
90
|
-
page.goto(
|
|
105
|
+
page.goto(page_url(props:), waitUntil: "networkidle")
|
|
91
106
|
page.wait_for_function('document.documentElement.dataset.animateItReady === "1"')
|
|
92
107
|
|
|
93
108
|
frame_list.each_with_index do |frame, index|
|
|
@@ -110,15 +125,19 @@ module AnimateIt
|
|
|
110
125
|
cancelled || cancel_check&.call ? :cancelled : :complete
|
|
111
126
|
end
|
|
112
127
|
|
|
113
|
-
def
|
|
128
|
+
def page_url(props:)
|
|
114
129
|
query = { pp: "disable" }
|
|
115
130
|
query[:props_json] = JSON.generate(props) if props.present?
|
|
131
|
+
endpoint = composition.client_driven? ? "player" : "filmstrip"
|
|
116
132
|
|
|
117
|
-
"#{host}#{AnimateIt.config.mount_path}/compositions/#{composition.id}
|
|
133
|
+
"#{host}#{AnimateIt.config.mount_path}/compositions/#{composition.id}/#{endpoint}?#{URI.encode_www_form(query)}"
|
|
118
134
|
end
|
|
119
135
|
|
|
120
|
-
def encode_video(frame_count: nil)
|
|
121
|
-
|
|
136
|
+
def encode_video(frame_count: nil, start_frame: 0)
|
|
137
|
+
if output_format == :png_sequence
|
|
138
|
+
publish_png_sequence(frame_count || contiguous_frame_count)
|
|
139
|
+
return
|
|
140
|
+
end
|
|
122
141
|
|
|
123
142
|
if output_format == :png
|
|
124
143
|
# Single-frame still: copy the captured PNG straight to output_path.
|
|
@@ -131,14 +150,24 @@ module AnimateIt
|
|
|
131
150
|
command = ["ffmpeg", "-y", "-framerate", composition.fps.to_s,
|
|
132
151
|
"-i", frames_dir.join("frame-%05d.png").to_s]
|
|
133
152
|
|
|
134
|
-
|
|
135
|
-
audios
|
|
153
|
+
clip_frame_count = frame_count || composition.duration_in_frames
|
|
154
|
+
audios = audio_capable? ? audio_segments(start_frame:, frame_count: clip_frame_count) : []
|
|
155
|
+
audios.each do |segment|
|
|
156
|
+
command += ["-stream_loop", "-1"] if segment.source[:loop]
|
|
157
|
+
command += ["-i", resolve_audio_path!(segment.source[:path])]
|
|
158
|
+
end
|
|
136
159
|
|
|
137
160
|
command += video_codec_args
|
|
138
161
|
command += ["-frames:v", frame_count.to_s] if frame_count
|
|
139
162
|
|
|
140
163
|
if audios.any?
|
|
141
|
-
command += [
|
|
164
|
+
command += [
|
|
165
|
+
"-filter_complex",
|
|
166
|
+
audio_filter_graph(audios, start_frame:, frame_count: clip_frame_count),
|
|
167
|
+
"-map", "0:v",
|
|
168
|
+
"-map", "[aout]",
|
|
169
|
+
"-shortest"
|
|
170
|
+
]
|
|
142
171
|
command += audio_codec_args
|
|
143
172
|
else
|
|
144
173
|
command += ["-an"] # explicit no-audio so output containers like .mov stay clean
|
|
@@ -149,6 +178,19 @@ module AnimateIt
|
|
|
149
178
|
run!(command)
|
|
150
179
|
end
|
|
151
180
|
|
|
181
|
+
def publish_png_sequence(frame_count)
|
|
182
|
+
FileUtils.mkdir_p(output_path)
|
|
183
|
+
Dir.glob(output_path.join("frame-*.png")).each { |path| FileUtils.rm_f(path) }
|
|
184
|
+
|
|
185
|
+
frame_count.times do |index|
|
|
186
|
+
filename = format("frame-%05d.png", index)
|
|
187
|
+
source = frames_dir.join(filename)
|
|
188
|
+
raise Error, "Captured frame not found: #{source}" unless source.file?
|
|
189
|
+
|
|
190
|
+
FileUtils.cp(source, output_path.join(filename))
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
152
194
|
def video_codec_args
|
|
153
195
|
case output_format
|
|
154
196
|
when :mp4
|
|
@@ -174,23 +216,44 @@ module AnimateIt
|
|
|
174
216
|
end
|
|
175
217
|
end
|
|
176
218
|
|
|
177
|
-
#
|
|
178
|
-
def audio_filter_graph(audios)
|
|
219
|
+
# Trim each source to its timeline window before delaying and mixing it.
|
|
220
|
+
def audio_filter_graph(audios, start_frame: 0, frame_count: composition.duration_in_frames)
|
|
179
221
|
ms_per_frame = 1000.0 / composition.fps
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
222
|
+
clip_end_frame = start_frame + frame_count
|
|
223
|
+
legs = audios.each_with_index.map do |segment, index|
|
|
224
|
+
segment_end_frame = segment.duration_frames ? segment.from_frame + segment.duration_frames : composition.duration_in_frames
|
|
225
|
+
overlap_start_frame = [segment.from_frame, start_frame].max
|
|
226
|
+
overlap_end_frame = [segment_end_frame, clip_end_frame].min
|
|
227
|
+
source_start_seconds = (overlap_start_frame - segment.from_frame).fdiv(composition.fps)
|
|
228
|
+
overlap_seconds = (overlap_end_frame - overlap_start_frame).fdiv(composition.fps)
|
|
229
|
+
delay_ms = ((overlap_start_frame - start_frame) * ms_per_frame).round
|
|
230
|
+
gain = segment.source[:gain] || 1.0
|
|
231
|
+
"[#{index + 1}:a]atrim=start=#{source_start_seconds}:duration=#{overlap_seconds}," \
|
|
232
|
+
"asetpts=PTS-STARTPTS," \
|
|
233
|
+
"adelay=#{delay_ms}|#{delay_ms},volume=#{gain}[a#{index}]"
|
|
184
234
|
end
|
|
185
235
|
# normalize=0: amix's default rescales every input by 1/N, which
|
|
186
236
|
# silently buries a voice-over under a music bed the moment a second
|
|
187
237
|
# track is declared. Declared gains are the only intended scaling.
|
|
188
|
-
|
|
238
|
+
clip_seconds = frame_count.fdiv(composition.fps)
|
|
239
|
+
mix = "#{audios.length.times.map { |i| "[a#{i}]" }.join}" \
|
|
240
|
+
"amix=inputs=#{audios.length}:duration=longest:normalize=0," \
|
|
241
|
+
"apad=whole_dur=#{clip_seconds},atrim=duration=#{clip_seconds}[aout]"
|
|
189
242
|
[legs, mix].flatten.join(";")
|
|
190
243
|
end
|
|
191
244
|
|
|
192
|
-
def audio_segments
|
|
193
|
-
|
|
245
|
+
def audio_segments(start_frame: 0, frame_count: composition.duration_in_frames)
|
|
246
|
+
clip_end_frame = start_frame + frame_count
|
|
247
|
+
composition.timeline.segments.select do |segment|
|
|
248
|
+
next false unless segment.kind == :audio
|
|
249
|
+
|
|
250
|
+
segment_end_frame = segment.duration_frames ? segment.from_frame + segment.duration_frames : composition.duration_in_frames
|
|
251
|
+
segment.from_frame < clip_end_frame && segment_end_frame > start_frame
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def audio_capable?
|
|
256
|
+
AUDIO_INCAPABLE_FORMATS.exclude?(output_format)
|
|
194
257
|
end
|
|
195
258
|
|
|
196
259
|
def resolve_audio_path!(path)
|
|
@@ -4,9 +4,14 @@ module AnimateIt
|
|
|
4
4
|
Style.build(*rules, **properties)
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
-
def absolute_fill(class_name: nil, style: nil, **attributes, &block)
|
|
7
|
+
def absolute_fill(class_name: nil, style: nil, vars: nil, **attributes, &block)
|
|
8
8
|
content = block.call
|
|
9
9
|
|
|
10
|
+
if vars
|
|
11
|
+
style = [style.presence, animation_vars(vars)].compact.join("; ")
|
|
12
|
+
attributes = { data: { animate_vars: vars } }.deep_merge(attributes)
|
|
13
|
+
end
|
|
14
|
+
|
|
10
15
|
tag.div(
|
|
11
16
|
**attributes,
|
|
12
17
|
class: ["animate-it-absolute-fill", class_name].compact,
|
|
@@ -16,6 +21,11 @@ module AnimateIt
|
|
|
16
21
|
end
|
|
17
22
|
end
|
|
18
23
|
|
|
24
|
+
def animate_text(key, tag_name: :span, **attributes)
|
|
25
|
+
attributes = { data: { animate_text: key } }.deep_merge(attributes)
|
|
26
|
+
tag.public_send(tag_name, evaluate_text_track(key).to_s, **attributes)
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
def render_template(template, assigns: {}, **)
|
|
20
30
|
view_context.render(template:, assigns:, layout: false, **)
|
|
21
31
|
end
|