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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +166 -0
  5. data/app/controllers/animate_it/application_controller.rb +73 -0
  6. data/app/controllers/animate_it/audio_controller.rb +31 -0
  7. data/app/controllers/animate_it/frames_controller.rb +22 -0
  8. data/app/controllers/animate_it/props_controller.rb +10 -0
  9. data/app/controllers/animate_it/renders_controller.rb +82 -0
  10. data/app/controllers/animate_it/studio_controller.rb +17 -0
  11. data/app/jobs/animate_it/application_job.rb +6 -0
  12. data/app/jobs/animate_it/render_job.rb +28 -0
  13. data/app/views/animate_it/frames/filmstrip.html.haml +65 -0
  14. data/app/views/animate_it/frames/fragment.html.haml +6 -0
  15. data/app/views/animate_it/frames/show.html.haml +54 -0
  16. data/app/views/animate_it/props/_form.html.haml +3 -0
  17. data/app/views/animate_it/props/update.html.haml +8 -0
  18. data/app/views/animate_it/renders/create.html.haml +9 -0
  19. data/app/views/animate_it/renders/show.html.haml +10 -0
  20. data/app/views/animate_it/studio/_composition_list.html.haml +7 -0
  21. data/app/views/animate_it/studio/_preview_pane.html.haml +35 -0
  22. data/app/views/animate_it/studio/_props_pane.html.haml +14 -0
  23. data/app/views/animate_it/studio/_studio_script.html.haml +273 -0
  24. data/app/views/animate_it/studio/_studio_styles.html.haml +284 -0
  25. data/app/views/animate_it/studio/_timeline_lanes.html.haml +34 -0
  26. data/app/views/animate_it/studio/index.html.haml +8 -0
  27. data/app/views/animate_it/studio/show.html.haml +23 -0
  28. data/app/views/layouts/animate_it/application.html.haml +17 -0
  29. data/config/routes.rb +12 -0
  30. data/exe/render_animate_it_video +53 -0
  31. data/lib/animate_it/animation.rb +189 -0
  32. data/lib/animate_it/animation_helpers.rb +86 -0
  33. data/lib/animate_it/asset_renderer.rb +49 -0
  34. data/lib/animate_it/beats.rb +61 -0
  35. data/lib/animate_it/composition.rb +354 -0
  36. data/lib/animate_it/configuration.rb +27 -0
  37. data/lib/animate_it/easing.rb +40 -0
  38. data/lib/animate_it/engine.rb +31 -0
  39. data/lib/animate_it/errors.rb +4 -0
  40. data/lib/animate_it/frame_context.rb +28 -0
  41. data/lib/animate_it/frame_duration.rb +13 -0
  42. data/lib/animate_it/output.rb +57 -0
  43. data/lib/animate_it/props_schema.rb +49 -0
  44. data/lib/animate_it/registry.rb +30 -0
  45. data/lib/animate_it/render_store.rb +189 -0
  46. data/lib/animate_it/scene.rb +237 -0
  47. data/lib/animate_it/style.rb +23 -0
  48. data/lib/animate_it/timeline.rb +81 -0
  49. data/lib/animate_it/timing.rb +63 -0
  50. data/lib/animate_it/units.rb +38 -0
  51. data/lib/animate_it/version.rb +3 -0
  52. data/lib/animate_it/video_renderer.rb +219 -0
  53. data/lib/animate_it/view_helpers.rb +84 -0
  54. data/lib/animate_it.rb +93 -0
  55. data/lib/generators/animate_it/install/install_generator.rb +40 -0
  56. data/lib/generators/animate_it/install/templates/animate_it.rb +8 -0
  57. data/lib/tasks/animate_it_tasks.rake +83 -0
  58. 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