From 37180f40701e476f9a0edbc648a13d84ad52cc99 Mon Sep 17 00:00:00 2001 From: Victor Fernandez Date: Thu, 13 Aug 2026 16:31:44 -0600 Subject: [PATCH 1/4] Add interactive chapter embeds --- .../animate_it/embed_assets_controller.rb | 25 ++ .../animate_it/frames_controller.rb | 3 + .../animate_it/public_players_controller.rb | 6 +- app/views/animate_it/frames/player.html.haml | 13 +- config/routes.rb | 5 + lib/animate_it.rb | 8 + lib/animate_it/chapter_navigation.rb | 160 +++++++++ lib/animate_it/chapters.rb | 95 +++++ lib/animate_it/composition.rb | 15 + lib/animate_it/embed_helper.rb | 208 ++++++++++- lib/animate_it/embed_runtime.rb | 13 + lib/animate_it/embed_runtime/embed.js | 338 ++++++++++++++++++ lib/animate_it/embed_styles.rb | 126 +++++++ lib/animate_it/engine.rb | 5 +- lib/animate_it/player_manifest.rb | 29 ++ lib/animate_it/runtime/runtime.js | 143 +++++++- spec/animate_it/chapters_spec.rb | 76 ++++ spec/animate_it/embed_helper_spec.rb | 135 +++++++ spec/animate_it/runtime_animation_spec.rb | 75 ++++ spec/animate_it/standalone_load_spec.rb | 13 + .../app/controllers/embeds_controller.rb | 11 + .../app/videos/broken_image_spec_video.rb | 17 + .../client_runtime_mobile_spec_video.rb | 38 ++ .../app/videos/client_runtime_spec_video.rb | 14 +- spec/dummy/app/views/embeds/broken.html.erb | 12 + .../app/views/embeds/headless_erb.html.erb | 7 + .../app/views/embeds/headless_haml.html.haml | 4 + spec/dummy/app/views/embeds/show.html.erb | 31 ++ spec/dummy/config/routes.rb | 4 + spec/rails_helper.rb | 2 + spec/rendering_spec.rb | 139 ++++++- spec/requests/studio_spec.rb | 85 +++++ 32 files changed, 1836 insertions(+), 19 deletions(-) create mode 100644 app/controllers/animate_it/embed_assets_controller.rb create mode 100644 lib/animate_it/chapter_navigation.rb create mode 100644 lib/animate_it/chapters.rb create mode 100644 lib/animate_it/embed_runtime.rb create mode 100644 lib/animate_it/embed_runtime/embed.js create mode 100644 lib/animate_it/embed_styles.rb create mode 100644 lib/animate_it/player_manifest.rb create mode 100644 spec/animate_it/chapters_spec.rb create mode 100644 spec/animate_it/standalone_load_spec.rb create mode 100644 spec/dummy/app/controllers/embeds_controller.rb create mode 100644 spec/dummy/app/videos/broken_image_spec_video.rb create mode 100644 spec/dummy/app/videos/client_runtime_mobile_spec_video.rb create mode 100644 spec/dummy/app/views/embeds/broken.html.erb create mode 100644 spec/dummy/app/views/embeds/headless_erb.html.erb create mode 100644 spec/dummy/app/views/embeds/headless_haml.html.haml create mode 100644 spec/dummy/app/views/embeds/show.html.erb 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/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/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/spec/animate_it/chapters_spec.rb b/spec/animate_it/chapters_spec.rb new file mode 100644 index 0000000..3f49f4e --- /dev/null +++ b/spec/animate_it/chapters_spec.rb @@ -0,0 +1,76 @@ +require "rails_helper" + +RSpec.describe AnimateIt::Chapters do + def composition(&block) + Class.new(AnimateIt::Composition) do + fps 10 + duration 30.frames + class_eval(&block) + end + end + + it "declares navigable chapters from existing beats" do + target = composition do + beat :intro, at: 0, length: 10.frames + beat :finish, at: 15.frames, length: 15.frames + chapter :intro, beat: :intro, label: "Intro", metadata: { thumbnail: "/intro.webp" } + chapter :finish, beat: :finish, label: "Finish" + end + + expect(target.chapters.map(&:name)).to eq(%i[intro finish]) + expect(target.chapters.fetch(:intro).metadata).to eq(thumbnail: "/intro.webp") + expect(target.player_manifest.as_json.fetch("chapters").first).to include( + "name" => "intro", "startFrame" => 0, "durationFrames" => 10, + "metadata" => { thumbnail: "/intro.webp" } + ) + end + + it "allows gaps while preserving ordered, non-overlapping chapters" do + target = composition do + beat :first, at: 0, length: 5.frames + beat :second, at: 10.frames, length: 5.frames + chapter :first, beat: :first, label: "First" + chapter :second, beat: :second, label: "Second" + end + + expect { target.chapters.validate! }.not_to raise_error + end + + it "rejects missing beats, blank labels, duplicate names, overlap, and out-of-bounds chapters" do + expect do + composition { chapter :missing, beat: :missing, label: "Missing" } + end.to raise_error(AnimateIt::Error, /Unknown beat/) + + expect do + composition do + beat :intro, at: 0, length: 5.frames + chapter :intro, beat: :intro, label: " " + end + end.to raise_error(ArgumentError, /labels must not be blank/) + + expect do + composition do + beat :one, at: 0, length: 10.frames + beat :two, at: 10.frames, length: 10.frames + chapter :same, beat: :one, label: "One" + chapter :same, beat: :two, label: "Two" + end + end.to raise_error(ArgumentError, /names must be unique/) + + expect do + composition do + beat :one, at: 0, length: 12.frames + beat :two, at: 10.frames, length: 10.frames + chapter :one, beat: :one, label: "One" + chapter :two, beat: :two, label: "Two" + end + end.to raise_error(ArgumentError, /must not overlap/) + + expect do + composition do + beat :late, at: 25.frames, length: 10.frames + chapter :late, beat: :late, label: "Late" + end + end.to raise_error(ArgumentError, /must fit within/) + end +end diff --git a/spec/animate_it/embed_helper_spec.rb b/spec/animate_it/embed_helper_spec.rb index fd7f053..332fc5b 100644 --- a/spec/animate_it/embed_helper_spec.rb +++ b/spec/animate_it/embed_helper_spec.rb @@ -19,4 +19,139 @@ expect { helper.animate_it_player("dummy-motion") } .to raise_error(ArgumentError, /not public/) end + + it "builds a poster-first interactive embed with accessible pill chapters" do + html = helper.animate_it_embed( + "client-runtime-spec", + poster: "/poster.webp", + navigation: { preset: :pills, mobile: :carousel }, + title: "Product walkthrough" + ) + page = Capybara.string(html) + embed = page.find("animate-it-embed", visible: :all) + manifest = JSON.parse(embed.find("script[data-animate-it-embed-manifest]", visible: :all).text) + + expect(page).to have_css('link[data-animate-it-embed-asset="style"]', visible: :all) + expect(page).to have_css('script[data-animate-it-embed-asset="script"]', visible: :all) + expect(page).to have_css(".animate-it-chapters--mobile-carousel", visible: :all) + expect(page).to have_css('button[aria-label="Jump to Intro"]', visible: :all) + expect(page).to have_css('svg rect[pathlength="1"]', count: 3, visible: :all) + expect(page).to have_css('img[src="/poster.webp"]', visible: :all) + expect(manifest.dig("options", "loadWhenVisible")).to eq(0.25) + expect(manifest.dig("options", "playWhenVisible")).to eq(2.0 / 3) + expect(manifest.dig("variants", 0, "composition", "chapters").pluck("name")) + .to eq(%w[intro details finish]) + end + + it "supports headless custom chapter controls" do + html = helper.animate_it_embed("client-runtime-spec", poster: "/poster.webp") do |embed| + embed.chapter_navigation(class: "custom-timeline") do |chapter| + chapter.button(class: "product-demo-card") do + helper.safe_join([helper.tag.span(chapter.label), helper.tag.span(chapter.thumbnail.to_s)]) + end + end + end + page = Capybara.string(html) + + expect(page).to have_css("nav.custom-timeline .product-demo-card", count: 3, visible: :all) + expect(page).to have_css('[data-animate-it-chapter="intro"]', text: "Intro/intro.webp", visible: :all) + expect(page).to have_no_css(".animate-it-chapters--pills", visible: :all) + expect(page).to have_no_css(".animate-it-chapter--pills", visible: :all) + end + + it "renders deterministic canvas chapter controls for Studio and exports" do + html = helper.animate_it_chapter_navigation( + composition: ClientRuntimeSpecVideo, + preset: :pills, + hide_when_embedded: true + ) + page = Capybara.string(html) + + expect(page).to have_css("style[data-animate-it-chapter-styles]", visible: :all) + expect(page).to have_css('[data-animate-it-hide-when-embedded="true"]', visible: :all) + expect(page).to have_css('div[data-animate-it-chapter="intro"]', visible: :all) + expect(page).to have_no_css("button", visible: :all) + end + + it "renders headless canvas SVG without applying the pill preset" do + html = helper.animate_it_chapter_navigation(composition: ClientRuntimeSpecVideo, frame: 5) do |chapter| + chapter.element(tag: :svg, class: "timeline-marker", viewBox: "0 0 20 20") do + helper.tag.text(chapter.label, x: 1, y: 10) + end + end + page = Capybara.string(html) + + expect(page).to have_css("svg.timeline-marker", count: 3, visible: :all) + expect(page).to have_css('[data-animate-it-chapter="details"][data-chapter-state="current"]', visible: :all) + expect(page).to have_no_css(".animate-it-chapter--pills", visible: :all) + end + + it "ships perimeter progress as one SVG stroke with an outer CSS glow" do + css = AnimateIt::EmbedStyles.chapter_source + + expect(css).to include("stroke-dasharray: var(--animate-it-chapter-progress) 1") + expect(css).to include("stroke-opacity: clamp") + expect(css).to include("box-shadow: var(--animate-it-active-glow)") + expect(css).not_to include("filter: blur") + expect(css).to include("--animate-it-chapter-font", "--animate-it-carousel-distance") + end + + it "rejects invalid embed options before rendering" do + expect do + helper.animate_it_embed("client-runtime-spec", poster: "/poster.webp", load_when_visible: 1.1) + end.to raise_error(ArgumentError, /thresholds must be between 0 and 1/) + + expect do + helper.animate_it_embed("client-runtime-spec", poster: "/poster.webp", reduced_motion: nil) + end.to raise_error(ArgumentError, /reduced_motion must be :poster/) + end + + it "validates responsive chapter compatibility and emits picture sources" do + responsive = Class.new(AnimateIt::Composition) do + id "client-runtime-mobile" + public_player! + fps 10 + size 120, 240 + duration 24.frames + beat :intro, at: 0, length: 8.frames + beat :details, at: 8.frames, length: 8.frames + beat :finish, at: 16.frames, length: 8.frames + chapter :intro, beat: :intro, label: "Intro" + chapter :details, beat: :details, label: "Details" + chapter :finish, beat: :finish, label: "Finish" + end + AnimateIt.register(responsive) + + html = helper.animate_it_embed( + "client-runtime-spec", + poster: "/desktop.webp", + variants: [{ media: "(max-width: 767px)", composition: "client-runtime-mobile", poster: "/mobile.webp" }] + ) + page = Capybara.string(html) + + expect(page).to have_css('source[media="(max-width: 767px)"][srcset="/mobile.webp"]', visible: :all) + ensure + AnimateIt.reset! + end + + it "rejects responsive variants with different chapter contracts" do + mismatched = Class.new(AnimateIt::Composition) do + id "client-runtime-mismatch" + public_player! + duration 18.frames + beat :intro, at: 0, length: 18.frames + chapter :intro, beat: :intro, label: "Different" + end + AnimateIt.register(mismatched) + + expect do + helper.animate_it_embed( + "client-runtime-spec", + poster: "/desktop.webp", + variants: [{ media: "(max-width: 767px)", composition: "client-runtime-mismatch", poster: "/mobile.webp" }] + ) + end.to raise_error(ArgumentError, /same ordered chapter names and labels/) + ensure + AnimateIt.reset! + end end diff --git a/spec/animate_it/runtime_animation_spec.rb b/spec/animate_it/runtime_animation_spec.rb index 665fbe4..ac8b710 100644 --- a/spec/animate_it/runtime_animation_spec.rb +++ b/spec/animate_it/runtime_animation_spec.rb @@ -199,4 +199,79 @@ def run_node(script) "button" => "Play", "pressed" => "false" ) end + + it "derives normalized chapter progress and previous/current/next state at every frame" do + result = run_node(<<~JS) + const api = require(process.argv[1]); + const element = (name) => ({ + tagName: "BUTTON", dataset: { animateItChapter: name }, attrs: {}, + style: { values: {}, setProperty(key, value) { this.values[key] = value; } }, + setAttribute(key, value) { this.attrs[key] = value; }, + removeAttribute(key) { delete this.attrs[key]; } + }); + const elements = [element("intro"), element("details"), element("finish")]; + const state = api.createChapterState({ chapters: [ + { name: "intro", startFrame: 0, durationFrames: 4 }, + { name: "details", startFrame: 6, durationFrames: 4 }, + { name: "finish", startFrame: 10, durationFrames: 2 } + ] }, { querySelectorAll() { return elements; } }); + const snapshot = (frame) => { + const current = state.update(frame); + return { + frame, chapter: current.chapter && current.chapter.name, progress: current.progress, + elements: elements.map((el) => ({ + name: el.dataset.animateItChapter, state: el.dataset.chapterState, + position: el.dataset.chapterPosition, + progress: el.style.values["--animate-it-chapter-progress"], + current: el.attrs["aria-current"] || null + })) + }; + }; + process.stdout.write(JSON.stringify([snapshot(2), snapshot(5), snapshot(6), snapshot(11)])); + JS + + expect(result.dig(0, "chapter")).to eq("intro") + expect(result.dig(0, "progress")).to be_within(0.0001).of(2.0 / 3) + expect(result.dig(1, "progress")).to eq(1) + expect(result.dig(1, "elements", 0)).to include("state" => "current", "current" => "step") + expect(result.dig(2, "elements", 0)).to include("state" => "completed", "progress" => "1") + expect(result.dig(2, "elements", 1)).to include("state" => "current", "position" => "current") + expect(result.dig(3, "chapter")).to eq("finish") + expect(result.dig(3, "progress")).to eq(1) + end + + it "emits stable transport lifecycle events without changing seek playback state" do + result = run_node(<<~JS) + const api = require(process.argv[1]); + let callback = null; + global.performance = { now: () => 0 }; + global.requestAnimationFrame = (next) => { callback = next; return 1; }; + global.cancelAnimationFrame = () => { callback = null; }; + let frame = 0; + const events = []; + const player = { + duration: 20, fps: 10, currentFrame: () => frame, + setFrame(value) { frame = value; return frame; } + }; + const transport = api.createTransport(player, [], { + loop: false, + onEvent(name, detail) { events.push([name, detail.frame]); } + }); + (async () => { + await transport.play(); + transport.seek(7); + const playingAfterSeek = transport.playing(); + transport.pause(); + transport.seek(2); + process.stdout.write(JSON.stringify({ events, playingAfterSeek, playingAfterPausedSeek: transport.playing(), frame })); + })(); + JS + + expect(result).to eq( + "events" => [["play", 0], ["pause", 7]], + "playingAfterSeek" => true, + "playingAfterPausedSeek" => false, + "frame" => 2 + ) + end end diff --git a/spec/animate_it/standalone_load_spec.rb b/spec/animate_it/standalone_load_spec.rb new file mode 100644 index 0000000..74b56a6 --- /dev/null +++ b/spec/animate_it/standalone_load_spec.rb @@ -0,0 +1,13 @@ +require "open3" + +RSpec.describe "standalone AnimateIt loading" do + it "loads the packaged entrypoint before Rails is booted" do + library = File.expand_path("../../lib", __dir__) + stdout, stderr, status = Open3.capture3( + RbConfig.ruby, "-I#{library}", "-e", 'require "animate_it"; print AnimateIt::VERSION' + ) + + expect(status).to be_success, stderr + expect(stdout).to eq(AnimateIt::VERSION) + end +end diff --git a/spec/dummy/app/controllers/embeds_controller.rb b/spec/dummy/app/controllers/embeds_controller.rb new file mode 100644 index 0000000..3870092 --- /dev/null +++ b/spec/dummy/app/controllers/embeds_controller.rb @@ -0,0 +1,11 @@ +class EmbedsController < ActionController::Base + protect_from_forgery with: :exception + + def show; end + + def broken; end + + def headless_erb; end + + def headless_haml; end +end diff --git a/spec/dummy/app/videos/broken_image_spec_video.rb b/spec/dummy/app/videos/broken_image_spec_video.rb new file mode 100644 index 0000000..195d714 --- /dev/null +++ b/spec/dummy/app/videos/broken_image_spec_video.rb @@ -0,0 +1,17 @@ +class BrokenImageSpecVideo < AnimateIt::Composition + id "broken-image-spec" + public_player! autoplay: true + fps 10 + size 240, 120 + duration 10.frames + beat :broken, at: 0, length: 10.frames + chapter :broken, beat: :broken, label: "Broken" + + class Scene < AnimateIt::Scene + def body + tag.img src: "/missing-visible-frame.webp", alt: "Missing frame fixture", style: "width:100%;height:100%" + end + end + + scene Scene +end diff --git a/spec/dummy/app/videos/client_runtime_mobile_spec_video.rb b/spec/dummy/app/videos/client_runtime_mobile_spec_video.rb new file mode 100644 index 0000000..e0d1a3e --- /dev/null +++ b/spec/dummy/app/videos/client_runtime_mobile_spec_video.rb @@ -0,0 +1,38 @@ +class ClientRuntimeMobileSpecVideo < AnimateIt::Composition + id "client-runtime-mobile-spec" + public_player! + fps 10 + size 120, 240 + duration 24.frames + beat :intro, at: 0, length: 8.frames + beat :details, at: 8.frames, length: 8.frames + beat :finish, at: 16.frames, length: 8.frames + chapter :intro, beat: :intro, label: "Intro" + chapter :details, beat: :details, label: "Details" + chapter :finish, beat: :finish, label: "Finish" + + class Scene < AnimateIt::Scene + track_vars(:root) { { hue: local_frame * 4 } } + text_track(:frame) { local_frame.to_s } + + def body + tag.div( + style: "width:120px;height:240px;background:#172b35;color:white;display:grid;place-items:center", + data: { animate_vars: "root" } + ) do + safe_join([ + tag.strong("Mobile"), + animate_text(:frame), + view_context.animate_it_chapter_navigation( + composition: self.class.composition_class, + preset: :pills, + hide_when_embedded: true, + frame: + ) + ]) + end + end + end + + scene Scene +end diff --git a/spec/dummy/app/videos/client_runtime_spec_video.rb b/spec/dummy/app/videos/client_runtime_spec_video.rb index 776cb28..89effdc 100644 --- a/spec/dummy/app/videos/client_runtime_spec_video.rb +++ b/spec/dummy/app/videos/client_runtime_spec_video.rb @@ -7,6 +7,12 @@ class ClientRuntimeSpecVideo < AnimateIt::Composition size 240, 120 duration 18.frames structure_epochs 5, 11 + beat :intro, at: 0, length: 5.frames + beat :details, at: 5.frames, length: 6.frames + beat :finish, at: 11.frames, length: 7.frames + chapter :intro, beat: :intro, label: "Intro", metadata: { thumbnail: "/intro.webp" } + chapter :details, beat: :details, label: "Details" + chapter :finish, beat: :finish, label: "Finish" class Scene < AnimateIt::Scene class << self @@ -64,7 +70,13 @@ def body "pulse", data: { css_animation_probe: true }, style: "animation:runtime-spec-pulse 1s linear infinite alternate" ), - reveal_words(:headline) + reveal_words(:headline), + view_context.animate_it_chapter_navigation( + composition: self.class.composition_class, + preset: :pills, + hide_when_embedded: true, + frame: + ) ], " " ) diff --git a/spec/dummy/app/views/embeds/broken.html.erb b/spec/dummy/app/views/embeds/broken.html.erb new file mode 100644 index 0000000..04bbf58 --- /dev/null +++ b/spec/dummy/app/views/embeds/broken.html.erb @@ -0,0 +1,12 @@ + +
+ <%= animate_it_embed( + "broken-image-spec", + poster: "/clapper.png", + navigation: false, + title: "Broken player fallback" + ) %> +
diff --git a/spec/dummy/app/views/embeds/headless_erb.html.erb b/spec/dummy/app/views/embeds/headless_erb.html.erb new file mode 100644 index 0000000..3dbce70 --- /dev/null +++ b/spec/dummy/app/views/embeds/headless_erb.html.erb @@ -0,0 +1,7 @@ +<%= animate_it_embed("client-runtime-spec", poster: "/clapper.png") do |embed| %> + <%= embed.chapter_navigation(class: "product-demo-cards") do |chapter| %> + <%= chapter.button(class: "product-demo-card") do %> + <%= chapter.label %> + <% end %> + <% end %> +<% end %> diff --git a/spec/dummy/app/views/embeds/headless_haml.html.haml b/spec/dummy/app/views/embeds/headless_haml.html.haml new file mode 100644 index 0000000..1a38e98 --- /dev/null +++ b/spec/dummy/app/views/embeds/headless_haml.html.haml @@ -0,0 +1,4 @@ += animate_it_embed("client-runtime-spec", poster: "/clapper.png") do |embed| + = embed.chapter_navigation(class: "product-demo-timeline") do |chapter| + = chapter.button(class: "product-demo-timeline__step") do + %span= chapter.label diff --git a/spec/dummy/app/views/embeds/show.html.erb b/spec/dummy/app/views/embeds/show.html.erb new file mode 100644 index 0000000..0de0ccf --- /dev/null +++ b/spec/dummy/app/views/embeds/show.html.erb @@ -0,0 +1,31 @@ + +
">Scroll to demo
+
+

Interactive product chapters

+

Seekable, responsive, and rendered from Rails.

+
+
+ <%= animate_it_embed( + "client-runtime-spec", + poster: "/clapper.png", + variants: [ + { + media: "(max-width: 479px)", + composition: "client-runtime-mobile-spec", + poster: "/clapper.png" + } + ], + navigation: { preset: :pills, mobile: :carousel }, + load_when_visible: 0.25, + play_when_visible: 2.0 / 3, + title: "Interactive Animate It demo" + ) %> +
diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 6c365b3..b910fa1 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -1,3 +1,7 @@ Rails.application.routes.draw do + get "embed-spec", to: "embeds#show" + get "embed-broken-spec", to: "embeds#broken" + get "embed-headless-erb", to: "embeds#headless_erb" + get "embed-headless-haml", to: "embeds#headless_haml" mount AnimateIt::Engine, at: AnimateIt.config.mount_path end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 610e840..814361a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -26,5 +26,7 @@ AnimateIt.register(DummyMotionVideo) AnimateIt.register(DummyErbVideo) AnimateIt.register(ClientRuntimeSpecVideo) if defined?(ClientRuntimeSpecVideo) + AnimateIt.register(ClientRuntimeMobileSpecVideo) if defined?(ClientRuntimeMobileSpecVideo) + AnimateIt.register(BrokenImageSpecVideo) if defined?(BrokenImageSpecVideo) end end diff --git a/spec/rendering_spec.rb b/spec/rendering_spec.rb index 6db2700..0b5a6f9 100644 --- a/spec/rendering_spec.rb +++ b/spec/rendering_spec.rb @@ -186,6 +186,141 @@ end end + it "loads, navigates, and swaps a responsive public embed without exposing its iframe" do + require "playwright" + + with_browser(viewport: { width: 800, height: 700 }) do |context| + page = context.new_page + response = page.goto("#{server_host}/embed-spec", waitUntil: "networkidle") + expect(response).to be_ok + expect(page.locator("animate-it-embed iframe").count).to eq(0) + + page.evaluate(<<~JS) + () => { + const host = document.querySelector("animate-it-embed"); + const top = host.getBoundingClientRect().top + window.scrollY; + window.scrollTo(0, top - window.innerHeight + (host.offsetHeight * .4)); + } + JS + page.wait_for_function('document.querySelector("animate-it-embed")?.dataset.playerReady === "true"') + expect(page.locator("animate-it-embed iframe").evaluate("element => element.contentWindow.AnimateItTransport.playing()")) + .to be(false) + + page.locator("animate-it-embed").scroll_into_view_if_needed + page.wait_for_function('document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + page.wait_for_function('getComputedStyle(document.querySelector(".animate-it-embed__poster")).opacity === "0"') + expect(page.locator('button[aria-label="Jump to Intro"]').get_attribute("aria-current")).to eq("step") + expect(page.locator("animate-it-embed iframe").get_attribute("aria-hidden")).to eq("true") + expect(page.locator("animate-it-embed iframe").get_attribute("tabindex")).to eq("-1") + expect(page.locator(".animate-it-embed__poster").evaluate("element => getComputedStyle(element).opacity")).to eq("0") + expect(page.locator(".animate-it-embed__shell").evaluate("element => getComputedStyle(element).opacity")).to eq("1") + expect(page.locator(".animate-it-embed__control").evaluate("element => [element.offsetWidth, element.offsetHeight]")).to eq([44, 44]) + + page.locator(".animate-it-embed__control").click + page.wait_for_function('!document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + page.locator('button[aria-label="Jump to Details"]').click + page.wait_for_function( + 'document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.currentFrame() === 5' + ) + expect(page.locator('button[aria-label="Jump to Details"]').get_attribute("aria-current")).to eq("step") + + page.evaluate(<<~JS) + () => window.postMessage({ + namespace: "animate-it", event: "chapterchange", + detail: { frame: 17, chapter: "finish" } + }, window.location.origin) + JS + expect(page.locator('button[aria-label="Jump to Details"]').get_attribute("aria-current")).to eq("step") + + page.evaluate(<<~JS) + () => document.querySelector("animate-it-embed iframe").contentWindow.postMessage({ + namespace: "animate-it", command: "props", props: { secret: true } + }, window.location.origin) + JS + expect(page.locator("animate-it-embed iframe").evaluate("element => element.contentWindow.AnimateItTransport.currentFrame()")) + .to eq(5) + + page.locator(".animate-it-embed__control").click + page.wait_for_function('document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + + page.evaluate(<<~JS) + () => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => true }); + document.dispatchEvent(new Event("visibilitychange")); + } + JS + page.wait_for_function('!document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + page.evaluate(<<~JS) + () => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); + document.dispatchEvent(new Event("visibilitychange")); + } + JS + page.wait_for_function('document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + + page.evaluate("window.scrollTo(0, 0)") + page.wait_for_function('!document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + page.locator("animate-it-embed").scroll_into_view_if_needed + page.wait_for_function('document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + page.locator(".animate-it-embed__control").click + page.wait_for_function('!document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.playing()') + + page.set_viewport_size(width: 400, height: 700) + page.wait_for_function( + 'document.querySelector("animate-it-embed iframe")?.src.includes("client-runtime-mobile-spec")' + ) + page.wait_for_function('document.querySelector("animate-it-embed")?.dataset.playerReady === "true"') + page.wait_for_function( + 'document.querySelector("animate-it-embed iframe").contentWindow.AnimateItTransport.currentFrame() === 8' + ) + geometry = page.evaluate(<<~JS) + () => { + const viewport = document.querySelector(".animate-it-embed__viewport").getBoundingClientRect(); + const iframe = document.querySelector("animate-it-embed iframe").getBoundingClientRect(); + return { + viewport: { width: viewport.width, height: viewport.height }, + iframe: { left: iframe.left, right: iframe.right, top: iframe.top, bottom: iframe.bottom }, + contained: iframe.left >= viewport.left - 1 && iframe.right <= viewport.right + 1 && + iframe.top >= viewport.top - 1 && iframe.bottom <= viewport.bottom + 1 + }; + } + JS + expect(geometry.fetch("contained")).to be(true) + expect(geometry.dig("viewport", "width")).to be <= 400 + expect(page.locator('.animate-it-chapters--mobile-carousel [data-chapter-position="current"]').count).to eq(1) + end + + with_browser(viewport: { width: 400, height: 700 }) do |context| + page = context.new_page + page.emulate_media(reducedMotion: "reduce") + page.goto("#{server_host}/embed-spec", waitUntil: "networkidle") + page.locator("animate-it-embed").scroll_into_view_if_needed + page.wait_for_timeout(1_000) + expect(page.locator("animate-it-embed iframe").count).to eq(0) + expect(page.locator("animate-it-embed").get_attribute("data-reduced-motion")).to eq("true") + end + end + + it "keeps the poster visible when a frame-zero image fails" do + require "playwright" + + with_browser(viewport: { width: 800, height: 700 }) do |context| + page = context.new_page + response = page.goto("#{server_host}/embed-broken-spec", waitUntil: "networkidle") + expect(response).to be_ok + page.wait_for_function('document.querySelector("animate-it-embed")?.dataset.playerError === "true"') + + presentation = page.evaluate(<<~JS) + () => ({ + posterOpacity: getComputedStyle(document.querySelector(".animate-it-embed__poster")).opacity, + shellOpacity: getComputedStyle(document.querySelector(".animate-it-embed__shell")).opacity, + ready: document.querySelector("animate-it-embed").dataset.playerReady + }) + JS + expect(presentation).to eq("posterOpacity" => "1", "shellOpacity" => "0", "ready" => "false") + end + end + it "plays an allowlisted public composition without a Studio parent" do require "playwright" AnimateIt.load_compositions! @@ -252,12 +387,12 @@ def server_host "http://127.0.0.1:#{@port}" end - def with_browser + def with_browser(viewport: { width: 240, height: 120 }) cli = ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", "npx playwright") Playwright.create(playwright_cli_executable_path: cli) do |playwright| browser = playwright.chromium.launch(headless: true) begin - context = browser.new_context(viewport: { width: 240, height: 120 }) + context = browser.new_context(viewport:) yield context ensure browser.close diff --git a/spec/requests/studio_spec.rb b/spec/requests/studio_spec.rb index d70a309..52ec222 100644 --- a/spec/requests/studio_spec.rb +++ b/spec/requests/studio_spec.rb @@ -45,6 +45,18 @@ expect(response).to have_http_status(:not_found) end + it "renders headless embed builders from ERB and HAML host views" do + get "/embed-headless-erb" + expect(response).to have_http_status(:ok) + expect(response.parsed_body.css(".product-demo-card").size).to eq(3) + expect(response.parsed_body.at_css(".animate-it-chapters--pills")).to be_nil + + get "/embed-headless-haml" + expect(response).to have_http_status(:ok) + expect(response.parsed_body.css(".product-demo-timeline__step").size).to eq(3) + expect(response.parsed_body.at_css(".animate-it-chapters--pills")).to be_nil + end + context "when the engine is mounted in production" do before do allow(Rails.env).to receive(:local?).and_return(false) @@ -59,9 +71,82 @@ get "#{mount}/public/compositions/client-runtime-spec/player" expect(response).to have_http_status(:ok) + expect(response.headers["X-Frame-Options"]).to eq("SAMEORIGIN") + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff") + expect(response.headers["Set-Cookie"]).to be_nil expect(response.parsed_body.at_css('script[data-animate-it-transport="true"]')).to be_present + manifest = JSON.parse(response.parsed_body.at_css("script[data-animate-it-manifest]").text) + expect(manifest.fetch("chapters").map { |chapter| chapter.fetch("name") }).to eq(%w[intro details finish]) expect(response.parsed_body.at_css("[data-animate-it-play]")).to be_present expect(response.body).to include("/public/compositions/client-runtime-spec/audio/0") + + get "#{mount}/public/compositions/client-runtime-spec/player", + params: { props_json: { headline: "private-session-data" }.to_json } + expect(response).to have_http_status(:ok) + expect(response.body).not_to include("private-session-data") + end + + it "keeps every development and render surface unavailable" do + get mount + expect(response).to have_http_status(:not_found) + + get "#{mount}/compositions/client-runtime-spec" + expect(response).to have_http_status(:not_found) + + get "#{mount}/compositions/client-runtime-spec/frame/0" + expect(response).to have_http_status(:not_found) + + get "#{mount}/compositions/client-runtime-spec/filmstrip" + expect(response).to have_http_status(:not_found) + + get "#{mount}/compositions/client-runtime-spec/player" + expect(response).to have_http_status(:not_found) + + patch "#{mount}/compositions/client-runtime-spec/props", params: { props_json: "{}" } + expect(response).to have_http_status(:not_found) + + post "#{mount}/compositions/client-runtime-spec/renders" + expect(response).to have_http_status(:not_found) + + get "#{mount}/renders/not-a-render" + expect(response).to have_http_status(:not_found) + end + + it "hides canvas navigation only when the decorative embed provides host controls" do + get "#{mount}/public/compositions/client-runtime-spec/player", + params: { embedded: "1", host_navigation: "1" } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.at_css("html")["data-animate-it-embedded"]).to eq("true") + expect(response.parsed_body.at_css("html")["data-animate-it-host-navigation"]).to eq("true") + expect(response.body).to include('html[data-animate-it-embedded="true"] .animate-it-public-play') + expect(response.body).to include('html[data-animate-it-host-navigation="true"]') + + get "#{mount}/public/compositions/client-runtime-spec/player", params: { embedded: "1" } + expect(response.parsed_body.at_css("html")["data-animate-it-host-navigation"]).to eq("false") + end + + it "lets the host embed own autoplay while preserving direct-player options" do + get "#{mount}/public/compositions/broken-image-spec/player" + expect(response.parsed_body.at_css("script[data-animate-it-tracks]")["data-animate-it-autoplay"]).to eq("true") + + get "#{mount}/public/compositions/broken-image-spec/player", params: { embedded: "1" } + expect(response.parsed_body.at_css("script[data-animate-it-tracks]")["data-animate-it-autoplay"]).to eq("false") + end + + it "serves versioned, immutable embed assets without exposing development tools" do + get "#{mount}/assets/#{AnimateIt::VERSION}/embed.js" + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("application/javascript") + expect(response.headers["Cache-Control"]).to include("public", "immutable") + + get "#{mount}/assets/#{AnimateIt::VERSION}/embed.css" + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("text/css") + expect(response.headers["Cache-Control"]).to include("public", "immutable") + + get "#{mount}/assets/wrong/embed.js" + expect(response).to have_http_status(:not_found) end it "serves byte ranges only for an allowlisted composition's declared audio" do From b5071362ce1e975f01926f91697a28f13f587ac3 Mon Sep 17 00:00:00 2001 From: Victor Fernandez Date: Thu, 13 Aug 2026 16:31:54 -0600 Subject: [PATCH 2/4] Prepare Animate It 0.5.0 release --- .github/workflows/ci.yml | 25 +++++++ CHANGELOG.md | 28 +++++++- Gemfile | 2 +- README.md | 77 ++++++++++++++++++++-- animate_it.gemspec | 2 +- docs/images/interactive-embed-desktop.png | Bin 0 -> 36878 bytes docs/images/interactive-embed-mobile.png | Bin 0 -> 28025 bytes gemfiles/rails_7.2.gemfile | 2 +- gemfiles/rails_8.1.gemfile | 2 +- lib/animate_it/version.rb | 2 +- package.json | 8 +-- skills/animate-it-generation/SKILL.md | 46 ++++++++++++- 12 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 docs/images/interactive-embed-desktop.png create mode 100644 docs/images/interactive-embed-mobile.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41a36d4..89641eb 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,11 @@ 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 + # 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/CHANGELOG.md b/CHANGELOG.md index 05ac962..02716e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ 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`. + +### 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 +121,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/docs/images/interactive-embed-desktop.png b/docs/images/interactive-embed-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..56d3eee92347f4d4f5bc68d9777b92dddf80a3f3 GIT binary patch literal 36878 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 g94R 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 literal 0 HcmV?d00001 diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 9468d3a..956cb65 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -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..fff9b05 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -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/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 `