diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41a36d4..f6111de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,26 @@ jobs: - name: Rubocop run: bundle exec rubocop + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Install security scanners + run: gem install bundler-audit brakeman --no-document + + - name: Audit dependencies + run: bundle-audit check --update + + - name: Scan Rails engine + run: brakeman -q -p . --no-pager --confidence-level 2 --exit-on-warn --exit-on-error + test: runs-on: ubuntu-latest strategy: @@ -43,6 +63,12 @@ jobs: - name: RSpec (unit + request) run: bundle exec rspec --exclude-pattern "spec/rendering_spec.rb" + - name: Audit appraisal dependencies + run: | + gem install bundler-audit --no-document + bundle-audit check --update \ + --gemfile-lock "gemfiles/${{ matrix.gemfile }}.gemfile.lock" + # The end-to-end render smoke drives real Chromium + FFmpeg, so run it once # on the newest supported cell rather than on all four. - name: Set up Node diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9c1883..4204a65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,10 @@ jobs: echo "published=$published" >> "$GITHUB_OUTPUT" echo "animate_it ${{ steps.version.outputs.value }} already published: $published" + - name: Build release description + if: steps.check.outputs.published == 'false' + run: ruby bin/release_notes "${{ steps.version.outputs.value }}" > release-notes.md + - name: Configure RubyGems trusted publishing if: steps.check.outputs.published == 'false' uses: rubygems/configure-rubygems-credentials@main @@ -57,4 +61,6 @@ jobs: tag="v${{ steps.version.outputs.value }}" git tag "$tag" git push origin "$tag" - gh release create "$tag" --title "$tag" --generate-notes + gh release create "$tag" \ + --title "Animate It ${{ steps.version.outputs.value }}" \ + --notes-file release-notes.md diff --git a/Appraisals b/Appraisals index bfa53cf..09dc362 100644 --- a/Appraisals +++ b/Appraisals @@ -1,9 +1,9 @@ # Dependency sets the CI matrix + `bundle exec appraisal rspec` run # against. The gem supports Rails >= 7.2; these pin the two we actively test. appraise "rails-7.2" do - gem "rails", "~> 7.2.0" + gem "rails", "~> 7.2.0", ">= 7.2.3.2" end appraise "rails-8.1" do - gem "rails", "~> 8.1.0" + gem "rails", "~> 8.1.0", ">= 8.1.3.1" end diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ac962..22e01fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0] - 2026-08-13 + +### Added +- User-navigable `chapter` declarations backed by existing beats, with a + versioned player manifest and normalized progress/state variables. +- `animate_it_embed`, a poster-first custom-element embed with responsive + composition variants, proportional scaling, readiness crossfade, visibility + playback, offscreen pausing, reduced-motion fallback, and accessible controls. +- Headless Rails chapter builders for arbitrary controls plus optional desktop + and mobile-carousel pill presets and canvas-side chapter navigation. +- Stable transport commands and `ready`, `framechange`, `chapterchange`, `play`, + `pause`, `ended`, and `error` events over a source-checked same-origin message + protocol. +- Versioned, immutable JavaScript and CSS endpoints that work independently of + Stimulus and the host application's asset pipeline. + +### Changed +- Public player readiness now waits for fonts, eager images, and two paint + frames. `AnimateItTransport` remains available as a compatibility alias. +- Repository and package metadata now point at `joinbuildit/animate_it`. +- Every automated GitHub release now publishes the matching curated changelog + section as its release description and fails safely when that entry is missing. + +### Compatibility +- `animate_it_player`, track schema v2, existing compositions, Studio playback, + and video rendering remain backward compatible. + ## [0.4.0] - 2026-08-01 ### Added @@ -96,7 +123,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `render_animate_it_video` executable and `animate_it:render` rake task. - `animate_it:install` generator. -[Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/joinbuildit/animate_it/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/joinbuildit/animate_it/compare/v0.3.2...v0.4.0 [0.3.2]: https://github.com/joinbuildit/animate_it/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/joinbuildit/animate_it/compare/v0.3.0...v0.3.1 diff --git a/Gemfile b/Gemfile index 23a3c06..8803780 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ group :development, :test do gem "factory_bot", "~> 6.5" gem "faker", "~> 3.5" gem "playwright-ruby-client", "~> 1.61.0" - gem "puma", "~> 6.0" + gem "puma", ">= 7.2.1", "< 9" gem "rspec-rails", "~> 8.0" gem "rubocop", "~> 1.68", require: false gem "rubocop-rails", "~> 2.28", require: false diff --git a/README.md b/README.md index 706fc2e..ed75b74 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- CI + CI Gem Version Ruby >= 3.3

@@ -174,6 +174,10 @@ endpoints, and public playback always uses the composition's default props. class HelloVideo < AnimateIt::Composition id "hello" public_player! autoplay: false, loop: true + beat :intro, at: 0, length: 45 + beat :details, at: 45, length: 45 + chapter :intro, beat: :intro, label: "Intro" + chapter :details, beat: :details, label: "Details" # ... end ``` @@ -190,9 +194,74 @@ mount AnimateIt::Engine, at: AnimateIt.config.mount_path <%= animate_it_player "hello", title: "Hello product demo" %> ``` -The iframe renders each structural layer once and advances entirely in the -browser. Audio-capable players fall back to the visible Play button when the -browser blocks autoplay. +`animate_it_player` remains the low-level responsive iframe. For a complete +production embed with accessible chapter navigation, a poster-first handoff, +visibility playback, reduced-motion behavior, and offscreen pausing, use: + +```erb +<%= animate_it_embed( + "hello", + poster: image_path("hello.webp"), + variants: [ + { + media: "(max-width: 767px)", + composition: "hello-mobile", + poster: image_path("hello-mobile.webp") + } + ], + navigation: { preset: :pills, mobile: :carousel }, + load_when_visible: 0.25, + play_when_visible: 2.0 / 3 +) %> +``` + +Responsive compositions may use different sizes and chapter frames, but must +declare the same ordered chapter names and labels. The embed swaps by media +query and restores the current chapter by name. + +The pill rail is optional. Build cards, tabs, thumbnails, dots, or custom SVG +with the headless Rails builder: + +```erb +<%= animate_it_embed("hello", poster: image_path("hello.webp")) do |embed| %> + <%= embed.chapter_navigation(class: "product-demo-cards") do |chapter| %> + <%= chapter.button(class: "product-demo-card") do %> + <%= chapter.label %> + <% end %> + <% end %> +<% end %> +``` + +Every control receives `data-chapter-state`, `data-chapter-position`, and the +normalized CSS variables `--animate-it-chapter-progress`, +`--animate-it-chapter-active`, and `--animate-it-chapter-complete`. The player +emits `animateit:ready`, `animateit:framechange`, `animateit:chapterchange`, +`animateit:play`, `animateit:pause`, `animateit:ended`, and `animateit:error` +events. Host commands use a source-checked same-origin message +boundary instead of reaching into iframe globals. + +For direct player integrations, `window.AnimateItPlayer` exposes `play`, +`pause`, `toggle`, `seek`, `seekChapter`, `playing`, and `currentFrame`. +`window.AnimateItTransport` remains an alias for compatibility with 0.4. + +To include the same chapter visualization in Studio and rendered media: + +```erb +<%= animate_it_chapter_navigation preset: :pills, hide_when_embedded: true %> +``` + +The iframe still renders each structural layer once and advances entirely in +the browser. Audio-capable players fall back to the visible Play button when +the browser blocks autoplay. + +### Migrating a custom iframe controller + +Replace application-owned iframe scaling, `IntersectionObserver`, poster +crossfade, breakpoint swapping, frame polling, and `contentWindow` transport +calls with `animate_it_embed`. Keep application CSS by overriding the documented +`--animate-it-*` tokens or render completely custom chapter controls through the +headless builder. Continue using `animate_it_player` when the application truly +needs to own the entire lifecycle. ### HAML or ERB diff --git a/animate_it.gemspec b/animate_it.gemspec index e55df9a..2d8c7c6 100644 --- a/animate_it.gemspec +++ b/animate_it.gemspec @@ -17,7 +17,7 @@ Gem::Specification.new do |spec| with polished product demos, launch clips, and social ads — without leaving Ruby, hiring an editor, or learning After Effects. DESC - spec.homepage = "https://github.com/growth-constant/animate_it" + spec.homepage = "https://github.com/joinbuildit/animate_it" spec.license = "MIT" spec.required_ruby_version = ">= 3.3" diff --git a/app/controllers/animate_it/embed_assets_controller.rb b/app/controllers/animate_it/embed_assets_controller.rb new file mode 100644 index 0000000..d7768b4 --- /dev/null +++ b/app/controllers/animate_it/embed_assets_controller.rb @@ -0,0 +1,25 @@ +module AnimateIt + class EmbedAssetsController < ApplicationController + layout false + skip_before_action :ensure_local_environment + skip_forgery_protection + + def javascript + serve_asset(EmbedRuntime.javascript, "application/javascript") + end + + def stylesheet + serve_asset(EmbedRuntime.stylesheet, "text/css") + end + + private + + def serve_asset(source, content_type) + return head :not_found unless params[:version] == AnimateIt::VERSION + + expires_in 1.year, public: true, immutable: true + response.set_header("X-Content-Type-Options", "nosniff") + render plain: source, content_type: + end + end +end diff --git a/app/controllers/animate_it/frames_controller.rb b/app/controllers/animate_it/frames_controller.rb index 3a48847..41a63eb 100644 --- a/app/controllers/animate_it/frames_controller.rb +++ b/app/controllers/animate_it/frames_controller.rb @@ -24,6 +24,9 @@ def player @props = preview_props @track_document = @composition.track_document(props: @props) TrackDocumentSchema.validate!(@track_document) + @player_manifest = @composition.player_manifest + @embedded_player = false + @host_navigation = false end end end diff --git a/app/controllers/animate_it/public_players_controller.rb b/app/controllers/animate_it/public_players_controller.rb index 9b7b3b5..122040c 100644 --- a/app/controllers/animate_it/public_players_controller.rb +++ b/app/controllers/animate_it/public_players_controller.rb @@ -13,9 +13,13 @@ def show @props = {} @track_document = composition.track_document TrackDocumentSchema.validate!(@track_document) + @player_manifest = composition.player_manifest @audio_segments = audio_segments @public_player = true - @public_player_options = composition.public_player_options + @embedded_player = params[:embedded] == "1" + @host_navigation = params[:host_navigation] == "1" + @public_player_options = composition.public_player_options.merge(autoplay: false) if @embedded_player + @public_player_options ||= composition.public_player_options render "animate_it/frames/player" end diff --git a/app/views/animate_it/frames/player.html.haml b/app/views/animate_it/frames/player.html.haml index 727a6c9..03954a6 100644 --- a/app/views/animate_it/frames/player.html.haml +++ b/app/views/animate_it/frames/player.html.haml @@ -1,5 +1,8 @@ !!! -%html{ lang: "en" } +%html{ lang: "en", data: { + animate_it_embedded: @embedded_player ? "true" : "false", + animate_it_host_navigation: @host_navigation ? "true" : "false" +} } %head %title= "#{@composition.id} player (#{@composition.duration_in_frames} frames)" %meta{ name: "viewport", content: "width=#{@composition.width}, initial-scale=1" } @@ -52,6 +55,12 @@ font: 600 14px/1 system-ui, sans-serif; cursor: pointer; } + html[data-animate-it-host-navigation="true"] [data-animate-it-hide-when-embedded="true"] { + display: none !important; + } + html[data-animate-it-embedded="true"] .animate-it-public-play { + display: none !important; + } %body .animate-it-stage = @composition.render_structure(self, props: @props) @@ -78,4 +87,6 @@ animate_it_loop: @public_player_options&.fetch(:loop, true) ? "true" : "false" } } != ERB::Util.json_escape(@track_document.to_json) + %script{ type: "application/json", data: { animate_it_manifest: true } } + != ERB::Util.json_escape(@player_manifest.as_json.to_json) = javascript_tag AnimateIt::Runtime.source.html_safe diff --git a/bin/release_notes b/bin/release_notes new file mode 100644 index 0000000..4de8093 --- /dev/null +++ b/bin/release_notes @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +version = ARGV.fetch(0) do + warn "Usage: ruby bin/release_notes VERSION [CHANGELOG]" + exit 64 +end + +changelog_path = ARGV.fetch(1, File.expand_path("../CHANGELOG.md", __dir__)) +changelog = File.read(changelog_path) +heading = /^## \[#{Regexp.escape(version)}\](?:\s+-\s+.+)?\s*$/ +match = changelog.match(heading) + +unless match + warn "CHANGELOG.md has no release description for #{version}" + exit 1 +end + +body_start = match.end(0) +body_end = changelog.index(/^## \[/, body_start) || changelog.length +description = changelog[body_start...body_end].strip + +if description.empty? + warn "CHANGELOG.md has an empty release description for #{version}" + exit 1 +end + +puts <<~MARKDOWN + ## Animate It #{version} + + #{description} + + ### Install + + ```ruby + gem "animate_it", "~> #{version}" + ``` + + See the [full changelog](https://github.com/joinbuildit/animate_it/blob/main/CHANGELOG.md) for earlier releases and compatibility notes. +MARKDOWN diff --git a/config/routes.rb b/config/routes.rb index 65c9c45..8748c31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,9 @@ AnimateIt::Engine.routes.draw do + get "assets/:version/embed.js", to: "embed_assets#javascript", as: :embed_javascript, + constraints: { version: /[0-9A-Za-z._-]+/ } + get "assets/:version/embed.css", to: "embed_assets#stylesheet", as: :embed_stylesheet, + constraints: { version: /[0-9A-Za-z._-]+/ } + root "studio#index" get "public/compositions/:id/player", to: "public_players#show", as: :public_composition_player diff --git a/docs/images/interactive-embed-desktop.png b/docs/images/interactive-embed-desktop.png new file mode 100644 index 0000000..56d3eee Binary files /dev/null and b/docs/images/interactive-embed-desktop.png differ diff --git a/docs/images/interactive-embed-mobile.png b/docs/images/interactive-embed-mobile.png new file mode 100644 index 0000000..da90657 Binary files /dev/null and b/docs/images/interactive-embed-mobile.png differ diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 9468d3a..b314656 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -2,7 +2,7 @@ source "https://rubygems.org" -gem "rails", "~> 7.2.0" +gem "rails", "~> 7.2.0", ">= 7.2.3.2" group :development, :test do gem "appraisal", "~> 2.5" @@ -10,7 +10,7 @@ group :development, :test do gem "factory_bot", "~> 6.5" gem "faker", "~> 3.5" gem "playwright-ruby-client", "~> 1.61.0" - gem "puma", "~> 6.0" + gem "puma", ">= 7.2.1", "< 9" gem "rspec-rails", "~> 8.0" gem "rubocop", "~> 1.68", require: false gem "rubocop-rails", "~> 2.28", require: false diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile index d05a986..76f9597 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -2,7 +2,7 @@ source "https://rubygems.org" -gem "rails", "~> 8.1.0" +gem "rails", "~> 8.1.0", ">= 8.1.3.1" group :development, :test do gem "appraisal", "~> 2.5" @@ -10,7 +10,7 @@ group :development, :test do gem "factory_bot", "~> 6.5" gem "faker", "~> 3.5" gem "playwright-ruby-client", "~> 1.61.0" - gem "puma", "~> 6.0" + gem "puma", ">= 7.2.1", "< 9" gem "rspec-rails", "~> 8.0" gem "rubocop", "~> 1.68", require: false gem "rubocop-rails", "~> 2.28", require: false diff --git a/lib/animate_it.rb b/lib/animate_it.rb index 39b5179..2f58b26 100644 --- a/lib/animate_it.rb +++ b/lib/animate_it.rb @@ -1,3 +1,6 @@ +require "active_support" +require "active_support/core_ext" + require_relative "animate_it/version" require_relative "animate_it/errors" require_relative "animate_it/frame_duration" @@ -13,6 +16,7 @@ require_relative "animate_it/registry" require_relative "animate_it/render_store" require_relative "animate_it/beats" +require_relative "animate_it/chapters" require_relative "animate_it/animation" require_relative "animate_it/text_effects" require_relative "animate_it/scene" @@ -22,6 +26,10 @@ require_relative "animate_it/tracks/document" require_relative "animate_it/tracks/recorder" require_relative "animate_it/track_document_schema" +require_relative "animate_it/player_manifest" +require_relative "animate_it/embed_styles" +require_relative "animate_it/chapter_navigation" +require_relative "animate_it/embed_runtime" require_relative "animate_it/runtime" require_relative "animate_it/embed_helper" require_relative "animate_it/output" diff --git a/lib/animate_it/chapter_navigation.rb b/lib/animate_it/chapter_navigation.rb new file mode 100644 index 0000000..5938111 --- /dev/null +++ b/lib/animate_it/chapter_navigation.rb @@ -0,0 +1,160 @@ +module AnimateIt + class ChapterPresenter + attr_reader :chapter + + delegate :name, :label, :start_frame, :duration_frames, :end_frame, :metadata, to: :chapter + + def initialize(view, chapter, interactive:, state:) + @view = view + @chapter = chapter + @interactive = interactive + @state = state + end + + def thumbnail + metadata[:thumbnail] || metadata["thumbnail"] + end + + def button(content = nil, **attributes, &block) + body = block ? @view.capture(self, &block) : (content || label) + classes = ["animate-it-chapter", attributes.delete(:class)].compact.join(" ") + attributes, data = stateful_attributes(attributes) + attributes[:type] ||= "button" + attributes[:"aria-label"] ||= "Jump to #{label}" + @view.tag.button(body, **attributes, class: classes, data:) + end + + def element(content = nil, tag: :div, **attributes, &block) + body = block ? @view.capture(self, &block) : (content || label) + classes = ["animate-it-chapter", attributes.delete(:class)].compact.join(" ") + attributes, data = stateful_attributes(attributes) + @view.tag.public_send(tag, body, **attributes, class: classes, data:) + end + + def default_control(preset: :pills) + progress = @view.tag.svg( + @view.tag.rect(x: 1, y: 1, width: 98, height: 38, rx: 19, pathLength: 1), + class: "animate-it-chapter__progress", viewBox: "0 0 100 40", preserveAspectRatio: "none", aria: { hidden: true } + ) + body = @view.safe_join([progress, @view.tag.span(label, class: "animate-it-chapter__label")]) + classes = "animate-it-chapter animate-it-chapter--#{preset}" + if @interactive + button(body, class: classes.delete_prefix("animate-it-chapter ")) + else + element(body, class: classes.delete_prefix("animate-it-chapter ")) + end + end + + private + + def stateful_attributes(attributes) + attributes = attributes.dup + data = (attributes.delete(:data) || {}).merge( + animate_it_chapter: name, + chapter_state: @state.fetch(:state), + chapter_position: @state.fetch(:position) + ) + variables = [ + "--animate-it-chapter-progress: #{@state.fetch(:progress)}", + "--animate-it-chapter-active: #{@state.fetch(:active)}", + "--animate-it-chapter-complete: #{@state.fetch(:complete)}", + attributes.delete(:style) + ].compact.join(";") + attributes[:style] = variables + attributes[:"aria-current"] = "step" if @interactive && @state.fetch(:state) == "current" + [attributes, data] + end + end + + class ChapterNavigationBuilder + def initialize(view, composition, interactive:, preset: :pills, mobile: nil, frame: 0) + @view = view + @composition = composition + @interactive = interactive + @preset = preset&.to_sym + @mobile = mobile&.to_sym + @frame = frame.to_i + end + + def render(**attributes, &block) + current_index = current_chapter_index + chapters = @composition.chapters.each_with_index.map do |chapter, index| + presenter = ChapterPresenter.new(@view, chapter, interactive: @interactive, state: chapter_state(chapter, index, current_index)) + block ? @view.capture(presenter, &block) : presenter.default_control(preset: @preset || :pills) + end + classes = ["animate-it-chapters", @preset && "animate-it-chapters--#{@preset}", + @mobile && "animate-it-chapters--mobile-#{@mobile}", attributes.delete(:class)].compact.join(" ") + data = (attributes.delete(:data) || {}).merge(animate_it_chapter_navigation: true) + attributes[:"aria-label"] ||= "Animation chapters" if @interactive + tag = @interactive ? :nav : :div + @view.tag.public_send(tag, @view.safe_join(chapters), **attributes, class: classes, data:) + end + + private + + def current_chapter_index + @composition.chapters.to_a.rindex { |chapter| @frame >= chapter.start_frame } || -1 + end + + def chapter_state(chapter, index, current_index) + state = if current_index.negative? || index > current_index + "upcoming" + elsif index < current_index + "completed" + else + "current" + end + progress = if state == "completed" + 1.0 + elsif state == "current" + chapter_progress(chapter) + else + 0.0 + end + { + state:, + position: chapter_position(index, current_index), + progress:, + active: state == "current" ? 1 : 0, + complete: state == "completed" ? 1 : 0 + } + end + + def chapter_progress(chapter) + return 1.0 if chapter.duration_frames == 1 + + (@frame - chapter.start_frame).fdiv(chapter.duration_frames - 1).clamp(0, 1) + end + + def chapter_position(index, current_index) + return "hidden" if current_index.negative? + return "current" if index == current_index + return "previous" if index == current_index - 1 + return "next" if index == current_index + 1 + + "hidden" + end + end + + module ChapterNavigationHelper + def animate_it_chapter_navigation( + composition: nil, preset: nil, mobile: nil, hide_when_embedded: false, frame: nil, **attributes, & + ) + target = composition || instance_variable_get(:@composition) + raise ArgumentError, "animate_it_chapter_navigation requires a composition" unless target + + target.chapters.validate! + data = (attributes.delete(:data) || {}).merge(animate_it_hide_when_embedded: hide_when_embedded ? "true" : "false") + attributes[:style] = ["--animate-it-chapter-count: #{target.chapters.count}", attributes[:style]].compact.join(";") + builder = ChapterNavigationBuilder.new( + self, target, interactive: false, preset:, mobile:, frame: frame || instance_variable_get(:@frame) || 0 + ) + safe_join( + [ + tag.style(AnimateIt::EmbedStyles.chapter_source.html_safe, data: { animate_it_chapter_styles: true }), + builder.render(**attributes, data:, &) + ] + ) + end + end +end diff --git a/lib/animate_it/chapters.rb b/lib/animate_it/chapters.rb new file mode 100644 index 0000000..fefba81 --- /dev/null +++ b/lib/animate_it/chapters.rb @@ -0,0 +1,95 @@ +require "json" + +module AnimateIt + Chapter = Data.define(:name, :label, :beat_name, :start_frame, :duration_frames, :metadata) do + def end_frame + start_frame + duration_frames + end + + def as_json(*) + { + "name" => name.to_s, + "label" => label, + "beat" => beat_name.to_s, + "startFrame" => start_frame, + "durationFrames" => duration_frames, + "endFrame" => end_frame, + "metadata" => metadata + } + end + end + + class Chapters + include Enumerable + + def initialize(composition) + @composition = composition + @chapters = [] + end + + def add(name, beat:, label:, metadata: {}) + key = name.to_sym + raise ArgumentError, "AnimateIt chapter names must be unique: #{name.inspect}" if @chapters.any? { |chapter| chapter.name == key } + raise ArgumentError, "AnimateIt chapter labels must not be blank" if label.to_s.strip.empty? + raise ArgumentError, "AnimateIt chapter metadata must be a hash" unless metadata.is_a?(Hash) + + JSON.generate(metadata) + + beat_record = @composition.beats.fetch(beat) + chapter = Chapter.new( + name: key, + label: label.to_s, + beat_name: beat_record.name, + start_frame: beat_record.start_frame, + duration_frames: beat_record.duration_frames, + metadata: metadata.freeze + ) + validate_after!(@chapters.last, chapter) + validate_bounds!(chapter) + @chapters << chapter + chapter + end + + def fetch(name) + @chapters.find { |chapter| chapter.name == name.to_sym } || + raise(Error, "Unknown chapter: #{name.inspect}. Declared: #{@chapters.map(&:name).inspect}") + end + + def each(&) + @chapters.each(&) + end + + delegate :empty?, to: :@chapters + + def as_json(*) + validate! + @chapters.map(&:as_json) + end + + def validate! + @chapters.each_with_index do |chapter, index| + validate_after!(@chapters[index - 1], chapter) if index.positive? + validate_bounds!(chapter) + end + self + end + + private + + def validate_after!(previous, chapter) + return unless previous + + raise ArgumentError, "AnimateIt chapters must have strictly increasing start frames" if chapter.start_frame <= previous.start_frame + return if chapter.start_frame >= previous.end_frame + + raise ArgumentError, "AnimateIt chapters must not overlap: #{previous.name.inspect} and #{chapter.name.inspect}" + end + + def validate_bounds!(chapter) + return if chapter.start_frame >= 0 && chapter.duration_frames.positive? && chapter.end_frame <= @composition.duration_in_frames + + raise ArgumentError, + "AnimateIt chapter #{chapter.name.inspect} must fit within 0...#{@composition.duration_in_frames} frames" + end + end +end diff --git a/lib/animate_it/composition.rb b/lib/animate_it/composition.rb index 5d681b5..1d9d26a 100644 --- a/lib/animate_it/composition.rb +++ b/lib/animate_it/composition.rb @@ -16,6 +16,7 @@ def inherited(subclass) subclass.instance_variable_set(:@output_format, :webm) subclass.instance_variable_set(:@verification_props, [{}].freeze) subclass.instance_variable_set(:@public_player_options, nil) + subclass.instance_variable_set(:@chapters, Chapters.new(subclass)) super end @@ -191,6 +192,20 @@ def beats @beats ||= Beats.new(fps: fps) end + # Public, user-navigable moments. Chapters reference existing beats so + # animation timing stays the single source of truth. + def chapter(name, beat:, label:, metadata: {}) + chapters.add(name, beat:, label:, metadata:) + end + + def chapters + @chapters ||= Chapters.new(self) + end + + def player_manifest + PlayerManifest.new(self) + end + # ----- Outputs path helpers -------------------------------------- # Declare a directory + basename so `outputs do ... end` entries can # be specified by format alone: diff --git a/lib/animate_it/embed_helper.rb b/lib/animate_it/embed_helper.rb index 7b51cd8..6d3e445 100644 --- a/lib/animate_it/embed_helper.rb +++ b/lib/animate_it/embed_helper.rb @@ -1,15 +1,38 @@ +require "json" + module AnimateIt + class EmbedBuilder + attr_reader :composition + + def initialize(view, composition, navigation: {}) + @view = view + @composition = composition + @navigation = navigation + end + + def chapter_navigation(**attributes, &block) + custom = block.present? + preset = if attributes.key?(:preset) + attributes.delete(:preset) + elsif !custom + @navigation.fetch(:preset, :pills) + end + mobile = if attributes.key?(:mobile) + attributes.delete(:mobile) + elsif !custom + @navigation[:mobile] + end + style = ["--animate-it-chapter-count: #{@composition.chapters.count}", attributes.delete(:style)].compact.join(";") + ChapterNavigationBuilder.new(@view, @composition, interactive: true, preset:, mobile:, frame: 0) + .render(**attributes, style:, &block) + end + end + module EmbedHelper def animate_it_player(composition_id, title: nil, **attributes) - AnimateIt.load_compositions! - composition = AnimateIt.registry.fetch(composition_id) - raise ArgumentError, "AnimateIt composition #{composition_id.inspect} is not public" unless composition.public_player? - - prefix = respond_to?(:request) && request ? request.script_name.to_s : "" - source = "#{prefix}#{AnimateIt.config.mount_path}/public/compositions/" \ - "#{ERB::Util.url_encode(composition.id)}/player" + composition = public_animate_it_composition!(composition_id) defaults = { - src: source, + src: animate_it_public_player_path(composition), title: title || composition.id, loading: "lazy", allow: "autoplay; fullscreen", @@ -18,5 +41,174 @@ def animate_it_player(composition_id, title: nil, **attributes) } tag.iframe(**defaults, **attributes) end + + def animate_it_embed( + composition_id, + poster:, + variants: [], + navigation: { preset: :pills }, + load_when_visible: 0.25, + play_when_visible: 2.0 / 3, + pause_offscreen: true, + reduced_motion: :poster, + autoplay: true, + title: nil, + **attributes, + &block + ) + composition = public_animate_it_composition!(composition_id) + navigation = navigation == false ? false : (navigation || {}).to_h.deep_symbolize_keys + resolved_variants = resolve_animate_it_variants(composition, poster, variants) + validate_animate_it_variant_chapters!(resolved_variants) + manifest = animate_it_embed_manifest( + title: title || composition.id, + variants: resolved_variants, + load_when_visible:, + play_when_visible:, + pause_offscreen:, + reduced_motion:, + autoplay: + ) + builder = EmbedBuilder.new(self, composition, navigation: navigation || {}) + navigation_html = if navigation + block ? capture(builder, &block) : builder.chapter_navigation(class: "animate-it-embed__navigation") + end + classes = ["animate-it-embed", attributes.delete(:class)].compact.join(" ") + data = (attributes.delete(:data) || {}).merge(animate_it_embed: true) + + safe_join( + [ + stylesheet_link_tag(animate_it_embed_asset_path("embed.css"), data: { animate_it_embed_asset: "style" }), + javascript_include_tag( + animate_it_embed_asset_path("embed.js"), defer: true, data: { animate_it_embed_asset: "script" } + ), + tag.public_send("animate-it-embed", **attributes, class: classes, data:) do + safe_join([ + navigation_html, + animate_it_embed_viewport(resolved_variants, title || composition.id), + tag.script(ERB::Util.json_escape(JSON.generate(manifest)).html_safe, + type: "application/json", data: { animate_it_embed_manifest: true }) + ].compact) + end + ] + ) + end + + private + + def public_animate_it_composition!(composition_id) + AnimateIt.load_compositions! + composition = AnimateIt.registry.fetch(composition_id) + raise ArgumentError, "AnimateIt composition #{composition_id.inspect} is not public" unless composition.public_player? + + composition + end + + def animate_it_mount_prefix + respond_to?(:request) && request ? request.script_name.to_s : "" + end + + def animate_it_public_player_path(composition) + id = ERB::Util.url_encode(composition.id) + "#{animate_it_mount_prefix}#{AnimateIt.config.mount_path}/public/compositions/#{id}/player" + end + + def animate_it_embed_asset_path(filename) + version = ERB::Util.url_encode(AnimateIt::VERSION) + "#{animate_it_mount_prefix}#{AnimateIt.config.mount_path}/assets/#{version}/#{filename}" + end + + def resolve_animate_it_variants(composition, poster, variants) + raise ArgumentError, "animate_it_embed requires a poster" if poster.blank? + + primary = animate_it_variant_hash(composition, poster:, media: nil) + responsive = Array(variants).map do |variant| + attributes = variant.to_h.deep_symbolize_keys + target = public_animate_it_composition!(attributes.fetch(:composition)) + media = attributes.fetch(:media).to_s + raise ArgumentError, "AnimateIt variant media query must not be blank" if media.blank? + + animate_it_variant_hash(target, poster: attributes.fetch(:poster), media:) + end + duplicate_media = responsive.group_by { |variant| variant.fetch("media") }.select { |_media, group| group.many? }.keys + raise ArgumentError, "AnimateIt variant media queries must be unique: #{duplicate_media.join(", ")}" if duplicate_media.any? + + [primary, *responsive] + end + + def animate_it_variant_hash(composition, poster:, media:) + raise ArgumentError, "AnimateIt variant poster must not be blank" if poster.blank? + + manifest = composition.player_manifest.as_json + { + "media" => media, + "composition" => manifest, + "poster" => poster.to_s, + "source" => animate_it_public_player_path(composition) + } + end + + def validate_animate_it_variant_chapters!(variants) + expected = variants.first.dig("composition", "chapters").map { |chapter| chapter.values_at("name", "label") } + variants.drop(1).each do |variant| + actual = variant.dig("composition", "chapters").map { |chapter| chapter.values_at("name", "label") } + next if actual == expected + + raise ArgumentError, "AnimateIt responsive variants must expose the same ordered chapter names and labels" + end + end + + def animate_it_embed_manifest(title:, variants:, load_when_visible:, play_when_visible:, pause_offscreen:, reduced_motion:, autoplay:) + load_ratio = Float(load_when_visible) + play_ratio = Float(play_when_visible) + unless load_ratio.between?(0, 1) && play_ratio.between?(0, 1) + raise ArgumentError, "AnimateIt visibility thresholds must be between 0 and 1" + end + raise ArgumentError, "AnimateIt reduced_motion must be :poster" unless reduced_motion.to_s == "poster" + + { + "version" => 1, + "title" => title, + "variants" => variants, + "options" => { + "loadWhenVisible" => load_ratio, + "playWhenVisible" => play_ratio, + "pauseOffscreen" => pause_offscreen == true, + "reducedMotion" => reduced_motion.to_s, + "autoplay" => autoplay == true, + "readyTimeout" => 5000, + "crossfadeDuration" => 120 + } + } + rescue TypeError, ArgumentError => e + raise e if e.message.start_with?("AnimateIt") + + raise ArgumentError, "AnimateIt visibility thresholds must be numbers between 0 and 1" + end + + def animate_it_embed_viewport(variants, title) + primary = variants.first + sources = variants.drop(1).map do |variant| + tag.source(media: variant.fetch("media"), srcset: variant.fetch("poster")) + end + poster = tag.picture( + safe_join([*sources, image_tag(primary.fetch("poster"), alt: title, loading: "eager")]), + class: "animate-it-embed__poster", data: { animate_it_embed_poster: true } + ) + viewport = tag.div(class: "animate-it-embed__viewport", data: { animate_it_embed_viewport: true }) do + safe_join( + [ + poster, + tag.div(tag.div("", class: "animate-it-embed__frame", data: { animate_it_embed_frame: true }), + class: "animate-it-embed__shell", data: { animate_it_embed_shell: true }), + tag.button( + "Play", type: "button", class: "animate-it-embed__control", hidden: true, + data: { animate_it_embed_control: true }, aria: { label: "Play animation", pressed: "false" } + ) + ] + ) + end + safe_join([viewport, tag.noscript(image_tag(primary.fetch("poster"), alt: title))]) + end end end diff --git a/lib/animate_it/embed_runtime.rb b/lib/animate_it/embed_runtime.rb new file mode 100644 index 0000000..a13c384 --- /dev/null +++ b/lib/animate_it/embed_runtime.rb @@ -0,0 +1,13 @@ +module AnimateIt + module EmbedRuntime + module_function + + def javascript + @javascript ||= File.read(File.expand_path("embed_runtime/embed.js", __dir__)).freeze + end + + def stylesheet + EmbedStyles.source + end + end +end diff --git a/lib/animate_it/embed_runtime/embed.js b/lib/animate_it/embed_runtime/embed.js new file mode 100644 index 0000000..69ec7e4 --- /dev/null +++ b/lib/animate_it/embed_runtime/embed.js @@ -0,0 +1,338 @@ +(function (global) { + "use strict"; + + if (!global.customElements || global.customElements.get("animate-it-embed")) return; + + function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } + + class AnimateItEmbed extends HTMLElement { + connectedCallback() { + if (this.connected) return; + this.connected = true; + this.manifest = JSON.parse(this.querySelector("[data-animate-it-embed-manifest]").textContent); + this.options = this.manifest.options; + this.viewport = this.querySelector("[data-animate-it-embed-viewport]"); + this.frame = this.querySelector("[data-animate-it-embed-frame]"); + this.control = this.querySelector("[data-animate-it-embed-control]"); + this.chapterControls = Array.from(this.querySelectorAll("[data-animate-it-chapter]")); + this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)"); + this.userPaused = false; + this.userStarted = this.options.autoplay; + this.visibleRatio = 0; + this.playerReady = false; + this.currentChapter = null; + this.playing = false; + this.boundMessage = this.receiveMessage.bind(this); + this.boundVisibility = this.syncPlayback.bind(this); + this.boundVariantChange = this.variantChanged.bind(this); + this.boundReducedMotion = this.reducedMotionChanged.bind(this); + this.boundControl = this.toggle.bind(this); + this.boundResize = this.scaleFrame.bind(this); + global.addEventListener("message", this.boundMessage); + document.addEventListener("visibilitychange", this.boundVisibility); + this.control.addEventListener("click", this.boundControl); + this.reducedMotion.addEventListener("change", this.boundReducedMotion); + this.chapterControls.forEach((control) => { + control.addEventListener("click", () => this.seekChapter(control.dataset.animateItChapter)); + }); + if ("ResizeObserver" in global) { + this.resizeObserver = new global.ResizeObserver(this.boundResize); + this.resizeObserver.observe(this.viewport); + } else { + global.addEventListener("resize", this.boundResize); + } + this.setupVariants(); + this.setupVisibility(); + this.applyReducedMotion(); + } + + disconnectedCallback() { + global.removeEventListener("message", this.boundMessage); + document.removeEventListener("visibilitychange", this.boundVisibility); + this.control && this.control.removeEventListener("click", this.boundControl); + this.reducedMotion && this.reducedMotion.removeEventListener("change", this.boundReducedMotion); + this.resizeObserver && this.resizeObserver.disconnect(); + global.removeEventListener("resize", this.boundResize); + this.intersectionObserver && this.intersectionObserver.disconnect(); + (this.variantQueries || []).forEach((entry) => entry.query.removeEventListener("change", this.boundVariantChange)); + this.cancelLoad(); + this.clearReadyTimer(); + this.removePlayer(); + this.connected = false; + } + + setupVariants() { + this.variantQueries = this.manifest.variants.filter((variant) => variant.media).map((variant) => ({ + variant: variant, + query: global.matchMedia(variant.media) + })); + this.variantQueries.forEach((entry) => entry.query.addEventListener("change", this.boundVariantChange)); + this.activateVariant(this.selectedVariant()); + } + + selectedVariant() { + const matched = this.variantQueries.find((entry) => entry.query.matches); + return matched ? matched.variant : this.manifest.variants[0]; + } + + variantChanged() { + const variant = this.selectedVariant(); + if (this.variant && variant.source === this.variant.source) return; + this.activateVariant(variant); + } + + activateVariant(variant) { + const resumeChapter = this.currentChapter; + this.cancelLoad(); + this.clearReadyTimer(); + this.removePlayer(); + this.variant = variant; + this.resumeChapter = resumeChapter; + this.playerReady = false; + this.dataset.playerReady = "false"; + const composition = variant.composition; + this.style.setProperty("--animate-it-crossfade-duration", `${this.options.crossfadeDuration}ms`); + this.viewport.style.aspectRatio = `${composition.width} / ${composition.height}`; + this.scaleFrame(); + this.updateChapters(0, null); + if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.scheduleLoad(); + } + + setupVisibility() { + if (!global.IntersectionObserver) { + this.visibleRatio = 1; + if (!this.reducedMotion.matches) this.scheduleLoad(); + return; + } + const thresholds = Array.from(new Set([0, this.options.loadWhenVisible, this.options.playWhenVisible, 1])).sort(); + this.intersectionObserver = new IntersectionObserver((entries) => { + const entry = entries[entries.length - 1]; + this.visibleRatio = entry && entry.isIntersecting && entry.boundingClientRect.height > 0 ? + entry.intersectionRect.height / entry.boundingClientRect.height : 0; + if (this.visibleRatio >= this.options.loadWhenVisible && !this.iframe && !this.reducedMotion.matches) this.scheduleLoad(); + this.syncPlayback(); + }, { threshold: thresholds }); + this.intersectionObserver.observe(this.viewport); + } + + applyReducedMotion() { + this.dataset.reducedMotion = this.reducedMotion.matches ? "true" : "false"; + if (this.reducedMotion.matches) { + this.cancelLoad(); + this.removePlayer(); + } + } + + reducedMotionChanged() { + this.applyReducedMotion(); + if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.scheduleLoad(); + } + + scheduleLoad() { + if (this.iframe || this.loadHandle) return; + const load = () => { + this.loadHandle = null; + if (!this.reducedMotion.matches && this.visibleRatio >= this.options.loadWhenVisible) this.mountPlayer(); + }; + if ("requestIdleCallback" in global) this.loadHandle = global.requestIdleCallback(load, { timeout: 800 }); + else this.loadHandle = global.setTimeout(load, 0); + } + + cancelLoad() { + if (!this.loadHandle) return; + if ("cancelIdleCallback" in global) global.cancelIdleCallback(this.loadHandle); + else global.clearTimeout(this.loadHandle); + this.loadHandle = null; + } + + mountPlayer() { + if (this.iframe) return; + const composition = this.variant.composition; + const separator = this.variant.source.includes("?") ? "&" : "?"; + const iframe = document.createElement("iframe"); + iframe.src = `${this.variant.source}${separator}embedded=1&host_navigation=${this.chapterControls.length ? "1" : "0"}`; + iframe.title = this.manifest.title; + iframe.loading = "eager"; + iframe.tabIndex = -1; + iframe.setAttribute("aria-hidden", "true"); + iframe.setAttribute("allow", "autoplay"); + iframe.width = composition.width; + iframe.height = composition.height; + iframe.addEventListener("load", () => { + if (iframe !== this.iframe) return; + if (!iframe.contentDocument || !iframe.contentDocument.querySelector("[data-animate-it-tracks]")) { + this.fail(new Error("AnimateIt player returned an invalid document")); + return; + } + this.readyTimer = global.setTimeout(() => this.degradedReady(), this.options.readyTimeout); + }, { once: true }); + iframe.addEventListener("error", () => this.fail(new Error("AnimateIt player failed to load")), { once: true }); + this.iframe = iframe; + this.frame.replaceChildren(iframe); + this.scaleFrame(); + } + + removePlayer() { + if (this.iframe) { + this.send("pause"); + this.iframe.remove(); + } + this.iframe = null; + this.playerReady = false; + this.playing = false; + if (this.control) this.control.hidden = true; + } + + scaleFrame() { + if (!this.variant || !this.viewport) return; + const composition = this.variant.composition; + const scale = this.viewport.clientWidth / composition.width; + this.frame.style.width = `${composition.width}px`; + this.frame.style.height = `${composition.height}px`; + this.frame.style.transform = `scale(${scale})`; + } + + receiveMessage(event) { + if (!this.iframe || event.source !== this.iframe.contentWindow || event.origin !== global.location.origin) return; + const message = event.data || {}; + if (message.namespace !== "animate-it" || !message.event) return; + const detail = message.detail || {}; + if (message.event === "ready") this.ready(detail); + else if (message.event === "framechange") this.frameChanged(detail); + else if (message.event === "chapterchange") this.chapterChanged(detail); + else if (message.event === "play") this.setPlaying(true); + else if (message.event === "pause" || message.event === "ended") this.setPlaying(false); + else if (message.event === "error") this.fail(new Error(detail.message || "AnimateIt player error")); + this.dispatchEvent(new CustomEvent(`animateit:${message.event}`, { detail })); + } + + ready(detail) { + this.clearReadyTimer(); + this.playerReady = true; + this.dataset.playerReady = "true"; + this.dataset.playerReadiness = "complete"; + this.control.hidden = false; + if (this.resumeChapter) this.send("seekChapter", { chapter: this.resumeChapter }); + this.updateChapters(detail.frame || 0, detail.chapter); + this.syncPlayback(); + } + + degradedReady() { + if (!this.iframe || this.playerReady) return; + this.playerReady = true; + this.dataset.playerReady = "true"; + this.dataset.playerReadiness = "degraded"; + this.control.hidden = false; + this.syncPlayback(); + this.dispatchEvent(new CustomEvent("animateit:degradedready")); + } + + fail(error) { + this.clearReadyTimer(); + this.dataset.playerReady = "false"; + this.dataset.playerError = "true"; + this.playerReady = false; + this.control.hidden = true; + this.dispatchEvent(new CustomEvent("animateit:error", { detail: { message: error.message } })); + } + + clearReadyTimer() { + if (this.readyTimer) global.clearTimeout(this.readyTimer); + this.readyTimer = null; + } + + frameChanged(detail) { + this.lastFrame = Number(detail.frame) || 0; + this.updateChapters(this.lastFrame, detail.chapter); + } + + chapterChanged(detail) { + this.currentChapter = detail.chapter || null; + this.updateChapters(Number(detail.frame) || 0, this.currentChapter); + } + + updateChapters(frame, namedChapter) { + const chapters = this.variant.composition.chapters; + let currentIndex = -1; + for (let index = chapters.length - 1; index >= 0; index -= 1) { + if (frame >= chapters[index].startFrame) { currentIndex = index; break; } + } + if (namedChapter) currentIndex = chapters.findIndex((chapter) => chapter.name === namedChapter); + const current = currentIndex >= 0 ? chapters[currentIndex] : null; + this.currentChapter = current && current.name; + const progress = current ? (current.durationFrames === 1 ? 1 : + clamp((frame - current.startFrame) / (current.durationFrames - 1), 0, 1)) : 0; + this.chapterControls.forEach((control) => { + const index = chapters.findIndex((chapter) => chapter.name === control.dataset.animateItChapter); + const state = currentIndex < 0 || index > currentIndex ? "upcoming" : (index < currentIndex ? "completed" : "current"); + const position = currentIndex < 0 ? "hidden" : + (index === currentIndex ? "current" : (index === currentIndex - 1 ? "previous" : (index === currentIndex + 1 ? "next" : "hidden"))); + control.dataset.chapterState = state; + control.dataset.chapterPosition = position; + control.style.setProperty("--animate-it-chapter-progress", String(state === "completed" ? 1 : (state === "current" ? progress : 0))); + control.style.setProperty("--animate-it-chapter-active", state === "current" ? "1" : "0"); + control.style.setProperty("--animate-it-chapter-complete", state === "completed" ? "1" : "0"); + if (state === "current") control.setAttribute("aria-current", "step"); + else control.removeAttribute("aria-current"); + }); + } + + seekChapter(name) { + if (!this.playerReady) { this.resumeChapter = name; return; } + this.send("seekChapter", { chapter: name }); + } + + seek(frame) { + this.send("seek", { frame: Number(frame) || 0 }); + } + + play() { + this.userStarted = true; + this.userPaused = false; + this.send("play"); + } + + pause() { + this.userPaused = true; + this.send("pause"); + } + + playingState() { + return this.playing; + } + + currentFrame() { + return this.lastFrame || 0; + } + + toggle() { + if (!this.playerReady) return; + this.userStarted = true; + this.userPaused = this.playing; + this.send(this.playing ? "pause" : "play"); + } + + syncPlayback() { + if (!this.playerReady) return; + const inViewport = this.visibleRatio >= this.options.playWhenVisible; + const shouldPlay = this.userStarted && !this.userPaused && !document.hidden && + (inViewport || !this.options.pauseOffscreen); + if (shouldPlay && !this.playing) this.send("play"); + else if (!shouldPlay && this.playing) this.send("pause"); + } + + send(command, detail) { + if (!this.iframe || !this.iframe.contentWindow) return; + this.iframe.contentWindow.postMessage(Object.assign({ namespace: "animate-it", command }, detail || {}), global.location.origin); + } + + setPlaying(playing) { + this.playing = playing; + this.control.textContent = playing ? "Pause" : "Play"; + this.control.setAttribute("aria-label", playing ? "Pause animation" : "Play animation"); + this.control.setAttribute("aria-pressed", playing ? "true" : "false"); + } + } + + global.customElements.define("animate-it-embed", AnimateItEmbed); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/animate_it/embed_styles.rb b/lib/animate_it/embed_styles.rb new file mode 100644 index 0000000..e360a79 --- /dev/null +++ b/lib/animate_it/embed_styles.rb @@ -0,0 +1,126 @@ +module AnimateIt + module EmbedStyles + module_function + + def chapter_source + @chapter_source ||= <<~CSS.freeze + .animate-it-chapters { + --animate-it-progress-color: #28d2bc; + --animate-it-progress-width: 2.5px; + --animate-it-active-glow: 0 0 10px 2px rgb(40 210 188 / 42%); + --animate-it-chapter-color: #414b57; + --animate-it-chapter-background: #fff; + --animate-it-chapter-shadow: 0 4px 12px rgb(39 47 57 / 8%); + --animate-it-chapter-font: 700 14px/1 system-ui, sans-serif; + --animate-it-chapter-gap: 14px; + --animate-it-chapter-height: 44px; + --animate-it-carousel-distance: 108%; + --animate-it-carousel-scale: .84; + --animate-it-carousel-opacity: .68; + --animate-it-carousel-duration: 180ms; + display: grid; + grid-template-columns: repeat(var(--animate-it-chapter-count, 4), minmax(0, 1fr)); + gap: var(--animate-it-chapter-gap); + } + .animate-it-chapter--pills { + --animate-it-chapter-progress: 0; + --animate-it-chapter-active: 0; + --animate-it-chapter-complete: 0; + position: relative; + min-width: 0; + min-height: 44px; + height: var(--animate-it-chapter-height); + padding: 2px; + border: 0; + border-radius: 999px; + appearance: none; + color: var(--animate-it-chapter-color); + background: var(--animate-it-chapter-background); + box-shadow: var(--animate-it-chapter-shadow); + cursor: pointer; + } + .animate-it-chapter--pills[data-chapter-state="current"] { box-shadow: var(--animate-it-active-glow); } + .animate-it-chapter--pills:focus-visible { outline: 3px solid var(--animate-it-progress-color); outline-offset: 3px; } + .animate-it-chapter__progress { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } + .animate-it-chapter__progress rect { + fill: none; + stroke: var(--animate-it-progress-color); + stroke-width: var(--animate-it-progress-width); + stroke-dasharray: var(--animate-it-chapter-progress) 1; + stroke-opacity: clamp(0, calc(var(--animate-it-chapter-progress) * 1000), 1); + stroke-linecap: round; + vector-effect: non-scaling-stroke; + } + .animate-it-chapter__label { + position: relative; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + opacity: calc(.58 + (var(--animate-it-chapter-active) * .42)); + font: var(--animate-it-chapter-font); + } + @media (max-width: 767px) { + .animate-it-chapters--mobile-carousel { position: relative; display: block; height: var(--animate-it-chapter-height); overflow: clip; } + .animate-it-chapters--mobile-carousel .animate-it-chapter--pills { + position: absolute; + left: 50%; + width: min(31.25%, 122px); + transition: transform var(--animate-it-carousel-duration) ease, opacity var(--animate-it-carousel-duration) ease; + } + .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="previous"] { transform: translateX(calc(-50% - var(--animate-it-carousel-distance))) scale(var(--animate-it-carousel-scale)); opacity: var(--animate-it-carousel-opacity); } + .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="current"] { transform: translateX(-50%); opacity: 1; } + .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="next"] { transform: translateX(calc(-50% + var(--animate-it-carousel-distance))) scale(var(--animate-it-carousel-scale)); opacity: var(--animate-it-carousel-opacity); } + .animate-it-chapters--mobile-carousel .animate-it-chapter--pills[data-chapter-position="hidden"] { visibility: hidden; pointer-events: none; opacity: 0; } + } + @media (prefers-reduced-motion: reduce) { + .animate-it-chapter--pills { transition: none !important; } + } + CSS + end + + def source + @source ||= <<~CSS.freeze + #{chapter_source} + animate-it-embed { + --animate-it-crossfade-duration: 120ms; + position: relative; + display: block; + width: 100%; + background: transparent; + } + .animate-it-embed__navigation { margin-bottom: 14px; } + .animate-it-embed__viewport { position: relative; width: 100%; background: transparent; } + .animate-it-embed__poster, .animate-it-embed__shell { position: absolute; inset: 0; width: 100%; height: 100%; } + .animate-it-embed__poster { z-index: 2; margin: 0; opacity: 1; transition: opacity var(--animate-it-crossfade-duration) ease; pointer-events: none; } + .animate-it-embed__poster img { display: block; width: 100%; height: 100%; object-fit: contain; } + .animate-it-embed__shell { z-index: 1; overflow: hidden; opacity: 0; transition: opacity var(--animate-it-crossfade-duration) ease; background: transparent; } + .animate-it-embed__frame { position: absolute; top: 0; left: 0; transform-origin: top left; background: transparent; } + .animate-it-embed__frame iframe { display: block; border: 0; background: transparent; pointer-events: none; } + animate-it-embed[data-player-ready="true"] .animate-it-embed__poster { opacity: 0; } + animate-it-embed[data-player-ready="true"] .animate-it-embed__shell { opacity: 1; } + .animate-it-embed__control { + position: absolute; + z-index: 4; + right: 12px; + bottom: 12px; + width: 44px; + height: 44px; + padding: 0; + border: 0; + border-radius: 999px; + color: #fff; + background: rgb(18 24 29 / 84%); + font: 700 13px/1 system-ui, sans-serif; + cursor: pointer; + } + .animate-it-embed__control:focus-visible { outline: 3px solid var(--animate-it-progress-color, #28d2bc); outline-offset: 3px; } + animate-it-embed[data-reduced-motion="true"] .animate-it-embed__navigation, + animate-it-embed[data-reduced-motion="true"] .animate-it-embed__control { display: none; } + @media (prefers-reduced-motion: reduce) { + .animate-it-embed__poster, .animate-it-embed__shell { transition: none; } + } + CSS + end + end +end diff --git a/lib/animate_it/engine.rb b/lib/animate_it/engine.rb index 5f182db..43549d0 100644 --- a/lib/animate_it/engine.rb +++ b/lib/animate_it/engine.rb @@ -29,7 +29,10 @@ class Engine < ::Rails::Engine end initializer "animate_it.embed_helper" do - ActiveSupport.on_load(:action_view) { include AnimateIt::EmbedHelper } + ActiveSupport.on_load(:action_view) do + include AnimateIt::EmbedHelper + include AnimateIt::ChapterNavigationHelper + end end end end diff --git a/lib/animate_it/player_manifest.rb b/lib/animate_it/player_manifest.rb new file mode 100644 index 0000000..44931b4 --- /dev/null +++ b/lib/animate_it/player_manifest.rb @@ -0,0 +1,29 @@ +module AnimateIt + class PlayerManifest + VERSION = 1 + + def initialize(composition) + @composition = composition + end + + def as_json(*) + @composition.chapters.validate! + { + "version" => VERSION, + "id" => @composition.id, + "width" => @composition.width, + "height" => @composition.height, + "fps" => @composition.fps, + "duration" => @composition.duration_in_frames, + "chapters" => @composition.chapters.as_json, + "playback" => @composition.public_player_options.transform_keys { |key| camelize(key) } + } + end + + private + + def camelize(key) + key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } + end + end +end diff --git a/lib/animate_it/runtime/runtime.js b/lib/animate_it/runtime/runtime.js index f7be528..908b421 100644 --- a/lib/animate_it/runtime/runtime.js +++ b/lib/animate_it/runtime/runtime.js @@ -74,6 +74,51 @@ }; } + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function createChapterState(manifest, root) { + var chapters = (manifest && manifest.chapters) || []; + var elements = chapters.length ? Array.prototype.slice.call(root.querySelectorAll("[data-animate-it-chapter]")) : []; + var lastName = null; + + function setElementState(el, chapter, index, currentIndex, progress) { + var state = currentIndex < 0 || index > currentIndex ? "upcoming" : (index < currentIndex ? "completed" : "current"); + var position = currentIndex < 0 ? "hidden" : + (index === currentIndex ? "current" : (index === currentIndex - 1 ? "previous" : (index === currentIndex + 1 ? "next" : "hidden"))); + var chapterProgress = state === "completed" ? 1 : (state === "current" ? progress : 0); + el.dataset.chapterState = state; + el.dataset.chapterPosition = position; + el.style.setProperty("--animate-it-chapter-progress", String(chapterProgress)); + el.style.setProperty("--animate-it-chapter-active", state === "current" ? "1" : "0"); + el.style.setProperty("--animate-it-chapter-complete", state === "completed" ? "1" : "0"); + if (el.tagName === "BUTTON") { + if (state === "current") el.setAttribute("aria-current", "step"); + else el.removeAttribute("aria-current"); + } + } + + function update(frame) { + var currentIndex = -1; + for (var i = chapters.length - 1; i >= 0; i -= 1) { + if (frame >= chapters[i].startFrame) { currentIndex = i; break; } + } + var current = currentIndex >= 0 ? chapters[currentIndex] : null; + var progress = current ? (current.durationFrames === 1 ? 1 : + clamp((frame - current.startFrame) / (current.durationFrames - 1), 0, 1)) : 0; + elements.forEach(function (el) { + var index = chapters.findIndex(function (chapter) { return chapter.name === el.dataset.animateItChapter; }); + if (index >= 0) setElementState(el, chapters[index], index, currentIndex, progress); + }); + var changed = (current && current.name) !== lastName; + lastName = current && current.name; + return { chapter: current, index: currentIndex, progress: progress, changed: changed }; + } + + return { update: update, chapters: chapters }; + } + // Pause native CSS/Web Animations and seek them to deterministic frame // time. Layers use scene-local time so delayed scenes start at zero. var animationCache = typeof WeakMap === "undefined" ? null : new WeakMap(); @@ -100,7 +145,8 @@ return animations; } - function createPlayer(doc, root) { + function createPlayer(doc, root, options) { + var settings = options || {}; var duration = doc.duration; var varBindings = []; var group; @@ -140,9 +186,12 @@ }); var current = -1; + var chapterState = createChapterState(settings.manifest, root); + var currentChapter = null; function setFrame(n) { var frame = Math.max(0, Math.min(duration - 1, Math.round(Number(n) || 0))); + var frameChanged = frame !== current; current = frame; layers.forEach(function (layer) { @@ -180,6 +229,8 @@ } else { seekAnimations(root, frame, doc.fps); } + currentChapter = chapterState.update(frame); + if (frameChanged && typeof settings.onFrame === "function") settings.onFrame(frame, currentChapter); return frame; } @@ -187,7 +238,8 @@ duration: duration, fps: doc.fps, setFrame: setFrame, - currentFrame: function () { return current; } + currentFrame: function () { return current; }, + currentChapter: function () { return currentChapter; } }; } @@ -204,6 +256,7 @@ var animationFrame = null; var startedAt = 0; var startedFrame = frame; + var emit = typeof settings.onEvent === "function" ? settings.onEvent : function () {}; audios.forEach(function (el) { var gain = Number(el.dataset.gain); @@ -269,10 +322,12 @@ } function pause() { + var wasPlaying = animationFrame !== null; if (animationFrame !== null) global.cancelAnimationFrame(animationFrame); animationFrame = null; syncAudio(frame, false); updateButton(false); + if (wasPlaying) emit("pause", { frame: frame }); } function seek(nextFrame) { @@ -294,6 +349,7 @@ if (next >= duration) { if (!shouldLoop) { frame = player.setFrame(duration - 1); + emit("ended", { frame: frame }); pause(); return; } @@ -316,8 +372,10 @@ startedFrame = frame; updateButton(true); animationFrame = global.requestAnimationFrame(tick); + emit("play", { frame: frame }); return syncAudio(frame, true).catch(function (error) { pause(); + emit("error", { message: error && error.message ? error.message : "AnimateIt playback failed" }); throw error; }); } @@ -339,10 +397,56 @@ }; } + function waitForReady(root) { + var fonts = root.fonts && root.fonts.ready ? root.fonts.ready.catch(function () {}) : Promise.resolve(); + var images = Array.prototype.slice.call(root.images || []).filter(function (image) { + if (image.hidden) return false; + if (typeof image.getBoundingClientRect !== "function") return true; + var rect = image.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }).map(function (image) { + if (image.complete) { + if (image.naturalWidth === 0) return Promise.reject(new Error("AnimateIt visible image failed to load")); + return typeof image.decode === "function" ? image.decode() : Promise.resolve(); + } + return new Promise(function (resolve, reject) { + image.addEventListener("load", resolve, { once: true }); + image.addEventListener("error", function () { + reject(new Error("AnimateIt visible image failed to load")); + }, { once: true }); + }); + }); + return Promise.all([fonts, Promise.all(images)]).then(function () { + return new Promise(function (resolve) { + global.requestAnimationFrame(function () { global.requestAnimationFrame(resolve); }); + }); + }); + } + function boot() { var script = document.querySelector("script[data-animate-it-tracks]"); if (!script) return; - var player = createPlayer(JSON.parse(script.textContent), document); + var manifestScript = document.querySelector("script[data-animate-it-manifest]"); + var manifest = manifestScript ? JSON.parse(manifestScript.textContent) : { chapters: [] }; + var currentChapterName = null; + function emit(name, detail) { + var payload = Object.assign({ frame: player ? player.currentFrame() : 0 }, detail || {}); + if (typeof global.CustomEvent === "function") global.dispatchEvent(new CustomEvent("animateit:" + name, { detail: payload })); + if (global.parent && global.parent !== global && global.location) { + global.parent.postMessage({ namespace: "animate-it", event: name, detail: payload }, global.location.origin); + } + } + var player = createPlayer(JSON.parse(script.textContent), document, { + manifest: manifest, + onFrame: function (frame, chapterState) { + var chapter = chapterState.chapter; + emit("framechange", { frame: frame, chapter: chapter && chapter.name, progress: chapterState.progress }); + if (chapterState.changed) { + currentChapterName = chapter && chapter.name; + emit("chapterchange", { frame: frame, chapter: currentChapterName, progress: chapterState.progress }); + } + } + }); player.setFrame(0); global.__animateIt = { totalFrames: player.duration, setFrame: player.setFrame }; global.AnimateItRuntime = player; @@ -352,13 +456,41 @@ Array.prototype.slice.call(document.querySelectorAll("audio[data-from-frame]")), { loop: script.dataset.animateItLoop === "true", - button: document.querySelector("[data-animate-it-play]") + button: document.querySelector("[data-animate-it-play]"), + onEvent: emit } ); + transport.seekChapter = function (name) { + var chapter = (manifest.chapters || []).find(function (item) { return item.name === String(name); }); + if (!chapter) throw new Error("Unknown AnimateIt chapter: " + name); + return transport.seek(chapter.startFrame); + }; global.AnimateItTransport = transport; + global.AnimateItPlayer = transport; + global.addEventListener("message", function (event) { + if (event.source !== global.parent || !global.location || event.origin !== global.location.origin) return; + var data = event.data || {}; + if (data.namespace !== "animate-it" || data.command === undefined) return; + try { + if (data.command === "play") transport.play().catch(function () {}); + else if (data.command === "pause") transport.pause(); + else if (data.command === "toggle") transport.toggle().catch(function () {}); + else if (data.command === "seek") transport.seek(data.frame); + else if (data.command === "seekChapter") transport.seekChapter(data.chapter); + } catch (error) { + emit("error", { message: error.message }); + } + }); if (script.dataset.animateItAutoplay === "true") transport.play().catch(function () {}); } - document.documentElement.dataset.animateItReady = "1"; + waitForReady(document) + .then(function () { + document.documentElement.dataset.animateItReady = "1"; + emit("ready", { manifest: manifest, chapter: currentChapterName }); + }) + .catch(function (error) { + emit("error", { message: error && error.message ? error.message : "AnimateIt player failed readiness" }); + }); } var api = { @@ -367,6 +499,7 @@ formatComputed: formatComputed, interpolate: interpolate, compileTrack: compileTrack, + createChapterState: createChapterState, seekAnimations: seekAnimations, createPlayer: createPlayer, createTransport: createTransport diff --git a/lib/animate_it/version.rb b/lib/animate_it/version.rb index c8cc231..4f7d15f 100644 --- a/lib/animate_it/version.rb +++ b/lib/animate_it/version.rb @@ -1,3 +1,3 @@ module AnimateIt - VERSION = "0.4.0".freeze + VERSION = "0.5.0".freeze end diff --git a/package.json b/package.json index f753f82..000409e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "animate-it-skills", - "version": "0.4.0", + "version": "0.5.0", "description": "Claude Code / Agent Skills for the AnimateIt Rails gem — author videos, GIFs, and stills from your Rails app's own components and data. Run `npx animate-it-skills` to install into .claude/skills.", "bin": { "animate-it-skills": "bin/install-skills.js" @@ -21,13 +21,13 @@ "remotion" ], "license": "MIT", - "homepage": "https://github.com/growth-constant/animate_it#readme", + "homepage": "https://github.com/joinbuildit/animate_it#readme", "repository": { "type": "git", - "url": "git+https://github.com/growth-constant/animate_it.git" + "url": "git+https://github.com/joinbuildit/animate_it.git" }, "bugs": { - "url": "https://github.com/growth-constant/animate_it/issues" + "url": "https://github.com/joinbuildit/animate_it/issues" }, "engines": { "node": ">=16.7" diff --git a/skills/animate-it-generation/SKILL.md b/skills/animate-it-generation/SKILL.md index eb2adad..80bfc7d 100644 --- a/skills/animate-it-generation/SKILL.md +++ b/skills/animate-it-generation/SKILL.md @@ -1,6 +1,6 @@ --- name: animate-it-generation -description: Author animated videos, GIFs, and still images with the AnimateIt Rails gem — compositions written as a Ruby class + a HAML or ERB canvas, played by a deterministic browser track runtime, and rendered to MP4/WebM/GIF/PNG via Playwright + FFmpeg. Use when a Rails app needs an animated hero, a product demo, a launch clip, a social ad, or a still hero rendered from the app's own components and data. Covers the client-driven composition DSL (`client_driven!`, `track_vars`, `text_track`, `animate`, `beat`, and `outputs`), Studio, deterministic verification, embedding, real app partials, audio, and common gotchas. +description: Author animated videos, interactive Rails embeds, GIFs, and still images with the AnimateIt Rails gem — compositions written as a Ruby class + a HAML or ERB canvas, played by a deterministic browser track runtime, and rendered to MP4/WebM/GIF/PNG via Playwright + FFmpeg. Use when a Rails app needs an animated hero, navigable product demo, launch clip, social ad, or still hero rendered from the app's own components and data. Covers chapters, responsive production embeds, the client-driven composition DSL, Studio, deterministic verification, real app partials, audio, and common gotchas. --- # AnimateIt — animation, video, GIF, and still generation @@ -137,6 +137,7 @@ Class-level on `AnimateIt::Composition`: | `output_basename "..."` | Filename stem; defaults to `id`. | | `outputs do … end` | Per-format destinations — `mp4`, `webm`, `gif`, `mov`, `png_sequence`, `png frame: N`. Pass `to: "..."` to override a path. | | `beat :name, at:, length:` | Named time marker. Accepts frames, durations, or time strings. | +| `chapter :name, beat:, label:` | Public, user-navigable stage backed by an existing beat. Optional `metadata:` supports custom controls. | | `props do … end` | Prop schema for parameterized renders (Studio prop editor). | | `verification_props({}, {...})` | Props variants checked by `verify` / `verify_all`. | | `structure_epochs 90, 180` | Frames where the DOM shape changes; each creates one structural layer. | @@ -254,7 +255,48 @@ For a typical page hero, declare `mp4 / webm / gif / png frame: 0`: WebM gives t ## Embedding on a page -Native `