animate_it 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +131 -19
- data/app/controllers/animate_it/frames_controller.rb +7 -0
- data/app/controllers/animate_it/public_players_controller.rb +69 -0
- data/app/controllers/animate_it/renders_controller.rb +3 -17
- data/app/controllers/animate_it/studio_controller.rb +1 -0
- data/app/jobs/animate_it/render_job.rb +2 -0
- data/app/views/animate_it/frames/filmstrip.html.haml +37 -6
- data/app/views/animate_it/frames/player.html.haml +81 -0
- data/app/views/animate_it/studio/_preview_pane.html.haml +1 -1
- data/app/views/animate_it/studio/_props_pane.html.haml +3 -2
- data/app/views/animate_it/studio/_studio_script.html.haml +71 -48
- data/app/views/animate_it/studio/show.html.haml +10 -3
- data/config/routes.rb +5 -0
- data/lib/animate_it/asset_manifest.rb +92 -0
- data/lib/animate_it/asset_renderer.rb +39 -7
- data/lib/animate_it/composition.rb +100 -9
- data/lib/animate_it/embed_helper.rb +22 -0
- data/lib/animate_it/engine.rb +4 -0
- data/lib/animate_it/runtime/runtime.js +385 -0
- data/lib/animate_it/runtime.rb +11 -0
- data/lib/animate_it/scene.rb +57 -3
- data/lib/animate_it/text_effects.rb +104 -0
- data/lib/animate_it/track_document_schema.rb +71 -0
- data/lib/animate_it/tracks/document.rb +100 -0
- data/lib/animate_it/tracks/layer.rb +13 -0
- data/lib/animate_it/tracks/recorder.rb +149 -0
- data/lib/animate_it/verification.rb +187 -0
- data/lib/animate_it/version.rb +1 -1
- data/lib/animate_it/video_renderer.rb +89 -26
- data/lib/animate_it/view_helpers.rb +11 -1
- data/lib/animate_it.rb +9 -0
- data/lib/tasks/animate_it_tasks.rake +133 -0
- metadata +13 -1
|
@@ -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
|
|
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
|
|
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
|
|
129
|
-
el.loop =
|
|
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 (
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
157
|
+
if (animationFrame === null) return;
|
|
150
158
|
const { start, len } = audioWindow(el);
|
|
151
|
-
if (frame < start || frame >= start + len) return;
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 &&
|
|
190
|
+
syncAudio(frame, keepAudioState && animationFrame !== null);
|
|
187
191
|
};
|
|
188
192
|
|
|
189
193
|
const stop = () => {
|
|
190
|
-
if (
|
|
191
|
-
|
|
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
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
timer = window.requestAnimationFrame(tick);
|
|
222
|
+
animationFrame = window.requestAnimationFrame(tick);
|
|
212
223
|
};
|
|
213
|
-
|
|
224
|
+
animationFrame = window.requestAnimationFrame(tick);
|
|
214
225
|
syncAudio(frame, true);
|
|
215
226
|
};
|
|
216
227
|
|
|
217
|
-
scrubber.addEventListener("input", (event) =>
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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 (
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
4
|
-
#
|
|
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
|
-
|
|
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:
|
|
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(
|
|
148
|
-
|
|
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:
|
|
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
|