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
@@ -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,14 @@
1
1
  AnimateIt::Engine.routes.draw do
2
2
  root "studio#index"
3
3
 
4
+ get "public/compositions/:id/player", to: "public_players#show", as: :public_composition_player
5
+ get "public/compositions/:id/audio/:index", to: "public_players#audio", as: :public_composition_audio,
6
+ constraints: { index: /\d+/ }
7
+
4
8
  get "compositions/:id", to: "studio#show", as: :composition
5
9
  get "compositions/:id/frame/:frame", to: "frames#show", as: :composition_frame, constraints: { frame: /-?\d+/ }
6
10
  get "compositions/:id/filmstrip", to: "frames#filmstrip", as: :composition_filmstrip
11
+ get "compositions/:id/player", to: "frames#player", as: :composition_player
7
12
  get "compositions/:id/audio/:index", to: "audio#show", as: :composition_audio, constraints: { index: /\d+/ }
8
13
  patch "compositions/:id/props", to: "props#update", as: :composition_props
9
14
  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
@@ -14,6 +14,8 @@ module AnimateIt
14
14
  subclass.instance_variable_set(:@zoom, 1.0)
15
15
  subclass.instance_variable_set(:@duration_in_frames, 30)
16
16
  subclass.instance_variable_set(:@output_format, :webm)
17
+ subclass.instance_variable_set(:@verification_props, [{}].freeze)
18
+ subclass.instance_variable_set(:@public_player_options, nil)
17
19
  super
18
20
  end
19
21
 
@@ -65,6 +67,81 @@ module AnimateIt
65
67
  AnimateIt.register(self)
66
68
  end
67
69
 
70
+ # Opt in to the seekable browser runtime. Legacy compositions continue
71
+ # rendering individual frames on the server.
72
+ def client_driven!
73
+ @client_driven = true
74
+ end
75
+
76
+ def client_driven?
77
+ @client_driven == true
78
+ end
79
+
80
+ # Explicitly expose this composition through the production-safe public
81
+ # player endpoint. Studio, frame, filmstrip, props, and render endpoints
82
+ # remain local-only. Public playback always uses schema-default props.
83
+ def public_player!(autoplay: false, loop: true)
84
+ client_driven!
85
+ @public_player_options = { autoplay: autoplay == true, loop: loop == true }.freeze
86
+ end
87
+
88
+ def public_player?
89
+ @public_player_options.present?
90
+ end
91
+
92
+ def public_player_options
93
+ @public_player_options || { autoplay: false, loop: true }.freeze
94
+ end
95
+
96
+ def verification_props(*variants)
97
+ return @verification_props if variants.empty?
98
+
99
+ raise ArgumentError, "verification_props entries must be hashes" unless variants.all?(Hash)
100
+
101
+ @verification_props = variants.map(&:deep_symbolize_keys).freeze
102
+ end
103
+
104
+ def structure_epochs(*frames)
105
+ return @structure_epochs || [] if frames.empty?
106
+
107
+ @structure_epochs = frames.map(&:to_i).sort.uniq
108
+ end
109
+
110
+ def structure_layers
111
+ timeline.segments.each_with_index.flat_map do |segment, segment_index|
112
+ next [] unless segment.kind == :scene
113
+
114
+ segment_end = segment.duration_frames ? segment.from_frame + segment.duration_frames : duration_in_frames
115
+ epochs = structure_epochs.select { |frame| frame > segment.from_frame && frame < segment_end }
116
+ bounds = ([segment.from_frame] + epochs).sort.uniq
117
+ bounds.each_with_index.map do |from_frame, index|
118
+ Tracks::Layer.new(
119
+ segment:,
120
+ segment_index:,
121
+ from_frame:,
122
+ to_frame: bounds[index + 1] || segment_end
123
+ )
124
+ end
125
+ end
126
+ end
127
+
128
+ def render_structure(view_context, props: {})
129
+ resolved_props = props_schema.resolve(props)
130
+ rendered_layers = structure_layers.map do |layer|
131
+ context = frame_context(frame: layer.from_frame, props: resolved_props, segment: layer.segment)
132
+ view_context.tag.div(
133
+ render_segment(view_context, layer.segment, context),
134
+ class: "animate-it-layer",
135
+ data: { animate_layer: layer.key }
136
+ )
137
+ end
138
+ view_context.safe_join(rendered_layers)
139
+ end
140
+
141
+ def track_document(props: {})
142
+ Tracks::Recorder.new(self, props:).call
143
+ end
144
+
68
145
  def fps(value = nil)
69
146
  return @fps if value.nil?
70
147
 
@@ -140,12 +217,16 @@ module AnimateIt
140
217
  # ----- Audio DSL --------------------------------------------------
141
218
  # Loop a track for the full duration (or a slice). Background music.
142
219
  def audio_loop(path, duration: nil, from: 0, name: nil, gain: 1.0, track: :background)
143
- # Simplest implementation: place the same source once at `from`
144
- # for the requested length. The renderer's audio mixer handles
145
- # truncation if the underlying file is longer than the segment.
146
220
  length = duration || (@duration_in_frames - Units.frames(from, fps: fps))
147
- audio(path, duration: length, from: from, name: name || "loop-#{File.basename(path.to_s)}", track: track,
148
- gain: gain)
221
+ audio(
222
+ path,
223
+ duration: length,
224
+ from:,
225
+ name: name || "loop-#{File.basename(path.to_s)}",
226
+ track:,
227
+ gain:,
228
+ loop: true
229
+ )
149
230
  end
150
231
 
151
232
  # Play a voice-over clip at a named beat (or time/frame). The clip's
@@ -212,14 +293,21 @@ module AnimateIt
212
293
  # Place an audio segment on its own track. The renderer doesn't draw
213
294
  # audio; the studio plays it via <audio> tags synced to the scrubber, and
214
295
  # ffmpeg muxes it into the final encode.
215
- def audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0)
296
+ def audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0, loop: false)
297
+ begin
298
+ gain = Float(gain)
299
+ rescue TypeError, ArgumentError
300
+ raise ArgumentError, "Audio gain must be a number between 0.0 and 1.0"
301
+ end
302
+ raise ArgumentError, "Audio gain must be between 0.0 and 1.0" unless gain.between?(0.0, 1.0)
303
+
216
304
  timeline.add_segment(
217
305
  name: name || File.basename(path.to_s),
218
306
  from_frame: Units.frames(start_at || from, fps:),
219
307
  duration_frames: Units.frames(duration, fps:),
220
308
  kind: :audio,
221
309
  track:,
222
- source: { path: path.to_s, gain: gain.to_f },
310
+ source: { path: path.to_s, gain:, loop: },
223
311
  show_in_timeline: true
224
312
  )
225
313
  end
@@ -246,14 +334,17 @@ module AnimateIt
246
334
  )
247
335
  end
248
336
 
249
- def render_frame(view_context, frame:, props: {})
337
+ def render_frame(view_context, frame:, props: {}, segment_origins: false)
250
338
  resolved_props = props_schema.resolve(props)
251
339
  segments = timeline.active_segments(frame.to_i, kind: :scene)
252
340
  return "" if segments.empty?
253
341
 
254
342
  rendered_segments = segments.map do |segment|
255
343
  context = frame_context(frame:, props: resolved_props, segment:)
256
- render_segment(view_context, segment, context)
344
+ content = render_segment(view_context, segment, context)
345
+ next content unless segment_origins
346
+
347
+ view_context.tag.div(content, data: { animate_it_segment_origin: segment.from_frame })
257
348
  end
258
349
  view_context.safe_join(rendered_segments)
259
350
  end
@@ -0,0 +1,22 @@
1
+ module AnimateIt
2
+ module EmbedHelper
3
+ def animate_it_player(composition_id, title: nil, **attributes)
4
+ AnimateIt.load_compositions!
5
+ composition = AnimateIt.registry.fetch(composition_id)
6
+ raise ArgumentError, "AnimateIt composition #{composition_id.inspect} is not public" unless composition.public_player?
7
+
8
+ prefix = respond_to?(:request) && request ? request.script_name.to_s : ""
9
+ source = "#{prefix}#{AnimateIt.config.mount_path}/public/compositions/" \
10
+ "#{ERB::Util.url_encode(composition.id)}/player"
11
+ defaults = {
12
+ src: source,
13
+ title: title || composition.id,
14
+ loading: "lazy",
15
+ allow: "autoplay; fullscreen",
16
+ allowfullscreen: true,
17
+ style: "border:0;width:100%;aspect-ratio:#{composition.width}/#{composition.height}"
18
+ }
19
+ tag.iframe(**defaults, **attributes)
20
+ end
21
+ end
22
+ end
@@ -27,5 +27,9 @@ module AnimateIt
27
27
  AnimateIt.reload_compositions!
28
28
  end
29
29
  end
30
+
31
+ initializer "animate_it.embed_helper" do
32
+ ActiveSupport.on_load(:action_view) { include AnimateIt::EmbedHelper }
33
+ end
30
34
  end
31
35
  end