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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/README.md +212 -20
- data/app/controllers/animate_it/embed_assets_controller.rb +25 -0
- data/app/controllers/animate_it/frames_controller.rb +10 -0
- data/app/controllers/animate_it/public_players_controller.rb +73 -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 +92 -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 +10 -0
- data/lib/animate_it/asset_manifest.rb +92 -0
- data/lib/animate_it/asset_renderer.rb +39 -7
- data/lib/animate_it/chapter_navigation.rb +160 -0
- data/lib/animate_it/chapters.rb +95 -0
- data/lib/animate_it/composition.rb +115 -9
- data/lib/animate_it/embed_helper.rb +214 -0
- data/lib/animate_it/embed_runtime/embed.js +338 -0
- data/lib/animate_it/embed_runtime.rb +13 -0
- data/lib/animate_it/embed_styles.rb +126 -0
- data/lib/animate_it/engine.rb +7 -0
- data/lib/animate_it/player_manifest.rb +29 -0
- data/lib/animate_it/runtime/runtime.js +518 -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 +17 -0
- data/lib/tasks/animate_it_tasks.rake +133 -0
- 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
|
-
|
|
13
|
-
|
|
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
|
|
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,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.
|
|
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
|