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,92 @@
1
+ !!!
2
+ %html{ lang: "en", data: {
3
+ animate_it_embedded: @embedded_player ? "true" : "false",
4
+ animate_it_host_navigation: @host_navigation ? "true" : "false"
5
+ } }
6
+ %head
7
+ %title= "#{@composition.id} player (#{@composition.duration_in_frames} frames)"
8
+ %meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
9
+ - AnimateIt.config.render_stylesheets.each do |sheet|
10
+ = stylesheet_link_tag sheet, "data-turbo-track": "reload"
11
+ :css
12
+ html, body {
13
+ width: #{@composition.width}px;
14
+ height: #{@composition.height}px;
15
+ margin: 0;
16
+ overflow: hidden;
17
+ background: transparent;
18
+ }
19
+ body {
20
+ zoom: #{@composition.zoom};
21
+ }
22
+ body, main, .hero-render-canvas {
23
+ background-color: transparent !important;
24
+ }
25
+ *, *::before, *::after {
26
+ transition: none !important;
27
+ }
28
+ .animate-it-stage {
29
+ position: absolute;
30
+ inset: 0;
31
+ width: #{@composition.width}px;
32
+ height: #{@composition.height}px;
33
+ overflow: hidden;
34
+ }
35
+ .animate-it-layer {
36
+ position: absolute;
37
+ inset: 0;
38
+ display: none !important;
39
+ visibility: hidden;
40
+ }
41
+ .animate-it-layer.is-active {
42
+ display: block !important;
43
+ visibility: visible;
44
+ }
45
+ .animate-it-public-play {
46
+ position: fixed;
47
+ right: 16px;
48
+ bottom: 16px;
49
+ z-index: 2147483647;
50
+ border: 0;
51
+ border-radius: 999px;
52
+ padding: 10px 18px;
53
+ background: rgba(0, 0, 0, 0.78);
54
+ color: #fff;
55
+ font: 600 14px/1 system-ui, sans-serif;
56
+ cursor: pointer;
57
+ }
58
+ html[data-animate-it-host-navigation="true"] [data-animate-it-hide-when-embedded="true"] {
59
+ display: none !important;
60
+ }
61
+ html[data-animate-it-embedded="true"] .animate-it-public-play {
62
+ display: none !important;
63
+ }
64
+ %body
65
+ .animate-it-stage
66
+ = @composition.render_structure(self, props: @props)
67
+ - if respond_to?(:render_page_stylesheets)
68
+ = render_page_stylesheets
69
+ - if respond_to?(:render_component_stylesheets)
70
+ = render_component_stylesheets
71
+ - if @public_player
72
+ - @audio_segments.each_with_index do |segment, index|
73
+ %audio{ data: {
74
+ from_frame: segment.from_frame,
75
+ duration_frames: segment.duration_frames || (@composition.duration_in_frames - segment.from_frame),
76
+ gain: segment.source[:gain],
77
+ loop: segment.source[:loop] ? "true" : "false"
78
+ },
79
+ preload: "auto",
80
+ src: public_audio_path(index),
81
+ style: "display:none" }
82
+ %button.animate-it-public-play{ type: "button", data: { animate_it_play: true }, aria: { pressed: "false" } } Play
83
+ %script{ type: "application/json", data: {
84
+ animate_it_tracks: true,
85
+ animate_it_transport: @public_player ? "true" : "false",
86
+ animate_it_autoplay: @public_player_options&.fetch(:autoplay, false) ? "true" : "false",
87
+ animate_it_loop: @public_player_options&.fetch(:loop, true) ? "true" : "false"
88
+ } }
89
+ != ERB::Util.json_escape(@track_document.to_json)
90
+ %script{ type: "application/json", data: { animate_it_manifest: true } }
91
+ != ERB::Util.json_escape(@player_manifest.as_json.to_json)
92
+ = javascript_tag AnimateIt::Runtime.source.html_safe
@@ -10,7 +10,7 @@
10
10
  %turbo-frame#animate_it_preview.animate-it-preview
11
11
  %iframe#animate_it_frame{
12
12
  title: "#{composition.id} frame preview",
13
- src: "#{frame_base_url}/0?pp=disable",
13
+ src: composition.client_driven? ? "#{player_url}?pp=disable" : "#{frame_base_url}/0?pp=disable",
14
14
  style: "width: min(100%, #{composition.width}px); aspect-ratio: #{composition.width} / #{composition.height};"
15
15
  }
16
16
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  %section.animate-it-renders
11
11
  %h3 Rendering jobs
12
- = turbo_stream_from AnimateIt::RenderStore::CHANNEL
13
- %p.animate-it-stream-status Turbo Stream connected for render progress.
12
+ - if respond_to?(:turbo_stream_from)
13
+ = turbo_stream_from AnimateIt::RenderStore::CHANNEL
14
+ %p.animate-it-stream-status Turbo Stream connected for render progress.
14
15
  = AnimateIt::RenderStore.html.html_safe
@@ -4,6 +4,8 @@
4
4
  if (!studio) return;
5
5
 
6
6
  const frameBaseUrl = studio.dataset.frameBaseUrl;
7
+ const playerUrl = studio.dataset.playerUrl;
8
+ const clientDriven = studio.dataset.clientDriven === "true";
7
9
  const duration = Number(studio.dataset.duration);
8
10
  const fps = Number(studio.dataset.fps);
9
11
  const lastFrame = duration - 1;
@@ -15,10 +17,10 @@
15
17
  const speedInput = document.getElementById("animate_it_speed");
16
18
  const propsInput = document.getElementById("animate_it_props_json");
17
19
 
18
- const clampFrame = (value) => Math.max(0, Math.min(lastFrame, Number(value) || 0));
20
+ const clampFrame = (value) => Math.max(0, Math.min(lastFrame, Math.round(Number(value) || 0)));
19
21
  const initialParams = new URLSearchParams(window.location.search);
20
22
  let frame = clampFrame(initialParams.get("frame"));
21
- let timer = null;
23
+ let animationFrame = null;
22
24
 
23
25
  const syncFrameUrl = () => {
24
26
  const url = new URL(window.location.href);
@@ -103,11 +105,20 @@
103
105
  .catch(() => { if (token === paintToken) fullReload(n); });
104
106
  };
105
107
 
106
- const renderFrame = (n) => {
108
+ const renderLegacyFrame = (n) => {
107
109
  if (docReady) paintFragment(n);
108
110
  else fullReload(n); // bootstrap (or recover) with the full document
109
111
  };
110
112
 
113
+ const renderFrame = (n) => {
114
+ if (clientDriven) {
115
+ const win = iframe.contentWindow;
116
+ if (win && win.__animateIt) win.__animateIt.setFrame(n);
117
+ } else {
118
+ renderLegacyFrame(n);
119
+ }
120
+ };
121
+
111
122
  const playheads = document.querySelectorAll("[data-animate-it-playhead]");
112
123
  const audios = Array.from(document.querySelectorAll("audio[data-from-frame]"));
113
124
 
@@ -120,18 +131,13 @@
120
131
  return { start, len };
121
132
  };
122
133
 
123
- // Background beds loop to cover their (possibly file-shorter) window;
124
- // one-shot SFX self-limit so a long file can't bleed past its few frames
125
- // even when the frame loop lurches behind real time.
126
134
  audios.forEach((el) => {
127
135
  const { len } = audioWindow(el);
128
- const isBed = el.dataset.track === "background";
129
- el.loop = isBed;
136
+ const loops = el.dataset.loop === "true";
137
+ el.loop = loops;
130
138
  el.volume = Math.min(1, Math.max(0, Number(el.dataset.gain ?? 1)));
131
- // currentTime is seeked to (frame - start)/fps, so it reads 0 at the
132
- // window start; the clip is done once it passes the window length.
133
139
  el.addEventListener("timeupdate", () => {
134
- if (isBed) return;
140
+ if (loops) return;
135
141
  if (el.currentTime >= len / fps && !el.paused) el.pause();
136
142
  });
137
143
  });
@@ -141,34 +147,32 @@
141
147
  playheads.forEach((el) => { el.style.left = `${pct}%`; });
142
148
  };
143
149
 
144
- // Seeking before the clip's metadata has loaded doesn't stick — it would
145
- // start at 0:00 regardless of the scrubbed position. Defer the seek
146
- // (recomputed from the then-current frame) until metadata is available.
150
+ const audioTime = (el, localTime) => {
151
+ if (el.dataset.loop !== "true" || !Number.isFinite(el.duration) || el.duration <= 0) return localTime;
152
+ return localTime % el.duration;
153
+ };
154
+
147
155
  const startAudio = (el) => {
148
156
  const begin = () => {
149
- if (timer === null) return; // stopped before metadata arrived
157
+ if (animationFrame === null) return;
150
158
  const { start, len } = audioWindow(el);
151
- if (frame < start || frame >= start + len) return; // playhead left the window
152
- el.currentTime = (frame - start) / fps;
159
+ if (frame < start || frame >= start + len) return;
160
+ el.currentTime = audioTime(el, (frame - start) / fps);
153
161
  el.play().catch(() => {});
154
162
  };
155
163
  if (el.readyState >= 1) begin();
156
164
  else el.addEventListener("loadedmetadata", begin, { once: true });
157
165
  };
158
166
 
159
- // Audio is audible only while actually playing — seeking/scrubbing is
160
- // silent. Each element plays only inside its own window, seeked to the
161
- // matching offset, so you hear exactly what belongs at the current frame.
162
167
  const syncAudio = (currentFrame, isPlaying) => {
163
168
  for (const el of audios) {
164
169
  const { start, len } = audioWindow(el);
165
170
  const within = currentFrame >= start && currentFrame < start + len;
166
- if (within && isPlaying) {
167
- // Seek only on (re)start when the clip is paused. Once it's
168
- // playing, leave it on its own real-time clock: re-seeking every
169
- // frame to chase the setInterval-driven frame counter (which lags
170
- // real time) is what chops the audio.
171
- if (el.paused) startAudio(el);
171
+ if (within) {
172
+ const time = audioTime(el, (currentFrame - start) / fps);
173
+ if (!isPlaying && el.readyState >= 1 && Math.abs(el.currentTime - time) > 0.05) el.currentTime = time;
174
+ if (isPlaying && el.paused) startAudio(el);
175
+ if (!isPlaying && !el.paused) el.pause();
172
176
  } else if (!el.paused) {
173
177
  el.pause();
174
178
  }
@@ -183,45 +187,64 @@
183
187
  renderFrame(frame);
184
188
  syncFrameUrl();
185
189
  updatePlayheads(frame);
186
- syncAudio(frame, keepAudioState && timer !== null);
190
+ syncAudio(frame, keepAudioState && animationFrame !== null);
187
191
  };
188
192
 
189
193
  const stop = () => {
190
- if (timer) window.cancelAnimationFrame(timer);
191
- timer = null;
194
+ if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
195
+ animationFrame = null;
192
196
  playButton.textContent = "Play";
193
197
  audios.forEach((el) => { if (!el.paused) el.pause(); });
194
198
  };
195
199
 
196
- // Wall-clock playback. The frame is derived from elapsed real time, so the
197
- // picture skips frames it can't keep up with rather than the clock slipping
198
- // behind — and audio (on its own real clock) stays in sync. Morphing the
199
- // body in place is fast and needs no load-gating, so the loop stays simple.
200
200
  const play = () => {
201
201
  stop();
202
202
  playButton.textContent = "Pause";
203
203
  const speed = Number(speedInput.value) || 1;
204
- const startFrame = frame >= lastFrame ? 0 : frame;
205
- const startWall = performance.now();
206
- const tick = () => {
207
- if (timer === null) return;
208
- const next = clampFrame(Math.floor(startFrame + ((performance.now() - startWall) / 1000) * fps * speed));
204
+ const startedAt = window.performance.now();
205
+ const startedFrame = frame >= lastFrame ? 0 : frame;
206
+ if (startedFrame !== frame) setFrame(startedFrame);
207
+ audios.forEach((el) => { el.playbackRate = speed; });
208
+ const tick = (now) => {
209
+ if (animationFrame === null) return;
210
+ const elapsedFrame = startedFrame + Math.floor(((now - startedAt) / 1000) * fps * speed);
211
+ if (elapsedFrame >= lastFrame) {
212
+ setFrame(lastFrame, { keepAudioState: true });
213
+ stop();
214
+ return;
215
+ }
216
+
217
+ const master = audios.find((el) => !el.paused && el.dataset.loop !== "true");
218
+ const next = master
219
+ ? clampFrame((Number(master.dataset.fromFrame) || 0) + Math.floor(master.currentTime * fps))
220
+ : clampFrame(elapsedFrame);
209
221
  if (next !== frame) setFrame(next, { keepAudioState: true });
210
- if (next >= lastFrame) { stop(); return; }
211
- timer = window.requestAnimationFrame(tick);
222
+ animationFrame = window.requestAnimationFrame(tick);
212
223
  };
213
- timer = window.requestAnimationFrame(tick);
224
+ animationFrame = window.requestAnimationFrame(tick);
214
225
  syncAudio(frame, true);
215
226
  };
216
227
 
217
- scrubber.addEventListener("input", (event) => setFrame(event.target.value));
218
- frameInput.addEventListener("change", (event) => setFrame(event.target.value));
228
+ scrubber.addEventListener("input", (event) => {
229
+ stop();
230
+ setFrame(event.target.value);
231
+ });
232
+ frameInput.addEventListener("change", (event) => {
233
+ stop();
234
+ setFrame(event.target.value);
235
+ });
219
236
  propsInput?.addEventListener("change", () => {
220
- fragmentCache.clear();
221
- pending.clear();
222
- setFrame(frame);
237
+ if (clientDriven) {
238
+ iframe.src = `${playerUrl}${propsQuery()}`;
239
+ } else {
240
+ fragmentCache.clear();
241
+ pending.clear();
242
+ setFrame(frame);
243
+ }
223
244
  });
224
245
 
246
+ if (clientDriven) iframe.addEventListener("load", () => renderFrame(frame));
247
+
225
248
  document.querySelectorAll("[data-motion-step]").forEach((button) => {
226
249
  button.addEventListener("click", () => {
227
250
  stop();
@@ -237,7 +260,7 @@
237
260
  });
238
261
 
239
262
  playButton.addEventListener("click", () => {
240
- if (timer) {
263
+ if (animationFrame !== null) {
241
264
  stop();
242
265
  } else {
243
266
  play();
@@ -255,7 +278,7 @@
255
278
  // natively activate it on Space, double-toggling to a no-op.
256
279
  event.preventDefault();
257
280
  if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
258
- timer === null ? play() : stop();
281
+ animationFrame === null ? play() : stop();
259
282
  return;
260
283
  }
261
284
 
@@ -2,12 +2,18 @@
2
2
 
3
3
  = render "studio_styles"
4
4
 
5
- %main.animate-it-studio{ data: { frame_base_url: @frame_base_url, duration: @composition.duration_in_frames, fps: @composition.fps } }
5
+ %main.animate-it-studio{ data: {
6
+ frame_base_url: @frame_base_url,
7
+ player_url: @player_url,
8
+ client_driven: @composition.client_driven? ? "true" : "false",
9
+ duration: @composition.duration_in_frames,
10
+ fps: @composition.fps
11
+ } }
6
12
  %aside.animate-it-sidebar
7
13
  %h1 Animate It
8
14
  = render "composition_list", compositions: @compositions
9
15
 
10
- = render "preview_pane", composition: @composition, frame_base_url: @frame_base_url, last_frame: @last_frame
16
+ = render "preview_pane", composition: @composition, frame_base_url: @frame_base_url, player_url: @player_url, last_frame: @last_frame
11
17
  = render "props_pane", composition: @composition, props_json: @props_json
12
18
 
13
19
  - @composition.timeline.segments.select { |seg| seg.kind == :audio }.each_with_index do |seg, idx|
@@ -15,7 +21,8 @@
15
21
  from_frame: seg.from_frame,
16
22
  duration_frames: seg.duration_frames || 0,
17
23
  track: seg.track,
18
- gain: seg.source[:gain]
24
+ gain: seg.source[:gain],
25
+ loop: seg.source[:loop] ? "true" : "false"
19
26
  },
20
27
  src: animate_it_path("/compositions/#{@composition.id}/audio/#{idx}"),
21
28
  preload: "auto" }
data/config/routes.rb CHANGED
@@ -1,9 +1,19 @@
1
1
  AnimateIt::Engine.routes.draw do
2
+ get "assets/:version/embed.js", to: "embed_assets#javascript", as: :embed_javascript,
3
+ constraints: { version: /[0-9A-Za-z._-]+/ }
4
+ get "assets/:version/embed.css", to: "embed_assets#stylesheet", as: :embed_stylesheet,
5
+ constraints: { version: /[0-9A-Za-z._-]+/ }
6
+
2
7
  root "studio#index"
3
8
 
9
+ get "public/compositions/:id/player", to: "public_players#show", as: :public_composition_player
10
+ get "public/compositions/:id/audio/:index", to: "public_players#audio", as: :public_composition_audio,
11
+ constraints: { index: /\d+/ }
12
+
4
13
  get "compositions/:id", to: "studio#show", as: :composition
5
14
  get "compositions/:id/frame/:frame", to: "frames#show", as: :composition_frame, constraints: { frame: /-?\d+/ }
6
15
  get "compositions/:id/filmstrip", to: "frames#filmstrip", as: :composition_filmstrip
16
+ get "compositions/:id/player", to: "frames#player", as: :composition_player
7
17
  get "compositions/:id/audio/:index", to: "audio#show", as: :composition_audio, constraints: { index: /\d+/ }
8
18
  patch "compositions/:id/props", to: "props#update", as: :composition_props
9
19
  post "compositions/:id/renders", to: "renders#create", as: :composition_renders
@@ -0,0 +1,92 @@
1
+ require "digest"
2
+ require "pathname"
3
+ require "yaml"
4
+
5
+ module AnimateIt
6
+ # Validates source inputs that a host application needs to reproduce its
7
+ # compositions. Media remains owned by the host; the gem only defines the
8
+ # manifest contract and checksum/provenance preflight.
9
+ class AssetManifest
10
+ Result = Data.define(:checked, :errors) do
11
+ def success?
12
+ errors.empty?
13
+ end
14
+ end
15
+
16
+ attr_reader :root, :path
17
+
18
+ def initialize(root: Rails.root, path: nil)
19
+ @root = Pathname(root).expand_path
20
+ @path = Pathname(path || @root.join("config/animate_it_assets.yml"))
21
+ end
22
+
23
+ def validate
24
+ manifest = load_manifest
25
+ assets = manifest.fetch("assets")
26
+ errors = []
27
+ errors << "manifest version must be 1" unless manifest["version"] == 1
28
+ errors.concat(validate_assets(assets))
29
+ Result.new(assets.size, errors)
30
+ rescue Errno::ENOENT
31
+ Result.new(0, ["asset manifest not found: #{path}"])
32
+ rescue KeyError, Psych::Exception => e
33
+ Result.new(0, ["invalid asset manifest #{path}: #{e.message}"])
34
+ end
35
+
36
+ def validate!
37
+ result = validate
38
+ return result if result.success?
39
+
40
+ raise Error, "AnimateIt asset preflight failed:\n- #{result.errors.join("\n- ")}"
41
+ end
42
+
43
+ private
44
+
45
+ def load_manifest
46
+ YAML.safe_load_file(path, aliases: false).tap do |manifest|
47
+ raise KeyError, "top level must be a mapping" unless manifest.is_a?(Hash)
48
+ raise KeyError, "assets must be an array" unless manifest["assets"].is_a?(Array)
49
+ end
50
+ end
51
+
52
+ def validate_assets(assets)
53
+ errors = []
54
+ paths = assets.filter_map do |asset|
55
+ asset["path"] if asset.is_a?(Hash) && !asset["path"].to_s.empty?
56
+ end
57
+ paths.tally.each { |asset_path, count| errors << "duplicate asset path: #{asset_path}" if count > 1 }
58
+ assets.each_with_index { |asset, index| errors.concat(validate_asset(asset, index)) }
59
+ errors
60
+ end
61
+
62
+ def validate_asset(asset, index)
63
+ return ["asset ##{index + 1} must be a mapping"] unless asset.is_a?(Hash)
64
+
65
+ relative_path = asset["path"].to_s
66
+ label = relative_path.empty? ? "asset ##{index + 1}" : relative_path
67
+ provenance = asset["provenance"]
68
+ errors = []
69
+ errors << "#{label}: path is required" if relative_path.empty?
70
+ errors << "#{label}: sha256 must be 64 lowercase hex characters" \
71
+ unless asset["sha256"].to_s.match?(/\A[0-9a-f]{64}\z/)
72
+ errors << "#{label}: provenance.provider is required" \
73
+ unless provenance.is_a?(Hash) && !provenance["provider"].to_s.empty?
74
+ return errors if relative_path.empty?
75
+
76
+ absolute_path = root.join(relative_path).cleanpath
77
+ unless absolute_path.to_s.start_with?("#{root}#{File::SEPARATOR}")
78
+ errors << "#{label}: path must remain inside #{root}"
79
+ return errors
80
+ end
81
+ unless absolute_path.file?
82
+ errors << "#{label}: file is missing"
83
+ return errors
84
+ end
85
+
86
+ actual_sha = Digest::SHA256.file(absolute_path).hexdigest
87
+ errors << "#{label}: checksum mismatch (expected #{asset["sha256"]}, got #{actual_sha})" \
88
+ if actual_sha != asset["sha256"]
89
+ errors
90
+ end
91
+ end
92
+ end
@@ -1,20 +1,38 @@
1
1
  module AnimateIt
2
2
  # Drives a composition through every Output it declared, writing each one
3
- # to its absolute repo-relative path. One VideoRenderer per output (cheap
4
- # frames are captured once per output even when the same composition has
5
- # several formats, because each format wants a different ffmpeg encode).
3
+ # to its absolute repo-relative path. Outputs covering the same frame range
4
+ # share one ordered browser capture; each format still gets its own encoder.
6
5
  module AssetRenderer
7
6
  module_function
8
7
 
9
8
  def render_composition(composition, host:, on_progress: nil, frame_range: nil)
10
9
  raise Error, "Composition #{composition.id} has no `outputs do ... end`" if composition.outputs.empty?
11
10
 
11
+ capture_dirs = {}
12
12
  composition.outputs.map do |output|
13
- render_output(composition, output, host: host, on_progress: on_progress, frame_range: frame_range)
13
+ effective_range = effective_range_for(output, frame_range)
14
+ next if effective_range == :skip
15
+
16
+ capture_key = capture_key_for(composition, effective_range)
17
+ frames_dir = capture_dirs[capture_key]
18
+ reuse_captured_frames = capture_dirs.key?(capture_key)
19
+ frames_dir ||= frames_dir_for(composition, capture_key, primary: capture_dirs.empty?)
20
+ capture_dirs[capture_key] = frames_dir
21
+
22
+ render_output(
23
+ composition,
24
+ output,
25
+ host:,
26
+ on_progress:,
27
+ frame_range: effective_range,
28
+ frames_dir:,
29
+ reuse_captured_frames:
30
+ )
14
31
  end
15
32
  end
16
33
 
17
- def render_output(composition, output, host:, on_progress: nil, frame_range: nil)
34
+ def render_output(composition, output, host:, on_progress: nil, frame_range: nil, frames_dir: nil,
35
+ reuse_captured_frames: false)
18
36
  effective_range = effective_range_for(output, frame_range)
19
37
  return nil if effective_range == :skip
20
38
 
@@ -25,13 +43,27 @@ module AnimateIt
25
43
  composition: composition,
26
44
  host: host,
27
45
  output_path: destination,
28
- format: output.format
46
+ format: output.format,
47
+ frames_dir:
29
48
  )
30
49
 
31
- renderer.render(frame_range: effective_range, on_progress: on_progress)
50
+ renderer.render(frame_range: effective_range, on_progress:, reuse_captured_frames:)
32
51
  destination
33
52
  end
34
53
 
54
+ def capture_key_for(composition, frame_range)
55
+ range = frame_range || (0...composition.duration_in_frames)
56
+ [range.first, range.last]
57
+ end
58
+
59
+ def frames_dir_for(composition, capture_key, primary:)
60
+ base = Rails.root.join("tmp/animate_it/#{composition.id}")
61
+ return base if primary
62
+
63
+ start_frame, end_frame = capture_key
64
+ base.join("captures/#{start_frame}-#{end_frame}")
65
+ end
66
+
35
67
  # A still PNG output always renders its declared single frame; if the
36
68
  # caller-supplied range excludes that frame, skip the output entirely.
37
69
  # Animated outputs use the caller's range when provided, otherwise