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,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
@@ -1,3 +1,3 @@
1
1
  module AnimateIt
2
- VERSION = "0.3.2".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end