animate_it 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/MIT-LICENSE +21 -0
- data/README.md +166 -0
- data/app/controllers/animate_it/application_controller.rb +73 -0
- data/app/controllers/animate_it/audio_controller.rb +31 -0
- data/app/controllers/animate_it/frames_controller.rb +22 -0
- data/app/controllers/animate_it/props_controller.rb +10 -0
- data/app/controllers/animate_it/renders_controller.rb +82 -0
- data/app/controllers/animate_it/studio_controller.rb +17 -0
- data/app/jobs/animate_it/application_job.rb +6 -0
- data/app/jobs/animate_it/render_job.rb +28 -0
- data/app/views/animate_it/frames/filmstrip.html.haml +65 -0
- data/app/views/animate_it/frames/fragment.html.haml +6 -0
- data/app/views/animate_it/frames/show.html.haml +54 -0
- data/app/views/animate_it/props/_form.html.haml +3 -0
- data/app/views/animate_it/props/update.html.haml +8 -0
- data/app/views/animate_it/renders/create.html.haml +9 -0
- data/app/views/animate_it/renders/show.html.haml +10 -0
- data/app/views/animate_it/studio/_composition_list.html.haml +7 -0
- data/app/views/animate_it/studio/_preview_pane.html.haml +35 -0
- data/app/views/animate_it/studio/_props_pane.html.haml +14 -0
- data/app/views/animate_it/studio/_studio_script.html.haml +273 -0
- data/app/views/animate_it/studio/_studio_styles.html.haml +284 -0
- data/app/views/animate_it/studio/_timeline_lanes.html.haml +34 -0
- data/app/views/animate_it/studio/index.html.haml +8 -0
- data/app/views/animate_it/studio/show.html.haml +23 -0
- data/app/views/layouts/animate_it/application.html.haml +17 -0
- data/config/routes.rb +12 -0
- data/exe/render_animate_it_video +53 -0
- data/lib/animate_it/animation.rb +189 -0
- data/lib/animate_it/animation_helpers.rb +86 -0
- data/lib/animate_it/asset_renderer.rb +49 -0
- data/lib/animate_it/beats.rb +61 -0
- data/lib/animate_it/composition.rb +354 -0
- data/lib/animate_it/configuration.rb +27 -0
- data/lib/animate_it/easing.rb +40 -0
- data/lib/animate_it/engine.rb +31 -0
- data/lib/animate_it/errors.rb +4 -0
- data/lib/animate_it/frame_context.rb +28 -0
- data/lib/animate_it/frame_duration.rb +13 -0
- data/lib/animate_it/output.rb +57 -0
- data/lib/animate_it/props_schema.rb +49 -0
- data/lib/animate_it/registry.rb +30 -0
- data/lib/animate_it/render_store.rb +189 -0
- data/lib/animate_it/scene.rb +237 -0
- data/lib/animate_it/style.rb +23 -0
- data/lib/animate_it/timeline.rb +81 -0
- data/lib/animate_it/timing.rb +63 -0
- data/lib/animate_it/units.rb +38 -0
- data/lib/animate_it/version.rb +3 -0
- data/lib/animate_it/video_renderer.rb +219 -0
- data/lib/animate_it/view_helpers.rb +84 -0
- data/lib/animate_it.rb +93 -0
- data/lib/generators/animate_it/install/install_generator.rb +40 -0
- data/lib/generators/animate_it/install/templates/animate_it.rb +8 -0
- data/lib/tasks/animate_it_tasks.rake +83 -0
- metadata +120 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
%section.animate-it-preview-pane
|
|
2
|
+
%header.animate-it-header
|
|
3
|
+
%div
|
|
4
|
+
%h2= composition.id
|
|
5
|
+
%p= "#{composition.width}x#{composition.height}, #{composition.fps}fps, #{composition.duration_in_frames} frames"
|
|
6
|
+
.animate-it-frame-readout
|
|
7
|
+
%strong#animate_it_frame_label 0
|
|
8
|
+
%span= "/ #{last_frame}"
|
|
9
|
+
|
|
10
|
+
%turbo-frame#animate_it_preview.animate-it-preview
|
|
11
|
+
%iframe#animate_it_frame{
|
|
12
|
+
title: "#{composition.id} frame preview",
|
|
13
|
+
src: "#{frame_base_url}/0?pp=disable",
|
|
14
|
+
style: "width: min(100%, #{composition.width}px); aspect-ratio: #{composition.width} / #{composition.height};"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
%section.animate-it-timeline{ aria: { label: "Frame controls" } }
|
|
18
|
+
%input#animate_it_scrubber{ type: "range", min: 0, max: last_frame, value: 0, step: 1 }
|
|
19
|
+
|
|
20
|
+
.animate-it-controls
|
|
21
|
+
%button{ type: "button", data: { motion_step: "-1" } } Previous Frame
|
|
22
|
+
%button#animate_it_play{ type: "button" } Play
|
|
23
|
+
%button{ type: "button", data: { motion_step: "1" } } Next Frame
|
|
24
|
+
%label
|
|
25
|
+
Frame
|
|
26
|
+
%input#animate_it_frame_input{ type: "number", min: 0, max: last_frame, value: 0 }
|
|
27
|
+
%label
|
|
28
|
+
Speed
|
|
29
|
+
%select#animate_it_speed
|
|
30
|
+
%option{ value: "0.25" } 0.25x
|
|
31
|
+
%option{ value: "0.5" } 0.5x
|
|
32
|
+
%option{ value: "1", selected: true } 1x
|
|
33
|
+
%option{ value: "2" } 2x
|
|
34
|
+
|
|
35
|
+
= render "timeline_lanes", composition: composition
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
%aside.animate-it-props
|
|
2
|
+
%h2 Props
|
|
3
|
+
%turbo-frame#animate_it_props
|
|
4
|
+
= render "animate_it/props/form", composition:, props_json:
|
|
5
|
+
|
|
6
|
+
%turbo-frame#animate_it_render_result
|
|
7
|
+
= form_with url: animate_it_path("/compositions/#{composition.id}/renders"), method: :post, html: { style: "margin-top: 1rem;" }, data: { turbo_frame: "animate_it_render_result" } do
|
|
8
|
+
= button_tag "Render Video", type: "submit", data: { turbo_submits_with: "Rendering..." }
|
|
9
|
+
|
|
10
|
+
%section.animate-it-renders
|
|
11
|
+
%h3 Rendering jobs
|
|
12
|
+
= turbo_stream_from AnimateIt::RenderStore::CHANNEL
|
|
13
|
+
%p.animate-it-stream-status Turbo Stream connected for render progress.
|
|
14
|
+
= AnimateIt::RenderStore.html.html_safe
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
:javascript
|
|
2
|
+
(() => {
|
|
3
|
+
const studio = document.querySelector(".animate-it-studio");
|
|
4
|
+
if (!studio) return;
|
|
5
|
+
|
|
6
|
+
const frameBaseUrl = studio.dataset.frameBaseUrl;
|
|
7
|
+
const duration = Number(studio.dataset.duration);
|
|
8
|
+
const fps = Number(studio.dataset.fps);
|
|
9
|
+
const lastFrame = duration - 1;
|
|
10
|
+
const iframe = document.getElementById("animate_it_frame");
|
|
11
|
+
const scrubber = document.getElementById("animate_it_scrubber");
|
|
12
|
+
const frameInput = document.getElementById("animate_it_frame_input");
|
|
13
|
+
const frameLabel = document.getElementById("animate_it_frame_label");
|
|
14
|
+
const playButton = document.getElementById("animate_it_play");
|
|
15
|
+
const speedInput = document.getElementById("animate_it_speed");
|
|
16
|
+
const propsInput = document.getElementById("animate_it_props_json");
|
|
17
|
+
|
|
18
|
+
const clampFrame = (value) => Math.max(0, Math.min(lastFrame, Number(value) || 0));
|
|
19
|
+
const initialParams = new URLSearchParams(window.location.search);
|
|
20
|
+
let frame = clampFrame(initialParams.get("frame"));
|
|
21
|
+
let timer = null;
|
|
22
|
+
|
|
23
|
+
const syncFrameUrl = () => {
|
|
24
|
+
const url = new URL(window.location.href);
|
|
25
|
+
url.searchParams.set("frame", frame);
|
|
26
|
+
window.history.replaceState(window.history.state, "", url);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const propsQuery = () => {
|
|
30
|
+
const params = new URLSearchParams({ pp: "disable" });
|
|
31
|
+
if (propsInput && propsInput.value.trim() !== "") {
|
|
32
|
+
params.set("props_json", propsInput.value);
|
|
33
|
+
}
|
|
34
|
+
return `?${params.toString()}`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// --- Persistent-document rendering ------------------------------------
|
|
38
|
+
// The first frame loads the full styled document into the iframe once, so
|
|
39
|
+
// its <head>/stylesheets are parsed a single time. Every later frame is
|
|
40
|
+
// applied by fetching the lightweight `?only=body` fragment and swapping
|
|
41
|
+
// it into the live DOM — no document teardown, so no reload flash.
|
|
42
|
+
let docReady = false;
|
|
43
|
+
iframe.addEventListener("load", () => { docReady = true; });
|
|
44
|
+
|
|
45
|
+
const fragmentUrl = (n) => `${frameBaseUrl}/${n}${propsQuery()}&only=body`;
|
|
46
|
+
|
|
47
|
+
const fullReload = (n) => {
|
|
48
|
+
docReady = false;
|
|
49
|
+
iframe.src = `${frameBaseUrl}/${n}${propsQuery()}`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Fragment cache (url -> html) plus in-flight de-duplication, so a frame is
|
|
53
|
+
// fetched at most once. A cached frame applies synchronously — no network
|
|
54
|
+
// wait, no first-visit hitch. Keyed by url, which includes the props, so a
|
|
55
|
+
// props change naturally misses; we also clear it on props edits below.
|
|
56
|
+
const PREFETCH_AHEAD = 16;
|
|
57
|
+
const fragmentCache = new Map();
|
|
58
|
+
const pending = new Map();
|
|
59
|
+
|
|
60
|
+
const requestFragment = (url) => {
|
|
61
|
+
if (fragmentCache.has(url)) return Promise.resolve(fragmentCache.get(url));
|
|
62
|
+
if (pending.has(url)) return pending.get(url);
|
|
63
|
+
const p = fetch(url)
|
|
64
|
+
.then((res) => { if (!res.ok) throw new Error(`fragment ${res.status}`); return res.text(); })
|
|
65
|
+
.then((html) => { fragmentCache.set(url, html); pending.delete(url); return html; })
|
|
66
|
+
.catch((err) => { pending.delete(url); throw err; });
|
|
67
|
+
pending.set(url, p);
|
|
68
|
+
return p;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const prefetchAhead = (n) => {
|
|
72
|
+
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
|
|
73
|
+
const f = n + i;
|
|
74
|
+
if (f > lastFrame) break;
|
|
75
|
+
requestFragment(fragmentUrl(f)).catch(() => {}); // fire-and-forget warm-up
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const applyFragment = (html) => {
|
|
80
|
+
const target = iframe.contentDocument && iframe.contentDocument.querySelector(".animate-it-frame");
|
|
81
|
+
if (!target) return false;
|
|
82
|
+
target.outerHTML = html;
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Latest-wins: a slow fetch for an earlier frame must not overwrite a
|
|
87
|
+
// newer one that already painted.
|
|
88
|
+
let paintToken = 0;
|
|
89
|
+
const paintFragment = (n) => {
|
|
90
|
+
const token = ++paintToken;
|
|
91
|
+
const url = fragmentUrl(n);
|
|
92
|
+
if (fragmentCache.has(url)) {
|
|
93
|
+
if (!applyFragment(fragmentCache.get(url))) fullReload(n);
|
|
94
|
+
prefetchAhead(n);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
requestFragment(url)
|
|
98
|
+
.then((html) => {
|
|
99
|
+
if (token !== paintToken) return; // superseded by a newer frame
|
|
100
|
+
if (!applyFragment(html)) fullReload(n);
|
|
101
|
+
prefetchAhead(n);
|
|
102
|
+
})
|
|
103
|
+
.catch(() => { if (token === paintToken) fullReload(n); });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const renderFrame = (n) => {
|
|
107
|
+
if (docReady) paintFragment(n);
|
|
108
|
+
else fullReload(n); // bootstrap (or recover) with the full document
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const playheads = document.querySelectorAll("[data-animate-it-playhead]");
|
|
112
|
+
const audios = Array.from(document.querySelectorAll("audio[data-from-frame]"));
|
|
113
|
+
|
|
114
|
+
// Resolve each segment's window once. A non-positive duration means "active
|
|
115
|
+
// to the end" — matches Timeline::Segment#active_at? on the Ruby side.
|
|
116
|
+
const audioWindow = (el) => {
|
|
117
|
+
const start = Number(el.dataset.fromFrame) || 0;
|
|
118
|
+
const rawLen = Number(el.dataset.durationFrames);
|
|
119
|
+
const len = rawLen > 0 ? rawLen : duration - start;
|
|
120
|
+
return { start, len };
|
|
121
|
+
};
|
|
122
|
+
|
|
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
|
+
audios.forEach((el) => {
|
|
127
|
+
const { len } = audioWindow(el);
|
|
128
|
+
const isBed = el.dataset.track === "background";
|
|
129
|
+
el.loop = isBed;
|
|
130
|
+
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
|
+
el.addEventListener("timeupdate", () => {
|
|
134
|
+
if (isBed) return;
|
|
135
|
+
if (el.currentTime >= len / fps && !el.paused) el.pause();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const updatePlayheads = (currentFrame) => {
|
|
140
|
+
const pct = duration <= 1 ? 0 : (currentFrame / (duration - 1)) * 100;
|
|
141
|
+
playheads.forEach((el) => { el.style.left = `${pct}%`; });
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Audio is audible only while actually playing — seeking/scrubbing is
|
|
145
|
+
// silent. Each element plays only inside its own window, seeked to the
|
|
146
|
+
// matching offset, so you hear exactly what belongs at the current frame.
|
|
147
|
+
const syncAudio = (currentFrame, isPlaying) => {
|
|
148
|
+
for (const el of audios) {
|
|
149
|
+
const { start, len } = audioWindow(el);
|
|
150
|
+
const within = currentFrame >= start && currentFrame < start + len;
|
|
151
|
+
if (within && isPlaying) {
|
|
152
|
+
// Seek only on (re)start — when the clip is paused. Once it's
|
|
153
|
+
// playing, leave it on its own real-time clock: re-seeking every
|
|
154
|
+
// frame to chase the setInterval-driven frame counter (which lags
|
|
155
|
+
// real time) is what chops the audio.
|
|
156
|
+
if (el.paused) {
|
|
157
|
+
el.currentTime = (currentFrame - start) / fps;
|
|
158
|
+
el.play().catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
} else if (!el.paused) {
|
|
161
|
+
el.pause();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const setFrame = (nextFrame, { keepAudioState = false } = {}) => {
|
|
167
|
+
frame = clampFrame(nextFrame);
|
|
168
|
+
scrubber.value = frame;
|
|
169
|
+
frameInput.value = frame;
|
|
170
|
+
frameLabel.textContent = frame;
|
|
171
|
+
renderFrame(frame);
|
|
172
|
+
syncFrameUrl();
|
|
173
|
+
updatePlayheads(frame);
|
|
174
|
+
syncAudio(frame, keepAudioState && timer !== null);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const stop = () => {
|
|
178
|
+
if (timer) window.cancelAnimationFrame(timer);
|
|
179
|
+
timer = null;
|
|
180
|
+
playButton.textContent = "Play";
|
|
181
|
+
audios.forEach((el) => { if (!el.paused) el.pause(); });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Wall-clock playback. The frame is derived from elapsed real time, so the
|
|
185
|
+
// picture skips frames it can't keep up with rather than the clock slipping
|
|
186
|
+
// behind — and audio (on its own real clock) stays in sync. Morphing the
|
|
187
|
+
// body in place is fast and needs no load-gating, so the loop stays simple.
|
|
188
|
+
const play = () => {
|
|
189
|
+
stop();
|
|
190
|
+
playButton.textContent = "Pause";
|
|
191
|
+
const speed = Number(speedInput.value) || 1;
|
|
192
|
+
const startFrame = frame >= lastFrame ? 0 : frame;
|
|
193
|
+
const startWall = performance.now();
|
|
194
|
+
const tick = () => {
|
|
195
|
+
if (timer === null) return;
|
|
196
|
+
const next = clampFrame(Math.floor(startFrame + ((performance.now() - startWall) / 1000) * fps * speed));
|
|
197
|
+
if (next !== frame) setFrame(next, { keepAudioState: true });
|
|
198
|
+
if (next >= lastFrame) { stop(); return; }
|
|
199
|
+
timer = window.requestAnimationFrame(tick);
|
|
200
|
+
};
|
|
201
|
+
timer = window.requestAnimationFrame(tick);
|
|
202
|
+
syncAudio(frame, true);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
scrubber.addEventListener("input", (event) => setFrame(event.target.value));
|
|
206
|
+
frameInput.addEventListener("change", (event) => setFrame(event.target.value));
|
|
207
|
+
propsInput?.addEventListener("change", () => {
|
|
208
|
+
fragmentCache.clear();
|
|
209
|
+
pending.clear();
|
|
210
|
+
setFrame(frame);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
document.querySelectorAll("[data-motion-step]").forEach((button) => {
|
|
214
|
+
button.addEventListener("click", () => {
|
|
215
|
+
stop();
|
|
216
|
+
setFrame(frame + Number(button.dataset.motionStep));
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
document.querySelectorAll("[data-motion-seek]").forEach((el) => {
|
|
221
|
+
el.addEventListener("click", () => {
|
|
222
|
+
stop();
|
|
223
|
+
setFrame(Number(el.dataset.motionSeek));
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
playButton.addEventListener("click", () => {
|
|
228
|
+
if (timer) {
|
|
229
|
+
stop();
|
|
230
|
+
} else {
|
|
231
|
+
play();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
window.addEventListener("keydown", (event) => {
|
|
236
|
+
// Only the props textarea is real text entry — let it keep every key.
|
|
237
|
+
// The scrubber/number/select shouldn't swallow the transport shortcuts.
|
|
238
|
+
if (event.target.matches("textarea")) return;
|
|
239
|
+
|
|
240
|
+
if (event.key === " ") {
|
|
241
|
+
// Toggle directly and drop focus first: calling playButton.click()
|
|
242
|
+
// while the button (or scrubber) is focused lets the browser also
|
|
243
|
+
// natively activate it on Space, double-toggling to a no-op.
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
246
|
+
timer === null ? play() : stop();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Arrows: defer to native behavior when a control is focused (range /
|
|
251
|
+
// number / select all move on their own), step frames otherwise.
|
|
252
|
+
if (event.target.matches("input, select")) return;
|
|
253
|
+
|
|
254
|
+
if (event.key === "ArrowLeft") {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
stop();
|
|
257
|
+
setFrame(frame - 1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (event.key === "ArrowRight") {
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
stop();
|
|
263
|
+
setFrame(frame + 1);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
window.AnimateItStudio = {
|
|
268
|
+
setFrame,
|
|
269
|
+
currentFrame: () => frame
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
setFrame(frame);
|
|
273
|
+
})();
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
:css
|
|
2
|
+
.animate-it-studio {
|
|
3
|
+
min-height: 100vh;
|
|
4
|
+
display: grid;
|
|
5
|
+
grid-template-columns: 17rem minmax(30rem, 1fr) 24rem;
|
|
6
|
+
gap: 1rem;
|
|
7
|
+
padding: 1rem;
|
|
8
|
+
font-family: system-ui, sans-serif;
|
|
9
|
+
background: #f8fafc;
|
|
10
|
+
color: #111827;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.animate-it-sidebar,
|
|
14
|
+
.animate-it-preview-pane,
|
|
15
|
+
.animate-it-props {
|
|
16
|
+
background: #fff;
|
|
17
|
+
border: 1px solid #e5e7eb;
|
|
18
|
+
border-radius: 1rem;
|
|
19
|
+
padding: 1rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.animate-it-sidebar h1,
|
|
23
|
+
.animate-it-props h2,
|
|
24
|
+
.animate-it-header h2 {
|
|
25
|
+
margin-top: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.animate-it-header {
|
|
29
|
+
display: flex;
|
|
30
|
+
justify-content: space-between;
|
|
31
|
+
gap: 1rem;
|
|
32
|
+
align-items: start;
|
|
33
|
+
margin-bottom: 1rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.animate-it-header p {
|
|
37
|
+
margin: 0.25rem 0 0;
|
|
38
|
+
color: #6b7280;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.animate-it-frame-readout {
|
|
42
|
+
text-align: right;
|
|
43
|
+
font-variant-numeric: tabular-nums;
|
|
44
|
+
color: #6b7280;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.animate-it-frame-readout strong {
|
|
48
|
+
display: block;
|
|
49
|
+
font-size: 2rem;
|
|
50
|
+
color: #111827;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.animate-it-preview {
|
|
54
|
+
position: relative;
|
|
55
|
+
display: block;
|
|
56
|
+
border-radius: 0.75rem;
|
|
57
|
+
overflow: visible;
|
|
58
|
+
background:
|
|
59
|
+
linear-gradient(45deg, #e5e7eb 25%, transparent 25%),
|
|
60
|
+
linear-gradient(-45deg, #e5e7eb 25%, transparent 25%),
|
|
61
|
+
linear-gradient(45deg, transparent 75%, #e5e7eb 75%),
|
|
62
|
+
linear-gradient(-45deg, transparent 75%, #e5e7eb 75%);
|
|
63
|
+
background-color: #f8fafc;
|
|
64
|
+
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
|
|
65
|
+
background-size: 24px 24px;
|
|
66
|
+
border: 1px solid #d1d5db;
|
|
67
|
+
padding: 12px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.animate-it-preview::before {
|
|
71
|
+
content: "video frame boundary";
|
|
72
|
+
position: absolute;
|
|
73
|
+
top: -0.75rem;
|
|
74
|
+
left: 1.25rem;
|
|
75
|
+
z-index: 2;
|
|
76
|
+
padding: 0 0.4rem;
|
|
77
|
+
background: #fff;
|
|
78
|
+
color: #dc2626;
|
|
79
|
+
font-size: 0.75rem;
|
|
80
|
+
font-weight: 700;
|
|
81
|
+
letter-spacing: 0.04em;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.animate-it-preview iframe {
|
|
86
|
+
display: block;
|
|
87
|
+
border: 3px dashed #ef4444;
|
|
88
|
+
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.25), 0 18px 40px rgba(15, 23, 42, 0.16);
|
|
89
|
+
background: transparent;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.animate-it-timeline {
|
|
93
|
+
margin-top: 1rem;
|
|
94
|
+
display: grid;
|
|
95
|
+
gap: 0.75rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#animate_it_scrubber {
|
|
99
|
+
width: 100%;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.animate-it-controls {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 0.5rem;
|
|
106
|
+
flex-wrap: wrap;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.animate-it-controls button,
|
|
110
|
+
.animate-it-controls input,
|
|
111
|
+
.animate-it-controls select,
|
|
112
|
+
.animate-it-props button {
|
|
113
|
+
font: inherit;
|
|
114
|
+
border: 1px solid #d1d5db;
|
|
115
|
+
border-radius: 0.5rem;
|
|
116
|
+
padding: 0.45rem 0.65rem;
|
|
117
|
+
background: #fff;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.animate-it-controls button:hover,
|
|
121
|
+
.animate-it-props button:hover {
|
|
122
|
+
background: #f3f4f6;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.animate-it-controls label {
|
|
126
|
+
display: inline-flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: 0.35rem;
|
|
129
|
+
color: #4b5563;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#animate_it_frame_input {
|
|
133
|
+
width: 5rem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#animate_it_props_json {
|
|
137
|
+
width: 100%;
|
|
138
|
+
min-height: 19rem;
|
|
139
|
+
box-sizing: border-box;
|
|
140
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
141
|
+
font-size: 0.875rem;
|
|
142
|
+
border: 1px solid #d1d5db;
|
|
143
|
+
border-radius: 0.75rem;
|
|
144
|
+
padding: 0.75rem;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.animate-it-renders {
|
|
148
|
+
margin-top: 1.25rem;
|
|
149
|
+
min-width: 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.animate-it-renders h3 {
|
|
153
|
+
margin: 0 0 0.5rem;
|
|
154
|
+
font-size: 1rem;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.animate-it-stream-status {
|
|
158
|
+
margin: 0 0 0.75rem;
|
|
159
|
+
color: #059669;
|
|
160
|
+
font-size: 0.85rem;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.animate-it-render-row {
|
|
164
|
+
display: grid;
|
|
165
|
+
gap: 0.35rem;
|
|
166
|
+
min-width: 0;
|
|
167
|
+
max-width: 100%;
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
padding: 0.75rem;
|
|
170
|
+
border: 1px solid #e5e7eb;
|
|
171
|
+
border-radius: 0.75rem;
|
|
172
|
+
background: #f9fafb;
|
|
173
|
+
font-size: 0.9rem;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.animate-it-render-row + .animate-it-render-row {
|
|
177
|
+
margin-top: 0.5rem;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.animate-it-render-row progress {
|
|
181
|
+
width: 100%;
|
|
182
|
+
max-width: 100%;
|
|
183
|
+
min-width: 0;
|
|
184
|
+
display: block;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.animate-it-render-row code,
|
|
188
|
+
.animate-it-render-path {
|
|
189
|
+
display: block;
|
|
190
|
+
max-width: 100%;
|
|
191
|
+
min-width: 0;
|
|
192
|
+
overflow-wrap: anywhere;
|
|
193
|
+
word-break: break-word;
|
|
194
|
+
white-space: normal;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.animate-it-timeline-lanes {
|
|
198
|
+
margin-top: 1.25rem;
|
|
199
|
+
border-top: 1px solid #e5e7eb;
|
|
200
|
+
padding-top: 1rem;
|
|
201
|
+
}
|
|
202
|
+
.animate-it-timeline-lanes h3 {
|
|
203
|
+
margin: 0;
|
|
204
|
+
font-size: 0.95rem;
|
|
205
|
+
}
|
|
206
|
+
.animate-it-timeline-lanes__header {
|
|
207
|
+
display: grid;
|
|
208
|
+
grid-template-columns: 7rem 1fr;
|
|
209
|
+
align-items: center;
|
|
210
|
+
margin-bottom: 0.5rem;
|
|
211
|
+
}
|
|
212
|
+
.animate-it-timeline-lanes__ruler {
|
|
213
|
+
position: relative;
|
|
214
|
+
height: 1.25rem;
|
|
215
|
+
color: #6b7280;
|
|
216
|
+
font-size: 0.7rem;
|
|
217
|
+
font-variant-numeric: tabular-nums;
|
|
218
|
+
}
|
|
219
|
+
.animate-it-timeline-lanes__tick {
|
|
220
|
+
position: absolute;
|
|
221
|
+
top: 0;
|
|
222
|
+
transform: translateX(-50%);
|
|
223
|
+
border-left: 1px solid #d1d5db;
|
|
224
|
+
padding-left: 0.25rem;
|
|
225
|
+
line-height: 1.25rem;
|
|
226
|
+
}
|
|
227
|
+
.animate-it-timeline-lanes__tick:first-child { transform: none; padding-left: 0.15rem; }
|
|
228
|
+
.animate-it-timeline-lanes__tick:last-child { transform: translateX(-100%); padding-left: 0; padding-right: 0.15rem; }
|
|
229
|
+
|
|
230
|
+
.animate-it-timeline-lanes__row {
|
|
231
|
+
display: grid;
|
|
232
|
+
grid-template-columns: 7rem 1fr;
|
|
233
|
+
gap: 0.5rem;
|
|
234
|
+
margin: 0.4rem 0;
|
|
235
|
+
}
|
|
236
|
+
.animate-it-timeline-lanes__label {
|
|
237
|
+
color: #4b5563;
|
|
238
|
+
font-weight: 600;
|
|
239
|
+
font-size: 0.8rem;
|
|
240
|
+
text-transform: uppercase;
|
|
241
|
+
letter-spacing: 0.04em;
|
|
242
|
+
line-height: 2.5rem;
|
|
243
|
+
}
|
|
244
|
+
.animate-it-timeline-lanes__lane {
|
|
245
|
+
position: relative;
|
|
246
|
+
height: 2.5rem;
|
|
247
|
+
background: #f3f4f6;
|
|
248
|
+
border-radius: 0.5rem;
|
|
249
|
+
overflow: hidden;
|
|
250
|
+
}
|
|
251
|
+
.animate-it-timeline-lanes__playhead {
|
|
252
|
+
position: absolute;
|
|
253
|
+
top: 0;
|
|
254
|
+
bottom: 0;
|
|
255
|
+
width: 2px;
|
|
256
|
+
background: #ef4444;
|
|
257
|
+
pointer-events: none;
|
|
258
|
+
transform: translateX(-1px);
|
|
259
|
+
z-index: 2;
|
|
260
|
+
}
|
|
261
|
+
.animate-it-timeline-lanes__chip {
|
|
262
|
+
position: absolute;
|
|
263
|
+
top: 0.25rem;
|
|
264
|
+
bottom: 0.25rem;
|
|
265
|
+
border: 1px solid transparent;
|
|
266
|
+
border-radius: 0.4rem;
|
|
267
|
+
padding: 0 0.6rem;
|
|
268
|
+
font: inherit;
|
|
269
|
+
font-size: 0.75rem;
|
|
270
|
+
text-align: left;
|
|
271
|
+
color: #fff;
|
|
272
|
+
cursor: pointer;
|
|
273
|
+
overflow: hidden;
|
|
274
|
+
display: flex;
|
|
275
|
+
flex-direction: column;
|
|
276
|
+
justify-content: center;
|
|
277
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
|
278
|
+
}
|
|
279
|
+
.animate-it-timeline-lanes__chip:hover { filter: brightness(1.05); }
|
|
280
|
+
.animate-it-timeline-lanes__chip--scene { background: linear-gradient(135deg, #6366f1, #4338ca); }
|
|
281
|
+
.animate-it-timeline-lanes__chip--audio { background: linear-gradient(135deg, #10b981, #047857); }
|
|
282
|
+
.animate-it-timeline-lanes__chip--transition { background: linear-gradient(135deg, #f59e0b, #b45309); }
|
|
283
|
+
.animate-it-timeline-lanes__chip-name { font-weight: 700; }
|
|
284
|
+
.animate-it-timeline-lanes__chip-time { opacity: 0.85; font-variant-numeric: tabular-nums; }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-# Visual timeline beneath the preview/scrubber. One row per track, chips
|
|
2
|
+
-# positioned by from_frame / duration_in_frames. Clicking a chip seeks the
|
|
3
|
+
-# scrubber via the data-motion-seek hook in _studio_script.
|
|
4
|
+
- total_frames = composition.duration_in_frames
|
|
5
|
+
- format_time = ->(frame) { secs = frame.to_f / composition.fps; sprintf("%d:%02d.%02d", (secs / 60).to_i, secs.to_i % 60, ((secs % 1) * 100).to_i) }
|
|
6
|
+
|
|
7
|
+
%section.animate-it-timeline-lanes{ aria: { label: "Track timeline" } }
|
|
8
|
+
%header.animate-it-timeline-lanes__header
|
|
9
|
+
%h3 Tracks
|
|
10
|
+
.animate-it-timeline-lanes__ruler
|
|
11
|
+
- 5.times do |i|
|
|
12
|
+
- tick_frame = (total_frames * i / 4.0).round
|
|
13
|
+
%span.animate-it-timeline-lanes__tick{ style: "left: #{(i / 4.0 * 100).round(2)}%;" }
|
|
14
|
+
= format_time.call(tick_frame)
|
|
15
|
+
|
|
16
|
+
- composition.timeline.tracks.each do |track|
|
|
17
|
+
.animate-it-timeline-lanes__row{ data: { track: track } }
|
|
18
|
+
.animate-it-timeline-lanes__label= track.to_s
|
|
19
|
+
.animate-it-timeline-lanes__lane
|
|
20
|
+
%span.animate-it-timeline-lanes__playhead{ data: { animate_it_playhead: true } }
|
|
21
|
+
- composition.timeline.segments_for_track(track).each do |seg|
|
|
22
|
+
- dur = seg.duration_frames || (total_frames - seg.from_frame)
|
|
23
|
+
- left_pct = (seg.from_frame.to_f / total_frames * 100).round(3)
|
|
24
|
+
- width_pct = (dur.to_f / total_frames * 100).round(3)
|
|
25
|
+
- chip_label = seg.name.to_s.split("::").last
|
|
26
|
+
- chip_class = ["animate-it-timeline-lanes__chip", "animate-it-timeline-lanes__chip--#{seg.kind}"]
|
|
27
|
+
%button{ type: "button",
|
|
28
|
+
class: chip_class,
|
|
29
|
+
data: { motion_seek: seg.from_frame, kind: seg.kind, from: seg.from_frame, duration: dur },
|
|
30
|
+
style: "left: #{left_pct}%; width: #{width_pct}%;",
|
|
31
|
+
title: "#{chip_label} · #{format_time.call(seg.from_frame)} → #{format_time.call(seg.from_frame + dur)}" }
|
|
32
|
+
%span.animate-it-timeline-lanes__chip-name= chip_label
|
|
33
|
+
%span.animate-it-timeline-lanes__chip-time
|
|
34
|
+
= "#{format_time.call(seg.from_frame)} → #{format_time.call(seg.from_frame + dur)}"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
- content_for :title, "Animate It Studio"
|
|
2
|
+
|
|
3
|
+
%main{ style: "padding: 2rem; font-family: system-ui, sans-serif;" }
|
|
4
|
+
%h1 Animate It Studio
|
|
5
|
+
%p Registered compositions
|
|
6
|
+
|
|
7
|
+
%turbo-frame#animate_it_compositions
|
|
8
|
+
= render "composition_list", compositions: @compositions
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
- content_for :title, @composition.id
|
|
2
|
+
|
|
3
|
+
= render "studio_styles"
|
|
4
|
+
|
|
5
|
+
%main.animate-it-studio{ data: { frame_base_url: @frame_base_url, duration: @composition.duration_in_frames, fps: @composition.fps } }
|
|
6
|
+
%aside.animate-it-sidebar
|
|
7
|
+
%h1 Animate It
|
|
8
|
+
= render "composition_list", compositions: @compositions
|
|
9
|
+
|
|
10
|
+
= render "preview_pane", composition: @composition, frame_base_url: @frame_base_url, last_frame: @last_frame
|
|
11
|
+
= render "props_pane", composition: @composition, props_json: @props_json
|
|
12
|
+
|
|
13
|
+
- @composition.timeline.segments.select { |seg| seg.kind == :audio }.each_with_index do |seg, idx|
|
|
14
|
+
%audio{ data: {
|
|
15
|
+
from_frame: seg.from_frame,
|
|
16
|
+
duration_frames: seg.duration_frames || 0,
|
|
17
|
+
track: seg.track,
|
|
18
|
+
gain: seg.source[:gain]
|
|
19
|
+
},
|
|
20
|
+
src: animate_it_path("/compositions/#{@composition.id}/audio/#{idx}"),
|
|
21
|
+
preload: "auto" }
|
|
22
|
+
|
|
23
|
+
= render "studio_script"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
!!!
|
|
2
|
+
%html{ lang: "en" }
|
|
3
|
+
%head
|
|
4
|
+
%title= content_for?(:title) ? yield(:title) : "Animate It Studio"
|
|
5
|
+
%meta{ name: "viewport", content: "width=device-width, initial-scale=1" }
|
|
6
|
+
%meta{ name: "turbo-cache-control", content: "no-cache" }
|
|
7
|
+
= csrf_meta_tags
|
|
8
|
+
-# Studio JS/CSS come from the host app's asset pipeline. Emit each tag only
|
|
9
|
+
-# when the host provides it, so the gem still renders in apps that don't run
|
|
10
|
+
-# importmap/turbo/propshaft. (The video renderer captures the filmstrip
|
|
11
|
+
-# endpoint directly and doesn't depend on this layout's assets.)
|
|
12
|
+
- if respond_to?(:javascript_importmap_tags)
|
|
13
|
+
= javascript_importmap_tags
|
|
14
|
+
= javascript_import_module_tag "@hotwired/turbo-rails"
|
|
15
|
+
= stylesheet_link_tag "application", "data-turbo-track": "reload"
|
|
16
|
+
%body
|
|
17
|
+
= yield
|