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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +131 -19
  4. data/app/controllers/animate_it/frames_controller.rb +7 -0
  5. data/app/controllers/animate_it/public_players_controller.rb +69 -0
  6. data/app/controllers/animate_it/renders_controller.rb +3 -17
  7. data/app/controllers/animate_it/studio_controller.rb +1 -0
  8. data/app/jobs/animate_it/render_job.rb +2 -0
  9. data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
  10. data/app/views/animate_it/frames/player.html.haml +81 -0
  11. data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
  12. data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
  13. data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
  14. data/app/views/animate_it/studio/show.html.haml +10 -3
  15. data/config/routes.rb +5 -0
  16. data/lib/animate_it/asset_manifest.rb +92 -0
  17. data/lib/animate_it/asset_renderer.rb +39 -7
  18. data/lib/animate_it/composition.rb +100 -9
  19. data/lib/animate_it/embed_helper.rb +22 -0
  20. data/lib/animate_it/engine.rb +4 -0
  21. data/lib/animate_it/runtime/runtime.js +385 -0
  22. data/lib/animate_it/runtime.rb +11 -0
  23. data/lib/animate_it/scene.rb +57 -3
  24. data/lib/animate_it/text_effects.rb +104 -0
  25. data/lib/animate_it/track_document_schema.rb +71 -0
  26. data/lib/animate_it/tracks/document.rb +100 -0
  27. data/lib/animate_it/tracks/layer.rb +13 -0
  28. data/lib/animate_it/tracks/recorder.rb +149 -0
  29. data/lib/animate_it/verification.rb +187 -0
  30. data/lib/animate_it/version.rb +1 -1
  31. data/lib/animate_it/video_renderer.rb +89 -26
  32. data/lib/animate_it/view_helpers.rb +11 -1
  33. data/lib/animate_it.rb +9 -0
  34. data/lib/tasks/animate_it_tasks.rake +133 -0
  35. 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
@@ -1,3 +1,3 @@
1
1
  module AnimateIt
2
- VERSION = "0.3.2".freeze
2
+ VERSION = "0.4.0".freeze
3
3
  end
@@ -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
- capture_status = capture_frames(frame_list, props:, on_progress:, cancel_check:)
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(filmstrip_url(props:), waitUntil: "networkidle")
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 filmstrip_url(props:)
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}/filmstrip?#{URI.encode_www_form(query)}"
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
- return if output_format == :png_sequence # frames already on disk; nothing to encode
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
- audios = audio_segments
135
- audios.each { |seg| command += ["-i", resolve_audio_path!(seg.source[:path])] }
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 += ["-filter_complex", audio_filter_graph(audios), "-map", "0:v", "-map", "[aout]", "-shortest"]
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
- # Build an `adelay=...|...,volume=g[aN]` chain per audio input, then mix.
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
- legs = audios.each_with_index.map do |seg, i|
181
- delay_ms = (seg.from_frame * ms_per_frame).round
182
- gain = seg.source[:gain] || 1.0
183
- "[#{i + 1}:a]adelay=#{delay_ms}|#{delay_ms},volume=#{gain}[a#{i}]"
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
- mix = "#{audios.length.times.map { |i| "[a#{i}]" }.join}amix=inputs=#{audios.length}:duration=longest:normalize=0[aout]"
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
- composition.timeline.segments.select { |seg| seg.kind == :audio }
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