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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 178ea946f7dfb1a83da1a68ba2ea514984d2ad1ab9792bbcd7d7fadff5380fc5
4
+ data.tar.gz: ca63aee15a52e08db308a17a2094989131bc1c6c6625f52cfb8dd375ef2b71ec
5
+ SHA512:
6
+ metadata.gz: 7d4f409c4d620768e021529c8d3b7b2de5d5e8b2be1eaa05165c986c25f10e384434842f71b5c5f0062f482dc228d43efc40d65dc18feae776914a91d19985b5
7
+ data.tar.gz: cdc55f7e5159a4ec9854a61b447be969a4710d95b831ff9024611afe5b88650eb29b8de2ba097cc3cfb0c91ac459fdadf60bb213e5d46ffffc279aadb393d251
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-07-08
10
+
11
+ ### Added
12
+ - Initial extraction of AnimateIt into a standalone gem.
13
+ - Declarative composition DSL: `size`, `fps`, `duration`, `beat`, `audio`,
14
+ `audio_loop`, `outputs`, `scene`, `series`.
15
+ - `Scene` render context with animation helpers (`at_global`, `at_act`,
16
+ `beat_frame`, `beat_range`), `fixtures`, and `expose`.
17
+ - Studio UI mounted at `/animate_it` for previewing and rendering compositions.
18
+ - `VideoRenderer`: single-browser Playwright frame capture piped through FFmpeg
19
+ to MP4/WebM/MOV/GIF, with audio mux (`adelay` + `volume` + `amix`).
20
+ - `render_animate_it_video` executable and `animate_it:render` rake task.
21
+ - `animate_it:install` generator.
22
+
23
+ [Unreleased]: https://github.com/growth-constant/animate_it/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/growth-constant/animate_it/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 growth-constant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,166 @@
1
+ <p align="center">
2
+ <img src="assets/logo.svg" alt="AnimateIt" width="360">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <strong>Videos as code.</strong> Declarative, frame-driven video compositions for Rails,
7
+ rendered with headless Chromium (Playwright) + FFmpeg.
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/growth-constant/animate_it/actions/workflows/ci.yml"><img src="https://github.com/growth-constant/animate_it/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
12
+ <a href="https://rubygems.org/gems/animate_it"><img src="https://img.shields.io/gem/v/animate_it.svg" alt="Gem Version"></a>
13
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.4-CC342D.svg" alt="Ruby >= 3.4">
14
+ </p>
15
+
16
+ ---
17
+
18
+ AnimateIt lets you describe a video as a Ruby class and a HAML template, preview it
19
+ frame-by-frame in a bundled **Studio** UI, and render it to MP4/WebM/MOV/GIF. Each
20
+ frame is a real web page — so you get the full power of CSS, your app's own
21
+ components, and real data — captured by a headless browser and stitched with FFmpeg.
22
+
23
+ ## Requirements
24
+
25
+ - Ruby >= 3.4, Rails >= 8.1.3
26
+ - **FFmpeg** on the PATH (rendering)
27
+ - **Node + Playwright** with a Chromium build (rendering): `npx playwright install chromium`
28
+ - **HAML** (composition sidecar templates + Studio views)
29
+
30
+ Playwright and FFmpeg are only needed when you actually render; the gem loads
31
+ Playwright lazily so production boot / asset precompile never touch it.
32
+
33
+ ## Installation
34
+
35
+ ```ruby
36
+ # Gemfile
37
+ gem "animate_it"
38
+ ```
39
+
40
+ ```bash
41
+ bundle install
42
+ bin/rails generate animate_it:install
43
+ ```
44
+
45
+ The generator adds `config/initializers/animate_it.rb` and mounts the Studio in
46
+ `config/routes.rb` (development/test only):
47
+
48
+ ```ruby
49
+ mount AnimateIt::Engine, at: AnimateIt.config.mount_path if Rails.env.local?
50
+ ```
51
+
52
+ Visit **http://localhost:3000/animate_it** to open the Studio.
53
+
54
+ ## Writing a composition
55
+
56
+ Compositions live in `app/videos/`. They auto-register (via the `id "..."` DSL)
57
+ and reload in development.
58
+
59
+ ```ruby
60
+ # app/videos/hello_video.rb
61
+ class HelloVideo < AnimateIt::Composition
62
+ id "hello"
63
+ fps 30
64
+ size 1080, 1080
65
+ duration 3.seconds
66
+
67
+ assets_dir "app/assets/images/videos"
68
+ output_basename "hello"
69
+
70
+ outputs do
71
+ mp4
72
+ gif
73
+ png frame: 45
74
+ end
75
+
76
+ beat :intro, at: 0, length: 45
77
+
78
+ class Scene < AnimateIt::Scene
79
+ def body
80
+ expose(vars: animation_vars(title_opacity: at_act(:intro, [0, 30], [0, 1])))
81
+ tag.div(class: "hero-render-canvas") { render_scene_template("canvas") }
82
+ end
83
+ end
84
+
85
+ scene Scene
86
+ end
87
+ ```
88
+
89
+ ```haml
90
+ -# app/videos/hello_video/canvas.html.haml
91
+ :css
92
+ .title { opacity: var(--title-opacity, 1); font-size: 6rem; font-weight: 800; }
93
+ .stage{style: @vars}
94
+ .title Hello 👋
95
+ ```
96
+
97
+ Animation helpers (`at_global`, `at_act`, `beat_frame`, `beat_range`), a per-frame
98
+ CSS-variable bag (`animation_vars`), named `beat`s, and `audio` / `audio_loop`
99
+ tracks are all available. Compositions can render your app's real partials and
100
+ ViewComponents — see `AnimateIt.config.render_stylesheets` below.
101
+
102
+ ## Rendering
103
+
104
+ The renderer needs your Rails **server running** (it drives a real browser against
105
+ the Studio's filmstrip endpoint).
106
+
107
+ ```bash
108
+ # start the app
109
+ bin/rails server
110
+
111
+ # in another shell — render a composition's declared outputs
112
+ bin/rails 'animate_it:render[hello]'
113
+ bin/rails 'animate_it:render[hello,0..45]' # just a frame range
114
+
115
+ # or the packaged CLI (writes to tmp/animate_it/ by default)
116
+ bundle exec render_animate_it_video hello
117
+ bundle exec render_animate_it_video hello tmp/hello.mp4
118
+ ```
119
+
120
+ You can also render from the **Studio** UI: open a composition, scrub the timeline,
121
+ and click **Render Video** to watch progress live.
122
+
123
+ ### Host resolution
124
+
125
+ The renderer points the browser at `ANIMATE_IT_HOST` (falling back to the legacy
126
+ `RAILS_MOTION_HOST`, then `RAILS_HOST`, then `http://127.0.0.1:3000`):
127
+
128
+ ```bash
129
+ ANIMATE_IT_HOST=http://127.0.0.1:3001 bundle exec render_animate_it_video hello
130
+ ```
131
+
132
+ ## Configuration
133
+
134
+ ```ruby
135
+ # config/initializers/animate_it.rb
136
+ AnimateIt.configure do |config|
137
+ # Where the Studio mounts (development/test only). Default "/animate_it".
138
+ config.mount_path = "/animate_it"
139
+
140
+ # Host stylesheets to inject into every rendered frame. Only needed when your
141
+ # compositions re-use host partials/components that expect their CSS. Names
142
+ # are passed to `stylesheet_link_tag`. Default [].
143
+ config.render_stylesheets = %w[application components/star-ratings]
144
+ end
145
+ ```
146
+
147
+ Render concurrency for the Studio's background renderer is tunable via
148
+ `ANIMATE_IT_RENDER_CONCURRENCY` and `ANIMATE_IT_RENDER_STARTS_PER_SECOND`.
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ bin/setup # bundle install
154
+ bundle exec rspec # unit + request specs (uses spec/dummy)
155
+ bundle exec rubocop
156
+
157
+ # full render smoke test (needs ffmpeg + Playwright chromium)
158
+ RUN_RENDER_SMOKE=1 bundle exec rspec spec/rendering_spec.rb
159
+ ```
160
+
161
+ Specs run against a minimal host app in `spec/dummy` — no external services, no
162
+ database.
163
+
164
+ ## License
165
+
166
+ [MIT](MIT-LICENSE).
@@ -0,0 +1,73 @@
1
+ module AnimateIt
2
+ class ApplicationController < ActionController::Base
3
+ # Composition sidecar templates live at app/videos/<composition>/*.html.haml
4
+ # in the host app — outside the engine's view path, so add it explicitly.
5
+ prepend_view_path Rails.root.join("app/videos")
6
+
7
+ # Sidecar templates routinely render host-app partials (e.g. the real
8
+ # candidates-page _match_row.haml). Those partials reach for host helpers
9
+ # like `user_status_style`, `full_name_hidden_display`, `add_stylesheet`,
10
+ # etc. — and host route helpers like `expert_profile_client_matches_job_path`.
11
+ # None of those live in the engine, so expose them all here so the engine's
12
+ # view context matches what the host's view context offers.
13
+ helper Rails.application.helpers
14
+ helper Rails.application.routes.url_helpers
15
+
16
+ protect_from_forgery with: :exception
17
+ layout "animate_it/application"
18
+
19
+ before_action :ensure_local_environment
20
+ helper_method :animate_it_path, :current_user, :policy
21
+
22
+ rescue_from AnimateIt::CompositionNotFoundError do
23
+ head :not_found
24
+ end
25
+
26
+ # Host partials reach for Devise's `current_user` and Pundit's `policy(...)`
27
+ # helpers. The engine has neither, so provide permissive null-object
28
+ # versions for hero rendering. Hero canvases are always read-only; the
29
+ # action buttons they incidentally render evaluate every permission to
30
+ # false and become no-ops.
31
+ NullPolicy = Class.new do
32
+ def method_missing(name, *_args)
33
+ name.to_s.end_with?("?") ? false : self
34
+ end
35
+
36
+ def respond_to_missing?(_name, _include_private = false)
37
+ true
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def ensure_local_environment
44
+ head :not_found unless Rails.env.local?
45
+ end
46
+
47
+ def composition
48
+ AnimateIt.load_compositions!
49
+ @composition ||= AnimateIt.registry.fetch(params[:id])
50
+ end
51
+
52
+ def preview_props
53
+ raw = params[:props_json].presence || params[:props].presence || "{}"
54
+ parsed = raw.is_a?(String) ? JSON.parse(raw) : raw.to_unsafe_h
55
+
56
+ parsed.deep_symbolize_keys
57
+ rescue JSON::ParserError
58
+ {}
59
+ end
60
+
61
+ def animate_it_path(path)
62
+ "#{request.script_name}#{path}"
63
+ end
64
+
65
+ def current_user
66
+ nil
67
+ end
68
+
69
+ def policy(_subject)
70
+ NullPolicy.new
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,31 @@
1
+ module AnimateIt
2
+ class AudioController < ApplicationController
3
+ AUDIO_BASE = Rails.root.join("app/audio").freeze
4
+
5
+ def show
6
+ audio_segments = composition.timeline.segments.select { |seg| seg.kind == :audio }
7
+ segment = audio_segments[params[:index].to_i]
8
+ return head :not_found unless segment
9
+
10
+ file_path = resolve_audio_path(segment.source[:path])
11
+ return head :not_found unless file_path && File.file?(file_path)
12
+
13
+ send_file file_path, type: Mime::Type.lookup_by_extension(File.extname(file_path).delete(".")),
14
+ disposition: "inline"
15
+ end
16
+
17
+ private
18
+
19
+ def resolve_audio_path(path)
20
+ return nil if path.blank?
21
+
22
+ pathname = Pathname.new(path)
23
+ return pathname.to_s if pathname.absolute? && pathname.realpath.to_s.start_with?(AUDIO_BASE.to_s)
24
+
25
+ candidate = AUDIO_BASE.join(path).expand_path
26
+ return nil unless candidate.to_s.start_with?(AUDIO_BASE.to_s)
27
+
28
+ candidate.to_s
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ module AnimateIt
2
+ class FramesController < ApplicationController
3
+ layout false
4
+ skip_forgery_protection
5
+
6
+ def show
7
+ @composition = composition
8
+ @frame = params[:frame].to_i
9
+ @content = @composition.render_frame(view_context, frame: @frame, props: preview_props)
10
+ # `?only=body` returns just the .animate-it-frame element (no <head> /
11
+ # stylesheets) so the studio can morph it into a persistent document
12
+ # instead of reloading the whole frame doc. The full-document render is
13
+ # what the Playwright render pipeline uses — left untouched.
14
+ render(:fragment) if params[:only] == "body"
15
+ end
16
+
17
+ def filmstrip
18
+ @composition = composition
19
+ @props = preview_props
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,10 @@
1
+ module AnimateIt
2
+ class PropsController < ApplicationController
3
+ def update
4
+ @composition = composition
5
+ @props_json = JSON.pretty_generate(@composition.props.resolve(preview_props))
6
+ @frame_url = "#{animate_it_path("/compositions/#{@composition.id}/frame/0")}?#{URI.encode_www_form(pp: "disable",
7
+ props_json: @props_json)}"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,82 @@
1
+ module AnimateIt
2
+ class RendersController < ApplicationController
3
+ def show
4
+ @render_id = params[:id]
5
+ # `params[:id]` here is a render UUID, not a composition id — look up
6
+ # the render record first to learn which composition produced it.
7
+ render_record = RenderStore.find(@render_id)
8
+ return head(:not_found) unless render_record
9
+
10
+ AnimateIt.load_compositions!
11
+ @composition = AnimateIt.registry.fetch(render_record.composition_id)
12
+ @output_path = Pathname(render_record.output_path)
13
+ if params[:download].present? && @output_path.exist?
14
+ send_file @output_path, type: Mime::Type.lookup_by_extension(@output_path.extname.delete(".")),
15
+ disposition: "inline"
16
+ end
17
+
18
+ @output_relative_path = relative_output_path(@output_path)
19
+ end
20
+
21
+ def create
22
+ @composition = composition
23
+ @render_id = SecureRandom.hex(8)
24
+ @output_path = output_path(@render_id)
25
+ @output_path.dirname.mkpath
26
+ @output_relative_path = relative_output_path(@output_path)
27
+
28
+ RenderStore.create!(
29
+ id: @render_id,
30
+ composition_id: @composition.id,
31
+ total_frames: @composition.duration_in_frames,
32
+ output_path: @output_path
33
+ )
34
+ start_render(@render_id, @composition.id, request.base_url, @output_path.to_s, frames_dir(@render_id).to_s,
35
+ preview_props, render_options)
36
+
37
+ render status: :accepted
38
+ end
39
+
40
+ def cancel
41
+ RenderStore.cancel!(params[:id])
42
+ head :accepted
43
+ end
44
+
45
+ private
46
+
47
+ def output_path(render_id)
48
+ ext = AnimateIt::VideoRenderer::EXTENSION_FOR_FORMAT.fetch(@composition.output_format) || "mp4"
49
+ Rails.root.join("tmp/animate_it/renders/#{render_id}.#{ext}")
50
+ end
51
+
52
+ def frames_dir(render_id)
53
+ Rails.root.join("tmp/animate_it/renders/#{render_id}_frames")
54
+ end
55
+
56
+ def relative_output_path(path)
57
+ path&.relative_path_from(Rails.root)&.to_s
58
+ end
59
+
60
+ def render_options
61
+ {
62
+ "concurrency" => env_with_legacy("ANIMATE_IT_RENDER_CONCURRENCY", "RAILS_MOTION_RENDER_CONCURRENCY", "3").to_i,
63
+ "starts_per_second" => env_with_legacy("ANIMATE_IT_RENDER_STARTS_PER_SECOND",
64
+ "RAILS_MOTION_RENDER_STARTS_PER_SECOND", "2").to_f
65
+ }
66
+ end
67
+
68
+ # Prefer the new ANIMATE_IT_* name; fall back to the legacy RAILS_MOTION_*
69
+ # name for one release so existing setups don't break, then the default.
70
+ def env_with_legacy(new_key, legacy_key, default)
71
+ ENV[new_key].presence || ENV[legacy_key].presence || default
72
+ end
73
+
74
+ def start_render(render_id, composition_id, host, output_path, frame_dir, props, options)
75
+ Thread.new do
76
+ Rails.application.executor.wrap do
77
+ RenderJob.perform_now(render_id, composition_id, host, output_path, frame_dir, props, options)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,17 @@
1
+ module AnimateIt
2
+ class StudioController < ApplicationController
3
+ def index
4
+ AnimateIt.load_compositions!
5
+ @compositions = AnimateIt.compositions
6
+ end
7
+
8
+ def show
9
+ @composition = composition
10
+ @compositions = AnimateIt.compositions
11
+ @frame_base_url = animate_it_path("/compositions/#{@composition.id}/frame")
12
+ @last_frame = @composition.duration_in_frames - 1
13
+ @props_json = JSON.pretty_generate(@composition.props.defaults)
14
+ @renders = RenderStore.all
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ module AnimateIt
2
+ # Engine base job. Standalone-safe: inherits ActiveJob::Base directly rather
3
+ # than the host app's ApplicationJob, so the gem works in any host.
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ module AnimateIt
2
+ class RenderJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(render_id, composition_id, host, output_path, frames_dir, props = {}, _options = {})
6
+ AnimateIt.load_compositions!
7
+ composition = AnimateIt.registry.fetch(composition_id)
8
+
9
+ RenderStore.start!(render_id)
10
+ VideoRenderer.new(
11
+ composition:,
12
+ host:,
13
+ output_path:,
14
+ frames_dir:
15
+ ).render(
16
+ props:,
17
+ cancel_check: -> { RenderStore.cancelled?(render_id) },
18
+ on_progress: ->(frame, total_frames) { RenderStore.progress!(render_id, frame:, total_frames:) }
19
+ )
20
+ RenderStore.complete!(render_id)
21
+ rescue VideoRenderer::CancelledError
22
+ RenderStore.cancel!(render_id)
23
+ rescue StandardError => e
24
+ RenderStore.fail!(render_id, e)
25
+ raise
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,65 @@
1
+ !!!
2
+ %html{ lang: "en" }
3
+ %head
4
+ %title= "#{@composition.id} filmstrip (#{@composition.duration_in_frames} frames)"
5
+ %meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
6
+ -# Host-declared render stylesheets (AnimateIt.config.render_stylesheets) so
7
+ -# compositions that re-use host partials get the host's component CSS. See
8
+ -# frames/show.html.haml for the rationale.
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
+ /* See frames/show.html.haml — Composition.zoom magnifies content
21
+ uniformly without changing the output viewport. */
22
+ zoom: #{@composition.zoom};
23
+ }
24
+ body, main, .hero-render-canvas {
25
+ background-color: transparent !important;
26
+ }
27
+ .animate-it-frame {
28
+ position: absolute;
29
+ inset: 0;
30
+ width: #{@composition.width}px;
31
+ height: #{@composition.height}px;
32
+ overflow: hidden;
33
+ display: none !important;
34
+ visibility: hidden;
35
+ }
36
+ .animate-it-frame.is-active {
37
+ display: block !important;
38
+ visibility: visible;
39
+ }
40
+ %body
41
+ - (0...@composition.duration_in_frames).each do |frame|
42
+ .animate-it-frame{ class: ('is-active' if frame.zero?), data: { frame: frame } }
43
+ = @composition.render_frame(self, frame: frame, props: @props)
44
+ -# See app/views/animate_it/frames/show.html.haml for why these are at the
45
+ -# bottom of body. Host-app accumulator helpers — emit only when present.
46
+ - if respond_to?(:render_page_stylesheets)
47
+ = render_page_stylesheets
48
+ - if respond_to?(:render_component_stylesheets)
49
+ = render_component_stylesheets
50
+ :javascript
51
+ (() => {
52
+ const frames = Array.from(document.querySelectorAll(".animate-it-frame"));
53
+ let active = frames[0] || null;
54
+ window.__animateIt = {
55
+ totalFrames: frames.length,
56
+ setFrame(n) {
57
+ const target = frames[n];
58
+ if (!target || target === active) return;
59
+ if (active) active.classList.remove("is-active");
60
+ target.classList.add("is-active");
61
+ active = target;
62
+ }
63
+ };
64
+ document.documentElement.dataset.animateItReady = "1";
65
+ })();
@@ -0,0 +1,6 @@
1
+ -# Bare frame body for the studio's persistent-document preview. Mirrors the
2
+ -# .animate-it-frame element in show.html.haml so it can be morphed in place,
3
+ -# but emits no <head>/stylesheets — those are parsed once by the persistent
4
+ -# document. The full-document show.html.haml stays the render pipeline's path.
5
+ .animate-it-frame{ data: { frame: @frame, composition: @composition.id } }
6
+ = @content
@@ -0,0 +1,54 @@
1
+ !!!
2
+ %html{ lang: "en" }
3
+ %head
4
+ %title= "#{@composition.id} frame #{@frame}"
5
+ %meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" }
6
+ -# Host-declared render stylesheets (AnimateIt.config.render_stylesheets).
7
+ -# Compositions that re-use host partials (e.g. a real _match_row.haml)
8
+ -# expect the host's component CSS — star-ratings, avatar sizing, match-row
9
+ -# layout, fonts. The host normally queues these via `add_stylesheet(...)` /
10
+ -# `add_component_stylesheet(...)` and emits them with `render_*_stylesheets`,
11
+ -# but that queue lives on the view context, which the engine's sub-render
12
+ -# boundary doesn't carry across — so the host lists the bundles in the
13
+ -# initializer and they're loaded statically here.
14
+ - AnimateIt.config.render_stylesheets.each do |sheet|
15
+ = stylesheet_link_tag sheet, "data-turbo-track": "reload"
16
+ :css
17
+ html, body {
18
+ width: #{@composition.width}px;
19
+ height: #{@composition.height}px;
20
+ margin: 0;
21
+ overflow: hidden;
22
+ background: transparent;
23
+ }
24
+
25
+ body {
26
+ /* Apply per-composition browser zoom (Composition.zoom). Default 1.0
27
+ is a no-op. With a smaller `size(...)` viewport, a >1.0 zoom
28
+ magnifies content uniformly without changing the output frame. */
29
+ zoom: #{@composition.zoom};
30
+ }
31
+
32
+ body, main, .hero-render-canvas {
33
+ background-color: transparent !important;
34
+ }
35
+
36
+ .animate-it-frame {
37
+ position: relative;
38
+ width: #{@composition.width}px;
39
+ height: #{@composition.height}px;
40
+ overflow: hidden;
41
+ }
42
+ %body
43
+ .animate-it-frame{ data: { frame: @frame, composition: @composition.id } }
44
+ = @content
45
+ -# Host-app stylesheets queued via `add_stylesheet(...)` / ViewComponent
46
+ -# `with_stylesheet` accumulate on the view context during content
47
+ -# rendering. Emit them at the end of body so the host's component CSS
48
+ -# (star-ratings, match-row, etc.) reaches the document. CSS still
49
+ -# applies regardless of position. These accumulator helpers are host-app
50
+ -# provided; emit them only when the host defines them.
51
+ - if respond_to?(:render_page_stylesheets)
52
+ = render_page_stylesheets
53
+ - if respond_to?(:render_component_stylesheets)
54
+ = render_component_stylesheets
@@ -0,0 +1,3 @@
1
+ = form_with url: animate_it_path("/compositions/#{composition.id}/props"), method: :patch do
2
+ %textarea#animate_it_props_json{ name: "props_json", rows: 14 }= props_json
3
+ = button_tag "Update Preview", type: "submit", data: { turbo_submits_with: "Updating..." }
@@ -0,0 +1,8 @@
1
+ %turbo-frame#animate_it_props
2
+ = render "form", composition: @composition, props_json: @props_json
3
+ :javascript
4
+ if (window.AnimateItStudio) {
5
+ window.AnimateItStudio.setFrame(window.AnimateItStudio.currentFrame());
6
+ } else {
7
+ document.querySelector("#animate_it_preview iframe")?.setAttribute("src", #{@frame_url.to_json});
8
+ }
@@ -0,0 +1,9 @@
1
+ %turbo-frame#animate_it_render_result
2
+ %p= "Render #{@render_id} queued for #{@composition.id}."
3
+ - if @output_relative_path.present?
4
+ %p
5
+ %strong Output:
6
+ %code= @output_relative_path
7
+ %p{ style: "color: #6b7280;" } Progress will appear in the rendering jobs panel below.
8
+ - else
9
+ %p{ style: "color: #b45309;" } Render queued, but no output path was returned.
@@ -0,0 +1,10 @@
1
+ %turbo-frame{ id: "animate_it_render_#{@render_id}" }
2
+ - if @output_path.exist?
3
+ %p= "Render #{@render_id}"
4
+ %p
5
+ %strong Output:
6
+ %code= @output_relative_path
7
+ %p
8
+ = link_to "Open MP4", animate_it_path("/renders/#{@render_id}?download=1"), target: "_blank", rel: "noopener"
9
+ - else
10
+ %p= "Render #{@render_id} was not found."
@@ -0,0 +1,7 @@
1
+ - if compositions.empty?
2
+ %p{ style: "color: #666;" } No AnimateIt compositions registered yet.
3
+ - else
4
+ %ul
5
+ - compositions.each do |composition|
6
+ %li
7
+ = link_to composition.id, animate_it_path("/compositions/#{composition.id}"), data: { turbo_frame: "_top" }