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
+
+
+ <%= 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 @@
-
+
@@ -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
zcmd42WmH>z@GV?QTUsbkLyA_QIJ9W-wiFFsT!KUK;-1h_ptwWP;10n;fS{$g1eajN
z2~O|;$<6bw_rLDCU+(Ao9mZ`~UZ_TO&@H=XY<;#sE!CGE*RqCnElOS~xq`gHM2HxM?p+in;#D#8tZ
z_mmR0?OJ!Z2|o#ss|Z_Vxpu;>-##ZIBOI{&f9Zf-4Kc}hI6yt@b0T;#)$hPENpW
zToB6L+}vN;JyDZO@^XHsP32OYm-lR8I?HjX5~eAhm^foSte?$Jk?J&&4V+ukQSyjQ
z_Vn)B_CUFBQaJ8Rsc7BOvbn=O@d3}Phxxb4_jR0d!S9NdCZl3P3ZZ$X&=enbauNx7
zbB(h^_$8upva|o;#iGydvS0Kun2m4_$2H(o~Wi~1<{%)dW>9ahaZ`=>fB2HttiX+pjJTQAr3&Y+uYcV3A*PZtqAnY>lemo`>F*!b82r+wmd|IjyvfPSF`M3G
zqLZyDYHJ%BLDqAetd`-7T
zVdFvP^Vp3m$gp99proYChWqx&SvASN-FQ>5^3w6YFI>|OBg{f>!#~XOc$31Gx&!E*
zKY#hu)s$8Q2S4amQJzJpX{3kxo^Fj5*}%z$8J|CUmM^&tgG;6HdxR+8xqB}<;9&jT
zL8Laqm}?x2my(iFeUn_NcX~N3T{%}J>qqS3Qq4FAYsE^_4EPDO+KATS`Hy0(xOC7e
z8kyoS=J~esE}9HSn8Es%`1TL`a8k01LG~Q@m5?Z6-KW`cBT|}=gJK6t&);{Z&a>tpkQlcx};Z5
zJ;ZpH!*I~50dj1+S2Wh=_YCeOHf2Hq>P?Kgi{IN20mdTNT|6T8X
zxlL6zZMXAzYE0B7Q>@Cw89Fe)hP9rDMx<1Z00aJxe1tE0Yk&AKt80F_1HPVx6MVA9
zXTZ}@mEgEF$xf~N(m6RVQ4Fh|7dw=Y>%-%}$kywRW$;Y?VyMJsu|SBu+PaO**oJ~C
z_PWePTJ3kaq|qWi8`N@-a<}ZJMX;OvU+%G7%T0%k{j{^)VtF6mQb2hzTV3l-$0nC<
z#W-&HJ5Kd0KnMFWj0BD15F=r4q^$Gn`dTvJYz`_t*8=F--8F*JD}Ds!tVHFPaY5V08>Fl*Z3Q-ZV=Qj~
zd}2M0mq}&Q0r>IHt7Ty9BxU2J59Oj;I7Z{<-19jzE7<5#zuA4Ao=@^+oYv6{
z1#9FsnLT~Qkaa=>8Z4tZap!F53c~Lc?g~Ald6)G$*ZG#XSCPcY8lZbKv1ic*Y2oBp
z0!V?zLz3*LBz^J5-@g-)s@INu{$(If@u}Or;A*SZdEHCPGJn%plP)eiF4y|CMN_;*
z#e9JZpn9`FWbxR)+|V~xN7karZX&xC_i!axcWII?KRi7AAYkkH>dT>qnZ!1n4|rEs
zr>yJ!7aFR^Io<-86EkdG;>ZcQ5zjmIX04ceS&=T#scH4&jUM~K$osL53VgtfKu1Gd
zQ*c&g$KxZ!?drX1J`un?ga@R
zt~V9ujH<}{4<4K~efah*c2hYleR|0g^3IS}dUU`->i5mTD|jg3r2S9`$k5op@aDRV
zISQ?$WQ46Nzb5zv#=l{D60>&f+$&9jr;iu>D0MM&j4fmbckNzH22|E+vexQ`Cp3kL{lvC*Zp0VbUNm^d=J8uqF{uCbBV{mG~
z{Y_`7@UXB;i*4lqoDFj;?}1r2MB3PUkv(7|mz8V%9HVVd%?&IZikp8uDda=B1jbeB
zq(0J=xKI8dE)~*@FU8Fe(w6LEMKf0yy@mbob#NtHifbIOE3R88q6;&SFMUez97wDe
zJG87VoZ$^P^(0jDv8aEm^u}c>7$>-~UUZU*`9wy|b8K|cZO_MizJ65`?(2CVVTLim
zmQgaz$Y+^NnXDF|)lDuY;ZhS#b}GLGwIQ=P#_7=RRt}!3ntO?P$453x=yCXx|InY*
zgbigz@Hk9&OJH-@ki}VV$_>h7e46U|1%y3CFJJzA3Wk&xkq
z^&D%|Y*_WdfdO)KHm9ld!s2AHnyBk_xbz8|k>J(&gH@@U^(%s}e%mnWN|$GDu*a@3
zw_bmZJQ8J7&Z48H+qRK0Xf=Z4fj=yub
zyLyg3hC6vdL79Z;4PWrqPM_kjr0T<9%OZ-HE+K<)NpD6+XoF!ZQAfxlj9W}EA}S^Z
zv774_1<=tZBz$0L9A=FW^Ipl8^!EOuuWV7Sr*_0O2LqBy^%|RYpGqE89TV0{!2Mg>
zXhD&u??#S>&BGKSJ^%&N$W>Dr@93uU%+_^p&dUze8y0D#Cxbuj0Kcw?mBJh0C
zA7t9Lc)G*dM?YTLmL?0(QD{+G1b+H74_yNDql=Jhn}zm#|1Fo4Ua?1-h<$F%9@DdD
zYuud0k|R_wVnpu6E?EsoM&G+>FH63qzO-B)-d8c_ige1164{whjFlOvNwQBK2DaE9
zPen$SpA2%thXu`HFKyh>GeAi4s{|Xi{eF6%Zgrjy3NP)Brx^Px3n8@o2}C4;sbn>PDxXAO{&L|oLR`a
zySlf$PPeA?oimK#W?yQpqhL78aJoD6Q_vzl(cMI%ZtHo@*&2^fsRe|tNXe${_!IN|
z_#&-L|5q(fIKp^%g$>#OrSCDTry2`QiLj1S^sF&t$~AJ0isK`rg!k9WQdj~$qt$lj
zth{`8aL}RU33`%)_zFFafhXIHq`6r6eIr0=w$nPhA+Z4LiCaIXPq&=frQylG{uxcm
zTf6Is-nM6#$2`XSkr>Py=Y~BZ*@{&-g1V9G_CJdPN=OU@?N@fR*Zw(j8_<5~BETjmGB(<&n4?L0;c^?J
za*!rtfbT$kCdqhzck@^Ct?w*!lYHmxoU@JMT{~P%?=C-M-8=6k1z5y|IF2=(xsO|4
zm&NFqD3a5$Ja-m$oNxE0{^99bri(2szF1`PJeakAsOJGdLPlB+Cx}HBjMnxNsi9Qa)-O$oa5Ln2O;*zXRoZ?1*fv)BFr=Ow(DyUjX(ZLwk#V
z9Z31fm((?q^$#QbH&1O&-k`exI&^B{98&K|AV4ym;9$E-+}j=WibxjrEKcN
zRUe7>%cp8^?0}Stu`4n*I0t>jKSG^1WiJg#hSIrWgJqD-dLU5dNfE?G71=vcRG{)1
zpc~)RdU3`EXPtxd&?ApaXnMZN^M^gQSY%AE@q%3E+gN^GF#2)3f2>nU2FYV`k8&$d
z4U9XDLH%X#yiZ{)hG_CvHrrjAC404*1~X!5<7|n7LZ*?Tyw`ar$aOa3F!&xy)ndo?1w`=bV&Q}AU5#I6ag)~ln|F@7=NBbA#G^iB^Y91^}(K_EA;F}1=
z-=}z2@tEPgk9x=L`92Y}9Qn4ramaf1`|TfP2(P`9znxz4vT{bQ%Udqe_`>2AUp3|c
zAAD;IDJkYuGeHwIvH>6#t(s7rpfg(g<$a_LuXk8=SA9*S
zdXQ8u>Nin>B5DP;)Kq>Nw;sGio_d`g%pKR$LougJ`BrW-u{iu-noWi+L&tKtfZC1A
zH4BfF)b(G$QdIg#xnf>0KxfMtGe(9yD)(uK3+8LY3d2(gdATqY!u%Av#)f$i(E0Lh|fQ75h`hf99w(g8mI!y}qH
zbP=KET}^PUC^$9piL{BUzgpl$%Z06yoze#>-ikM)$aX!1QjwYl+9eKP%=Zh_@(pTNMe
z2*3Ny=+EGg)~jx}A#%y4NOY+QgbBX1)&JDv$X+YRJK!n*C^Z1E7D>+ca*ee*nnF6n%jJG_&M#Nm9mG^ud#^o%Q0%V<^
zCnh;4>puB*(7bNo*yIe|2fF1?cNFh8T+$&^yNLumFhwFj0ePv0cg8J8V+mM!dj9O)
zT?HSAChgooMM{!?Zp@NTS3~|gV`K1SLA+N|X{;X9`7OpFJ_+W&?vc53GKz}C5f;H0
z0Edz*uAs%CDURWo4J*Hq@uZ&{DUgcntHg=BQBkGwi;GuVWGxj&QW4ksRN37xO>^$?
zgXo3p&uzKa+qg(*owmt^M2!4q3ut_1(SY@h5_!B=56|=oyY$5mEg=vV+evied+DPZ
z#Q?|xoq)SrzvvpdE)uvt&9e|dgiQ6jQRbG@+KRi-0#hnXkDjgG*55P
zS5VrZXyrBnbjQN(NAZ|b$BYFF8YsSU@$(4#f=BEWlHmLzj;g0k0!prVB4zUi_(ac3
zS#vL^HbeXSZVg6{QKw+8DDeA*H4!
z?FlJoxG8MjLpURV{T|(H=S?qdp%=@Bd<~A_;Z5~m+(sw%bY9Kq@H#!3$pdu`Y5R{Q
za8;wH@mu!E16^Pw;gvr>44L*Fxg2rdBIGrau`yB%narmj^uooPo-+w)Q^Up_Ic6_i
z1K)Y(Rpv(ymd`g!h_2{tRO`=*`kfaFJLpOGpU>`U4l0PdH{>_4aB}_uzspn7JZJ75
z>{l#Jr=@?o2ks-VhLhovt_+j+h>(N*$?ye2*u@9nZ{B}EtCy090!9Q&|Bemp*r=0s})%6a0;3<>6p_
z$8N^_n<#pn-(R@2G|d5$-(I|un165jd(;BiTnf+S%@9=tR%d5_o8FhK@n?*Qd*+T4
zVYy5vKpGQH7y**Vk5sai37)ohln?Ia=Qjc6vv1iwyiI+}&HEmAhF%sCfaqKE$mjdF
zZkh5E_TCEY`}*pRX*A)1lV&o4B!Ykb)bbW)jSd|B>AxZ{}cRA
zU;cN_eLVu(|K_zR5uptb8F=@9U+n)p5NyZICc
zyTMyOyH_Y-2&=ccs+2B5p29d)%L$y%r9c6rl5TWL<8ewasYdj3X-Kb=SGisb4hr62
z8W;I?Ld~t;_;uDUwRdnZ8R`1|^%o+$+z)q)Lxdd{j%KG<$s&wKIZIUks7wQ&Fw^HT
zl$fyLC9|Dls8#5-T`#rmg>eElAD4i}w?F-e!OfP!6v5ZETvjGmYso5fle((>wrKRz
zLgk3)Xa(h8U!o(95^(zqd2VGJDv1`INH(nR{O1RHW;rh(o0;|OlW(yo^@mP6huk3%
zani#77%iGEOaHg25r4bqhQG@WcDBEW{(yRU$$fNGEq}~M^WK&~3f}B4c>N1fYfA|@
zL3ff&Dn`yVTW$U+;4<+!PN2!UfK1wHeDgQ9iX{DiCz?9xEtASr1`{PIo37@x%1o
zJ?qThJD0B)U|$ehTMA(+Wz$Zm8pqA_1Mty5Ns;cZ&sV;Q*JLfrr4hu$7hd6MF@eF`
zHj_;%n5$%aKHG(wROOScv6r0u{;2ZHbhwk6)50gw{WS#((a|0<>4W88OIc8orXgrb
zBD-||JGXLO=cKHq6A_E4r%Hc0I5=!a%fmKn8HY&9534&vPwXdND8Au+e>I^99*nKo
z@1LYsdp9F*bpvCIIeoIN4a#|DLsj0t_L))UU{6qBSrGt;p-L{(}WZG#u
zhKzKs-bre^?hD-nwCv(^xMzug>$~OThm0Gz-@QERKscWqv+RgCWxlt<;(|i+9gAfU
z!ZguRJS;5U3*8kBn
zLqm%lGf{wPsIJ#pTZHQ|rr?$9de>&Jb7NUm@kNmdUJvdP(HzN<$8Lv)AeB|N^RL_1pXWb$X97xIYLJ1mJdm>}Kc
z$WzV*j$+W5*<%bQUC5RlI#v>3AK<9)3G25Z!9m&6_17rivT+QD>qLGT6A)P6xXe@A
znW;?Dj3c^f8o(}*{6_f%F+9K`<{XZ|D;RN1m}8y>2b
zE(#vLX07>T=}5W1WPcIcN~)eY+Q&GlcKl|zRRsb*k64`4rD&H#c$OzT;v1(IalXP_
znNZqTQtVC(2nwztZQXDee)aV;Yi-szQOA0Qu=UE5NN#=QKcRC8&U2}l<;~UHyjQ#u
zo9-o~&A4Hkn#j7K-pA*r23S9fwxQnMF`DG${=mRnKEk4;{Rzla_}{(RwdYdU6%yMH
zLTAGT>Xb!Iu^;VSDA{q4fYK
zR{1Sw(6!124K?+~+8XF=??NV2;NIEJOJ?~`?P^bP6ItLPERP9D`X!H~#fK%P)9tC$
zHt()b$ju9AZp%q2j2@pV)CMT>B!sk-UG^S@Jq-x>YG5qM9^rw0!BcxTShgnlj9G&F
zxYo^e9cGy;J1&QziSvBrc)7bfPo7?HrxKc^f9$ko90fV?`l>JzOVsjY#e#-czoZ4M
zsD*J&z`1)~Z_kz4Ygw+L$PG7mj4CC!dmey$*^=bAE+;UvZI>?b(`?gINH1wT?|6W(
zXBZ(h)y`h%vkmA}(&OhQ=C5z-Cp;lr#IGUxuhHx9{BCD(nigF3Qyb8jH3q9Mopkvb
zBhk|26(1({z@??hBk@OWeKH7d-%ZGw3*vC+#d4Rhp);J#5|2SssYZ}*&Egre!qsj%
z1g1MW)jhomiin9q6T=~{-`_2L&jMF^)>PL}uVTwL<5NzWAOG*#xNc-pYb
z(@}y|G=B4Th2U7JYyyGox_Q2`YR+_h
z5cQpih@S@npg1d{*nvEvx$3-FDwsbV0Xrv}mKLMsry?!ySJ=Jk+3-7P)&Z13{7)19
zru_Mn`|qD=*b7Gk;kK=eaVrpnDbpH{VBwQqx(Gpdq2GeWCJMNwY*pY~)8$^Rb3o&D
z9_@w=Nk!MB@a&(B?H@L;z#Hr5la%6`2oe5QMvGu|_3ZNG&@A5obidGRRW#d?>g}I*
z(!`yvW3H)&pWFIf4poCxU+#{*yG$EEP8Tj+tr21#g@I#Dac643Zyox#D$&bIAY>O8O7@~CcJt2yiJ#w3~a8ML8-4E>tm87
z8sXBxHLj=F_Vy1PtenF?+z;9aZHnJz|DOzCGKM7t;ne85(Cv6k&71TmkYQ|1o_{1t
z*F;N8i+ibyUZ@=IwFGiBvmK0QueWiwQ>_#r7cAEGnu}gQ8Jn4z5jZbE!N(oCHgT(i
zHnQY?4IO|kzkL6_&1kxplN=ldYa5%J8=^acsrpu@MUQ2!?4Q;&Kcg*F&SX{lt;Gg3
zF})g=96nbU(Ew{y!=BiT0_w)blJIPCYHV$7IF}V$8IpS2No8@<9YTp$!1d1kn~81B
zY-SA~x1bhXI)V5oDB#*OC%)<>0vgQM1+orh8pJ=^OE6UY9h=%~UQWB2hQW$8!%vzBv1ouq?PSDGCP6@yM+1JK>TeZ)e
zP%C{+$uQAYlw6s?7ocP!X{voul#*5^TrFS)fu4SuR9hjgkphhxpM((pmpdw;
zwLaEK#Yf0~*A3j@E`teCnbX1@+l-s*xub0~JW4)G_%l(d=xyp!R$FcKNPWSSq32#m
z#nU%FE7U)8?ta^Dw6V@7ogM3c=DVHT?c{RKckphkD{tK77-YzKG
zpWGL7*y1hpU`((uGtW5wnwbTpHp+(q(%Ly0IFww|>w+1FdyU{`1JbqB{X|0i{6?t2
z$N_@J#UMJGkX!AGh4t+mOCGokYeZi|ebflJBCpQP_$^k@HZ}YqCEn#v;^dAn7s%E|
zw3lr%2~(wCOM4wL8;P*{V_?h)xs`zavZ
z9iJGqO7LUYriNe5C)Ie}6gq1D9DjxW%Q4PsOin+cyPf%NRHJOlDOOHR8u?m(yU?CD
zPHc9gLcAcB+e4j04+vflCRKO*51tXAfr{_xo?1l209_=8EJO0!&Scr^C|`_Fz+AMk
z*|OP~6st5w#kViqqp{IPg-$#Mm=50?*%*ka!*Cl{tAd}{roVUG3XI+}fEv}B?fct!
zoif^$_0QT+I8Wx5NF3|LKv#YJVmp`hn&(B|!{!3-4y6gIXGF!6U?VDlZH*X{9WMX)
zQ+hZd*mJ&UM>Sd14)95o`K3?<%gX-8sS(rg7#B$Q+S12s*Ey_XpQM*L
zenp4FFo=>Rvrp@yc;g0#gI40B>UU2{aeh16!|*z`Nym4#o!e6-3j~O~E^4LyHK*c_
z(1Bu_Z*7fRJOO4*$FuP485!1V&sTCnRPp&~!4bNgN&jqWkB-?(?%un1kJm)I{tXQe
z!5{kZ=$mrLYux9o{0bcnB-5l*KV9;k5s>R|KIf=to@&yPw2FJCLbZi%czFgvh{JgV
z*aoN9JDlSW?*erCySay^##95YqIBQu{$bK}t8nn^2Je3G_(%XT$k^{K;Ood<;qDI(1_FdNzPT2BTcI9GW`dnkF{*@@qcnxrWY@@
z7RT<|K(_J6Sf6-C#F=vD(Lob=y5IR$T51x^Zm;{j=x$2AjiTuCDqpw9c5Jhwe1ANZ
zeVT}^7GK}2FFO^VE!O{h)!EnUAj6YApnE#7
zv7tv_;tfR-#`2~Vhbe8BQu^!sJ8fs1k2V|;Yb~c}w9%#JGym;%FN@qfi0_u?-C~{l
zdc~l+%K5RC!-0cJE|1>x1^UWn^_^??{`SSo%e$IRagPd%19U2zPmK2Lu^ze^_@S7m
z1w;U@y*f1~pRTUFg)F1^zm6hCXP9&gY3y~V`~jjuErwUK5GA@Vaw$eI&X1I-e|a~p
zn0U8VW@(@0_$1fel8unXpFqf(4GN(?Xay;om56H;-_}F(BVKV51
z3&Z0bVJF9_{G82Wh2z-#&b}`;VoUxJg_+`Y`n8~l9dgd~o?Pyhm-r<40NjwbA`9mA
zW0k7}j_|GlQr@e*HG*rxRM%4O{3zcw+I;lnDL%1nfQklkvlo+47kqO)$-Pb>2u{Bw
zsabkEFHXfbLlDlbm>rj=MMG)OV!UO(OQSQA7yAe6<{Np8ZmpC!7$>yjZ%;wn@H|92
z8F8KY>l)%jvMX`^H1X50#G3+crADtZg}m#OCAC;*vi^sgLzS7ROs`~tY(wJPmKG7e
z4ySdX%bQUD(}^a!0vVJ?C$KW
zD5hX(#qp=DPd%Z*U0key5BN^NHDik`U9>lfYu+3rt0enP9}g0MFOh5<0hAP%xqP9@
zj-QEN_K1qsEqM$iX~N=9AJNg7?=F-lh<()4`z?k2YNg=H$-x^I9^1}t|Chkm
z>NecF9PivL^?5Oe!5sGSGnZO__}#m~@@H=X9ECx>SY&_y(KX_$|IK8xWDJt$_^VXh
z)m8N@=9nyM;VdTecF8z3l`hV4z;B>$*y9RrT{~An&DDv>L*@J+kcv{m-AF6*fW0Xz
zwY?o97bpw(LLZo=BF=}D4wG_e+QT%|wYV$7`>S8FLK=Smj?GTE;XdEZkns2m^}c({
zjyml=fgn|uLlflE{}(y*!|_{M1XG(lmoqMR?8jlLao7(YAKGuL0t<5beLi?3s+6A7
zM%Pe;%6uHs1kHHKH{z2WiGT9^TazXHNzAvBlU$o(50C1o6I2m>D8v(8BNW|Pc{6R^
z{5e||X|P@c|1@@6Rq77+KbDnmNrl|Qxrxb8ZiC!0kSN|})QL8_XrGFk0yY^pw=|NA
z^u(z-p%^Qlt)qC2%m4`8p`~HRpf<^GH7`tw*zWQeh%ywMaz^OnaFS^m6uS;hJo$b0
z!ST;b?&lygkIipgZ}yU9Z#oSp7_2xPnLLXV_&amqv`ywB@5tVBJgN$OT3-H9U9wm#
zBTkV_#jY~G{*{p7ETd-xU2EN4B`(7PO1>povdQEe0Yp{?tFnIGpTc8jXPBN<@}wrp
ze7E_F@rMO(ZdAzUyqDi{9}_NDQqEwdF~Ogdw+qjmp}Hw_`^2Aob}Mu_b*si&Pp`
z`3%y}e9)gmZd@$KmFhXH^*Y5(p|j?9fs`hf72{51S8KJW*wj^b+V{Zqr{WavCBjJy
zC2KWNE(@AE<(d<(3z91x4C=;K5J2g*g)3@^=$=JzX*3!%Qj0#))e-JfH*Nk2IewNh5K
z@10R~ez4v3Z;XC`E9;~z%VHYt8L2X%)zqL$@~&JDXLzY(4U6jC;-V*|&jE#ut^)1)
zBbxA6r-gSoD}Gsdc_7751p5+h+2maAb6;=&>MmX+>GeX414sW*O$z&a;F{hOyXp|V
z3bW)u(+Fjf&w9k1rynA^_S76WOrCQtX>2IO==nBj*6}CR?jZ|xYFT8HzK-@+CKe}g
z-T=+u4Pw?MYL*Vnbzhn3C({>B**wU7w8aeVnca1&7>3Q0YV}7N&5F@Vu1TFwwlY>D
zb%v`7oRW29#V@sxdP>@ikbu{v5vx@Md)WE4^E>gj76uU$m#fwUx#4t5ovb}k6@EG#
z??<=q2G>0d`}+9FD8JyRH!HV;*5I4wo`^e)2;&>DM%a+!`SLgN<1H!U=aqB`FO6-H
zl{%#v+GS5djaRg)7pUsPC%Fqm=Iw0LwsANf+$C)?3%)qPbaT$siobCt7>lD
zE30BnUBfJAp|Ftal)y1{twieEy-MI=QIC7LabLZT;wZl5nMJqe9359n1yc&)!BZ^Y
z&gF4-&h40{fe|)K@Uv&MkDk%?0`uM|^6(}?z-|;fZsx>4nnHTqBYvT&ndU?##Qx3}
z$+|GT&3}|l-TX-=<0^3_K7Qp%`a+e6=nvyC`xgoT+w+n!ckNv5iZytbMsDv<2A#bw
zyFR4>!{w>Kzaz1KvtxhVQW}Ou-1&N$C!aV4Gz^trM=6bkldiKNoyfjTNmV?l{gJ^F
zIQ7AXD_62zh`MBD5J(Z4T+L8fs#8EmIxF%g<-aem7n^{A+4|!BDI=Q~oYY;;
z-D0>axrtlywa(=ob{6fJYt=%vpTSjE#Z(l@&lJbf;;X?LPT1%_63~H%zarbJoob~X
zyEq+3gab&!jr1GK0X028>2D&XTRg4YU=PLUNmz}k0Nn)6<`qwnr>LMXc}zJCL+LH4
z-ugP|Td*#$((>emqORQ-X$|{FoiYIn{qkYnLuwmh^FWmD;)nbF1?Fjw8N5K
z&}38Kdj0AMnB?A_&p$QTilg#t8dyXu^qsoL<|92WXIa-n(TR3bJRa$kVM^cpw46X@
zVMEr}xf?dp=DWTHlx_vWWTjo4JjNxC!A~l}8P2u>vnk?6$L{6bqT;BYo8GXrnhR&O
z5G~V>uY6|HO`2FJ1B))O=HApPiOFAw{aW{1`bthoo`dA)lo9^je0x>|#ntFsK5M7j
zj)9THL9H=2${#Cqx2pJtRUp5!uzqr-E+E&hYeOFCna>j}Gg8@1rj(R8Ynew&b*x?+
zi#|&?pKbzDaSiY5$a>lwl)XaKy%LZAA!86K?;;_y-lysi;53!9^6G(J$8g9e`>M&=
zcU%fC)YTbmv|?rY;r0%iu$+;VqleuOZoFc5>4So1=rnw4X4h!?TSw)(OJ#`$rkPj-
zS>Ch!YVBl~?>A^;SiBlvZV?afr+YLPBG)>VpLg_ECQPq}?Wm2J1H6%KZ^TuiH9O&}
zUF~KtnwvCHN%(*SNy&8sOXuPs2*e9My$XxFhQt|;!Qp?oZMx%^wgV5WQs&7V3g@2YL;c-i-Ol=NG^@S1f26_rxAYd80%bHw6Z
zpgp@gMWJ@B61<42c+_{!YmJHJrU;td75Ms;el4y
z;X3uq)q1&|GP6P1n5i`z$p*hY;nQ*%QJt2t{8e_CZnA@_zRz7H^uMlc)_+QHE{|q;
zk4Fl+m!%UiR16qh)($C4gBMXL}vbpQ0PMJg#J`5WULC9x1$wXX~3
zp0EIQQjPOH1A&(TklcL^OE*{Vr5U5=02gxJQe(Z59H7kzRbzfmmB(j6G-+WhxuS59
zxksYGX+K~%!oV)YR1?Zwxs{^hN;{S$m9s~$sQWh9L80|LilU&fjOWVzW+1hmepy}@{he`eLJW?j?3fIXowS^
zPMN+2(f6Z|?w;RmE#2ayl(-U6O3=qZ-zQ~dU$Q;BTtZh&mws9F@3d-^Fxq*yyLuxw
zg6@-Vc!#{67I<~GlPsB6KAfNinL`}a28kzO=(QWkp>^~+`j<+QDe5hQwQdcg8)lD5
zO-rrK9uLPk7^hHrsmxGKzjfHYOS*^svwj|xA$&g}_+FBuRx3rnkY+`(X(LrT>_f#Z
z`5YO&39ky@Zj$$24A4R+{fzXN^hr2b6~pcssjpsJliXcEx{9@`6xwGDGMA**5#*%N
zln-^(^tJ2MwGE`y8Kevz0M4Tlp{6zM?YCkAEGpQ|C)YBTxj0Bjs!6^-FbMt}X+K#8
ziaox4OYLCxg`wUz)7e0~g7ggg{Cm@lKZj9C3zd|!|Gxco%lybdUt=puPm(DeUO!pF
zX884W2(7T{S=9TpW&+-7MNRe@14h1of5=7ZCP+%*SK>mFI%Du#eE%>jyZu&xz_>4b
zW^Wis7$&In;L{N|O^V*!Pkf|s*)QmcF#cF_k)2)Bx;OUbtIK571ZOGglOvoSOV(6L
zTbQlI>h@tTj7+{$zk_pOuuc?~BdxP%_eyJm?tM{D`{$O|D|JNG-T6*^9y3uD$nEC`
z+ljLDPXQGSde8O@e!BjQpm@0QBgoo^AS+fAKbIy@4)J10{v^_8b4a|^gEbM1>L
zUOwE~onBDnfiARi59sgtGd_DZA=RkewkXLf=A4KRYSPkgeSTi1oZ)r$oG?%{d~c@CDxJGo!~-1a=~#Gu
zii|eqP)Qezw=JLs+^10G=;f0{d>S2Qs4M%vWajd9)zr47+HS8^V~)9LsQEA>&gb%O
zji}-v2LDa}1NFclfue#|PT#GmbH
zc)$2q%4YL-Z=ivDce09OTSQDmR7BJ)flpmeJH^ym-*gu-wf1ZyB6T&*uU6(DmgZa8
zARO`3j2Cbg;S6@&p(a)8yJzjSrkLy+HEil~mot>QqOO_=FrW|fU($oC{*dDFYYrjb
zHuhW=9VJ+Y*`up#)5_ACby7u3J?@sxu|~CvDtWX{8fuchZ@Kx-9ic;hanJoMVYf<#
z!!$vs2KUR-_V#w(Ow{=nibGPs{`xNmieLIhtWkelu85qTdx-b>^V9`|yHT6Vz;fi^
z4zx*7kj3(>Ie4!AAhG|m`6r`k)x9}ZxQ<=H+DNf%BbpvlI|;P`p3zl;_Cti}LdD+X
zYYxo&FHjXxFtR+A#$m2PAprtjxRvK_8qT~bm&Oe`6
zRiAI}qFTJRrEAU2bNR%)5(N({=Bi2@({%`12!fI_p6>ynC+)OQ+Q=>Jvn?G;Srqf$
z%9JolOUWQ{f-DD*QxtL88PUIUSNC^_#Qo&bfg09X%4)c{3~Z>VF-y%DT;cYvP$E;N
zu-pVhwKLh*&5vGa?3JR`e)Es{2l1JA&
z1YwSrU8reUE&DnC%?GnOV;6KEFV%r5GEVZedELUW?C;=PW&wvbf;-
z!E@Bq@2vfKF6w90ir+ow`4Y~Ul@rui3#JH@4Xsx$KR{E#4*!Y9dimI6Lji_o@#|%Y
zs2;ba;t6-yzQ35Si?2uDU7MbsZrocOoG_Ril@5=h6+7&<=LMgy@~u|__!t;%E|{pF
zG8UYLgbnuhC*KYlY&@LT_^(Fzz6`n0C`&iM8I+
z*=?qVYDRcCOv`DG5{PFjY%R2>@1ThJaw!|v!b&NLo3|h1zc`*|j>d1S3JkYt?qj!x
zXkZ@*LYOp()_g@EB^+2I39SO|+Z
zj}}J(&2#rCu+_{9517x=Z-~j^IC$Ohk@V*XU#ZqtdR`@*LcUh~rj50{h*61;{Tt8&M^L)q
z2oDP*PD;$PBtk}i+>DgiJ8vrlaG6=_oDoQJOI4ulk`FZA5FUH*Fh=S)ybh&sd|o^r
z3Xu9@M}n+Orf~NAa26Hr+XNBTP}ke?_)d^VD!Rr)eqJ=lgpSwN)JC0m_w**qja`%#wTXSgy8C?kV5z9jVdg@XX-OZ~9Ln
zgiV9Q5WQxUbrWM*T{W5InyVU!HJ#vs(7JAN`mbfY;^IXz2uoYN2VBfmYmh^2k);IB
ztty7<$WvlzpwHPTv=#4z9RwFhYM`jsHye8npsYpiR7p#RCrg!I9+2BzwraGp1Kql0
zQFYMQEx4A^ov&scA3vs;xDOL18uM$CJl`9Gk}Hhd7QnooehTV5Ou?g-K;$2bCA|bc
ztI1sH`n;2PR@esV^ajCWetj{Q*qjEqQn$Fex(WO*_TDlq%IJF+#sHO4DQQ$d8dSOk
z>F#DI>5`72#6Y?krMsD-dj^$~&Y@wXrMv5FfB$pd_c|Zm`gA_L=Ns2B1J69q-fORQ
z-}kx~ca?>CcJ0nYUg1U2lu!43{%z|?gon+$HScUod?q_vVWJSrQq=cPOf3MJ;CQt@oEFVU5E3Z_E1nx#a#x8>YZ>d&21rCfx~-;YC|#hm`d@EB}*`PoF*sI2Iy-K047r9#5}K
zlD@|NuPBfz!OJf8zdeLNFD#H7=Zvcvvt?Co%$J~*{$Ff7
z5xMOBbIX4Wj``MjV_NkhQiWndmk?T+Ctv9MC-h0b=*FQUni!}3PISxD_8`zQd=V%(
z83BT(w&19|)5a*OOr)%LbGRr?L-;q2mHOdNry8~8-2lpG7ackADKK%DZp`Z;xN-@|
zRLf#8nULZ6LnUlhe~cc~_X6F>#m>lRq|{pU+Bgsh!B{+;ZSyDVS{zc%{dd@JU-cuNXP1OY~@HpsqS<~Q!}ZRH>Sxq$jzoxaFy$fcqt))
zxke_)moS#vo52`JDm>OEwvVY0sIx}4YUmAgn!q|jK>9`gKq}iSxhM^U$i-Jr7(4yv
zq2SWva+-8eajl#26bi!$7xMa&yCF+X~qoX`Ap8f@|z^OlOBeYK9GRW%cj0o
zE24RKr^>*nt2?`XJ%7E{#wCHvoTUbe(ffHlsC~e8lUQ^~zm6`W#}K^hF>0~x0X^)N
z13u@w&tlW*>(C1jr<(V7s+N|Xu<}OjJ(sn#?k;)qrOJS#f;5umUJh={`-!cn=H_O(
zmXB}7Oxp=Ne=8qohV8%Q7%OtTcXwH_D&p;N)s$XTzmjEz&D*-3I7UKfMZ3BUjLtB&
zmG)iD@z+pYH3hy};8tOtox`Z!Fq?P1ShGF?&U&Jh1cmrz#%!R|MQE>fbM%QAIn>=V
zEPXZ>^r(dK_q>P;@9%|8TT5+K7AD(bleP&%Xi6j#aSw_a038je)N1t?5J3dfYHY%(s00pbec;(^KXAr*fN8uYpAE#EV
zUHlIYhgFy_WG01i-n;bX5K}yc|M=)&Q27B}oAt(SzlCw2C67;bhYycB4_k0;XB+c^
zI5M~uuIylt>&fBj|IE$*t=n7-KI_ehIkL+8k4U5bQaO{`uwoeLA96W1&Nu=kWnGO*
zS3L#QI4`HLu}Y#~vYP5h9bsPbm&O%SG{43r8uD=v$rRALi$esbm16zX9at!-tjbD{RXc}2dOUM|(bdvod{%q{%LPDX`
zWU)w;>J+vIX_;bEiBTrHI`x%etVMbPMJ0>*PlxaA(jkgrqdW-DIt>#41GX}VZD(WU
zVwych?lGQKd|9!l)k!j4`JojhvXt?rt1R)(+AntdgDS){s`QPrt}2fl3hCtb{Wlx)
zAYK8P1((Xv%a!%JIWIU?%~VTF1rbRWld%~kSov|0tb^HTg*2)ZJ~QobJ^0TFYO&);
z>;x^d%I09n9l!>_J+;dZ#f$H*o1+S|)*ZquHh-q+IvB^TQvXu$fvY$55K*$&-7=ca
zju4I-cG81ClR>-ZwX<6@d2twb?L@>a;D_nG9GG9oW;YB)eE<+Sfq_xlO*I|zn*rZC
z?u86B9t4zTQUnk6wsZDj{Rs{F4GlH(c5^R>&Vx4(__+M&JruKAY3Uvv0&I+^<^)>oRBn}FcFT-kOob+6_?E5i6~l;h
z`Cm%&)mF}J=NX4_EOUL+cfx#&Hx%ab#vbYVvIMrc#YOFgox_iPa*}=|JIg#PcSt^k
zR@mqr$W-0gWnVa7$v8DNt+BPMYfC^Z{8{O1SJxw&5SZ1I@ej&d6OgGPJq}g@Jp6s)
zcNKDG4vs2c(%PbP=u-wMCu2xtW7=yx^cA_0b7D3e8@PX$EHQ<@QLmzRG6WPV9|-d2
z4yUAF$oQi)-lD@?%GvuLA9eS`T&@IZ2#X9A>Z8mjh)!#Zp01Wu&$d|Sk`;@>f0f;e
z=CR2kh4bh)_%Jfo4vTG)g_xCEwMMmOIel_Xb%R8+y`hm6{%!6d-~h-HQ?Cg($J#!h
z$*#xRRC5nrPJK&Bw64MymL>Mkk-3BJQxf*lwtvpPH3uhHfR2tG?9hAPf$IhX9hfcrw#tby1LSSoXtf*Hn`fMucwQd(~jzKUptPP&+zMdG5&-1E)`Xt
z+Ea&{mo8B*B``#F0>WlUVTW)r5TmDML{0M;qPR`~kS-OCaYQewqNC*d`18@kErD)M
zxxbguZ;|(50pBIhNXbnjLnEpUCZk7%7{e?q0{Yi4OSOHzmmE~qk|lW*hE?OF(qaCH
zKZvBd2PVZCLJZjw`DKJlSp1+2lQ@G-f82m+hkHEJyL
zc2<{wBj4Gre5g5_WW$dPY8-p^_(tAALhoIa$WLjTcSGcYc}uCgl(~i
z7MiJ8#i4*v_vQB!915YyB?|0O=1pzv(gTK--Z|x8JF@1b0#2V#YbZ#}Z3m0APm<3o
zXu-F)2#@LxiWk?vJzC7vOlFZfEg9Y;mjlWpd8
z)t47C3-e>(o||kZS4!$6(SlAUL!<94hiV0z8h=ir)oZGAwX?slu13FgQ4uymvkU6+
zJ&abuUHO6IbrFv1xwK3oe`Gu)nW%lGK8o7vPceWGH=5~>a_b!pQAFHQGz0s`ljov?
zvUKGhdtUYz$#x6z=Wa_WCWV`F!5?qmvB?*_1(~cnJE%79uFy3h(2gry@`^XzLG~y*
z)MjTd1pmq=@_7IB2KuPo>sGU0hby{*+78`~HYx<`uR^feE@{R+%9_%NKn|ZUHsfp1
z)UYiPYUv*bg*O6}y~-~Kd5y|=2L;{BlHsEPx&~FInT>mEttN?{&R%*Xt$bam$Fe_5
z{VkNWH@?`GC!`w`9xaS|J|lbEqT={!(#UlmFQM6wr6Q!TDGuv9Zp@9oeXEtrukTH9
z7PoI(+zDQ|^Ks+uXC8c9$4Ast37hLZ5bj524VA_8PN2
zP*&7-vccda$B~6${|Le&J85jiJso^qd<>dBtirHhfw|?IMq|XZ=KMDU-2~lSY9Hb(
zYTN9xTSH?%^z6|nX+8W_jz<5<;TAuA1ws4^67cvIJeaaQI^r&>33psIi}m?dokKS2
zS|@c1YiZXHNF7mbX5nsAa1V9Pt)fzN$0$ro4Vk#@TFsz!9yqSeUPjp3jzuEf0=EpB
zV%HPGWJAKc?9hmz+_V-ub@sX1hu~UsEs%gBwp09}ak(u^5*=5s(U{{lol-BF7`15N
zvE;+^9wA8KMUdwfVbY&4oSvePckL2HP{sNYi#l$p7Lz^`aPWMrAPIw;GVTZ
ze`rkb|7v|O$v-Ai!)(feI-yrs$kZw8?f}8y@al@*hzK
zjd>MU&kwNnb*_V_)A_h2A*m8D?RbQZoS~Xiy{qM?kwTkP^DCb=sqDW^k(MWf9n*)I
z>1KOhgdzM7YX1)`U@n{Q|0p*He~?@X9{>422Z??65jH<
z79onUxoXby#I+k%r3lo<#0purvf+t2qe4l(BqKti*a_HN;dAUZL;Ie#<1C7dxbmA+
z>RJDIae2F0d3mx{^fPMTCn}M(RWCi;*lr%Rm;3DXm9FevcAD_Xa`{82{h75fAL|Ld
z;d_y3({b9!$EGg7Z#M0aKPMNB_md7{SY_3C1B#-xh`+U(c`S41mEyf7EnP1wjSinH
zFiI;gG)7eayfQ|!c<5VXvvL?E=CK_GMs-WEU7-P8T(StLM0q=7Y(>rOaF}d{a(m9X
zW6=WSqj7Ink;2Wdnw@uH(Z2N{JgP_8JX{zv`ujdH3!fl3Z!0KjxPwym%sjE;ZN#-0
ziI(s(CA3GoeAG5Pt9=md`R
z!k0T>^;xSr@10h{Pp?!wd77e!tKYFuF_-XIC4OP$br9T~3Me}ik&@ntu=HERE#`mQ
ztD&H%L8$9GJRG52rme3qP0%CFrd#Ox_n~;4*AG<1n6UhDB-(h3K#vm9MASY#*XE%q
z(YIPFzS~&v5|Bk%33=q^NhJhB+WD@ZIb>}qHLZPlFquRKRYh7I$l*vXkCEZpA
z3B%9EqDtQ{Z*i16e`+GGbS_2NUHlj%YYo*+AAuwlv>A__Z8-jM8EKmhkruaUJl7Va
zk8f3<`je9&!%?Ff$=Chy`o#>te5H_aF>~il2j_CDnA21GaI$%_p_1Fo|2sn)pvtDYbqZJr>
zEOpzYfUsJ`
zzGu~48L6-9b(P$K3qa_nPZRbxi++hCGWcp(-1-sxw{NTLw!FY9d_-DGfAaS&|5>Ab
z&zNzye2(!nnRByyV4Bcgw|cyv=kf1n*Ra^OvtJ`#yv}30IjR9#SXhOL;F}gYrFpZ=
zldo>h)OaoC(+nrYej~rRpsPxV?#iSjAtzzHG-oJNpc%#v-TS
z;M+%pq@ZwMWaL|3A|oe%L_}1o{DhRW=R_X65cl@j&^_>1j=HdoExEQ
z7pBNXY0Iw0VN!7ohnnzk#-zu3wAVaT{=O?QJ8Y*fDd{lI>8M*elZjO`7bAhBZ9Wld
z$G_<{IGp-6khs2bRYz+-@l-wq(qcA^e26Sgoq+^wr+YdY+Kn`MZZ$2#tw}Oj|1Jf5
z0&Sg<{3qMOg`+k({p7i-J^T$#JJ7BT^M~Ts`Fr`)S|nI@7^!xK4u=m37{j=-2nFo!;`A
zyU3okg06%LH+iazC12)ARXenw!yp%J0uuwjNA8*Uv`-Rjj6cA`Lw9f`GRLQ-0fuj?
zc8ev$|16wGff8dky|e;s6hf4E9Mp6(zCWVNX+fL3P`Q~eV3|5BYQ4aKRda4m7jfm%
zQTq8@+2R>vh!TC+elKH2hn1q
zha>C}UCGU7Mn%wT3f$Q&X3xt|D67MrX>#yN%IN|8Tk3RxY?}x8aaIHm|l{I_p~<%5sP2DAN@e7?*!ot*jH
zTqlyGj4c=De(DEL?9Gp2Nsd>G@uCZ%H$DOUcT1tGaGjS*P>8pI<rZ~A+y1X_38@wv&v5m;79OSkX%|C#IREeY^%BzJbgG{{OE
zB_t$dW-u9Yv8dygbU7wBaL~&uPIWiA+l^HCmrT0BwiSYxQjU3~ApCm=
z2TyYw_x7WGry{WTxg?9SDXN~p^=jtEk)lmr&8u*>qZG~SUOaSfJJTv&XF0nI|IqYb#lE$2rY
zr#n3KCxkS^8<;aTPteIm?WMs)>vR4TvDU4O^bMsSAQxF4m`wL-@9e~<8kQXhMzwXw
zr1ATnUVdit+L{9o*;ghB(e(Ot&un6R+N!k~7W%e-d9l^SF$s%QSM)SX)0EN{6K{St8F0PmZ(2ki3P3DKypYvruixQ8x12D}?T#6jGAzaxYceC*;&x?|cwENNVqtc}OwJ4P^6d
zIPRN+1}1L^9mX;H_3ZfrvB*osfmwP2*m>#(LU3msli&4;;pLVw^6I?x{PsS+wKr8t
zH^(RK%IScCZ5o3*yRl-+MR;0#3ZJcMb4hvm@A22b8C>3B`m9Cvzc~D@&XHX`_bp<^gekFG;_MRliAn
zj9=%7QB>WR$y!m%pV`_Sg5SSS62G`|+n#JaEYmU=Tpv>wc>gEHwYjacU9Ze?Rr_*p
zJoPeIa`E=Qvsp)}v~`*wgV2<=aroE!i6MUzIf@#2L6a1skg?GuFlBqJM}a7%SHxc`
z5ZM}FF6jUY;L$7F?gy_|67T(zOoN8Du6>UdIn!B#a5_W|+#_0fnW$&s+Ol~sT3R+IR}{D_sMww&=Em=N`u}`?WLXM7Tkq8+Zs^+IS8B3vtfc
zxbhf2Of2DJO?-+GK_K^yx~$KCKdGbzegIedFiUOm&fD+b{t#m6HK`x|1}EVa?An2d
z3Q<_&oRQzGggXa7_hSv;jmKn|JmsL&(0$)yMjA)fnpnbCqxd>(-vtNSoqh%4cCFKN
zS!O;nOGQ&2Tcnmk5@Kes$y(N5WqPbH%KAr~Gf77ZS;>jhLtrnS_&PCrg`$xxPz`?R<{JJl0?DM1HacQnD~kk>+>-XpBJ
zOR_!5K_6GsULzhS@^8ee9lvFdk9%(y`rn=yJBtwb45VMBBSF)wxkW(CaPLNIsfin^Xa-C;@~8a
zuH~?&VgTdbIg$=d#T4|W)|vWfzeTIZAsY~}F+#P@N88_IK^y80%;Z8!BC_1`ip<1I
z{zI1iYOU|R{KjSCfr_LQ-Rv3k$W1FApJHteh&7
z@x{>(R>$@>q+*qJm+0#Zkc^5~?D64T0*lFP1L~{=hOpXban~O1fxtgLFh9?t)XyQh
z@B1Kp_Z>@AtrSJB^rW6fs8j4BWSqsh1123n-h3g>0+m~DV%(Sx7N|Sn&<92
z!`oS%o!(_RJq6wuM+B
z2q2qi+Idrz50|l%H*hT%cbavXpgg>{VA>_xWWgJS<_GOsz`@6#4lRc=6L!yY-R0q3@QVcVOikg}?eu59dR
z-p@2_j1$imkvBCBN|<9)a(=`3xLgG=gSSn*fc^=gS5slX`p!Q}=O=1D`CwTV&X-+nYUiAL75WliDu_I`SQj63z
zi?*%1C6bf#{N0Fl6rLatt;jb>fl7m>OCB@nNS1adVcAleqE*~}BBC*&4R5jUMdZeK
z*&^chs%KY1+b4ksu1$DirmQ_9igq84
z(r3U!3`Hx<@Y%?#?`)^}rp)ELuyfNd$2Z@*0{a7YT6d08hMi7lG`*x@kZ?)KfiixR
zg&HEO(JE*ND0%u$2|=Qj>i_x9GOP1HR*3^xhjF6JuN*sqHA7Am*~ODXWuo>7*2iP%nvf-0hU2BPn>rkx&;+fj=g*5@
zUQ0^)^~w{KJMc{>j*Dk!$QNXIKGmkM2xMVd>uQl@acU6t+s~--EZBsuH9!vqx8hQ$
zp2Tc2_94!dyBJl)F6QDa?IU~SEcUj%#4o^=uOT?M+`~*}n`)u!6|QRvC?2uH3GxBP
z0a0kZ%a3H>;oyu~w5@yeLih(r=+}|_bR+bmmQONX?VYTh!}NSCI(8Wz2A=NmFOx>9
z?qADdoF~e+P*AvfP)@4=xJwE7S2JuF1w_N1`VG^k9x*MV3E*jd*-Pxj%rv!i|V3v=6C@$7vKN)cI;VlAr)(`-2SB=3M(Apn>}-pgboY^LKd#HOP)ZgWc7{K&N7?>eTG<=B&8cu)Fv`yk}u4p23?l
z$U&>>TA8Y1CB;GiI4b?!Wm{3DLGF5k*Uqg6W+5ZUhpLR22ebWtsVz3>9986&$Nc%x
zI=Pqqh&5*dL&0geDR2kVS?7;8bmCXT$f2TGY!W55f9_3
zMHv(Cf(2M}438}32s+iL2jXX0idY79*~__0uQ}It(D%IO0)IxnVBXVV9^;=cmX?%+
z!kl}%A;tG2(HazaE?atWO
ztgCI*BJL!1NU^`^J%X0v%t4dTUt5PqxK!WX$KeqaWp;5j`YC=#+;=)M%y}JE1Wf=w
zTjTKQ?6fmIrdM#|=_IhPUq#8XV%}BnM<+k&9jewa3uZmF*}$|&mMocHRK#L=@Q2B?-4LLn_abAknp
zY2Oy})ju-$8${JGU5a>8GluSH5O<%!K9>R3OPQ)Bl0@27o|O9-+E82wO{<;-%-DCpsJ`8QMN93SEKK>PKrkK&o`AH&%H8+1t-VvZ}xSY
zg!YUd86xBOxuy$rN^yC#c$@7yJsN~cuz+XofS!~DK-q
zpiSrTDr0XoX1&k4pM}h!kiv>)N`en^b2m>~ew1sTU+=f3fCE}zL*v)uh=ZkC6~aw~
zw@nHC?&;e)a<)Ag4+2M+0vf<&YI`h+`t(9wKQzxZ8Kk)&@*>qQnNcj^}O-6_6)@W7@
zAIH6{iXCzPuEZ0c>$wTag{yu7g_{BiCVEg9Ks-%*wkD|UBioKUDs0sgjSYL$Pdd@TGD
z3@@1@j-FW`voO8}0*9^HdY?`OkJQ^&14Av&ljLL_A-``E$sZlp_*xketaWDGoG+w2
z02$WJO3A}@?t6z@Q)P2!Ffg
zHNO6_CJS1$3`eq<+dMZ{J&wN9TyyN6RQXCDRU>au^y~B;F`SE6BjsARR)=MoQEId3fb)?V?`D33x
zWgxYxZA%^aFFK{nNMblkb$WbqLUv}MPnozdPF0HG+QFC?4Y=|{dAiXm=hHE%?%R`Z
zPxA7&CZTWk{okvZIw&Pva=QrcnePp|KFNwh;C{q$A9t3K`J&hn7wxnAXZ|pv^l#z)
zvwI82VtIZw=9Yah4?#?E!RuHcZ(F*oTG
zgYfgZ*uQK=g@pp_y`<$t3v7RXq`UheibInD6s-{-;kuDF;CDP!beYLW-1fMhjlMgr&3hq3p6_vTEyg1q3_Jr9-0#m>$N<-+ZGs
z;V{DWBlnhyeBzzj>#LtfFQ~>6=@0{^b;8$|7ntk?yZ6U3FcrrL{wovemz;2RDN5Bw
ztx-G`0RH_-x4voEWG}M!)bS#0^4w`!?zf&Hd&xJ1+8cX
z)Y~@gtpo+k22$S?EKHX_^Os2zJpGduAHdIFHLw(Um_@TC_2pb1mo&mBM$i$9Xwj>vacn2##w0Mj
zX{s5M=gtsAsRx{Wc;ip=uT~M;pE1Pv?iiZTbNxo`*L87+r1@Murek*@0;x;liNSwJ4v#XJ!5jG6E{vy
z4M-C(Hozgp05~U4dV1hUC{1;L`mnkhA3>)HE!qFP+PN2~ol{X6Axt1{v8)Jb*l8Ij
zU#(AbIK8TOrLQt_>5?-UI9|%BqjbI&v&`3swd@)1igxMGc{ukjLJe?W9X%e3znbbU
zn|R|U-cj#c0Me)-Z*~pL)S9lB{6eq>CKRll;qMRqtBbJ(gpyJ(iN(M6?D4!iO;o61
zyxA14wx7%~45{tv1KlSKIC-=J46>hFuG0;_SL83v-?{LL24hNcYlJN`sxdTuSR2Ze
z<1nw`A`5$4F1g7nljIOGE`4M4t%okd>*EFCo~}*VFBs%Wr`(Mk+(%vzWarSkxgHSG
z_0Ke&yh$|Z4*nQS$yfRh@7_mhwGR_oYPsSUbSuhzMay)=!D779VX%zviS(aKSUv6~
zbkG;I$(_k|lkbbMrm4mM4=f;0UcFn~i6Hz1ICvLFqG4$PB_3EOH!&>4VuJ;ki`{!B
zuWaodyT7fIaX_wSdgvOC#u}G?anDu`y^W51bu8$1L9(xW`coD&@veT~RLLFK1d3|a
z`Q0lOg`Qx%r!{e-`v1eqJ^=VO@!2+`N#^*e&DWyd*44`cy+Id+&YTw}_^r~-w@y^;R(U^S1#b(Yb8HaI|>#~fkkHbjuWFhxG3e98&0}IK0
z)!_b=oq&Ts$niC|^W*gyq#+bpSp%?YKo}_Y$(v+)AymLkn7Ratdx>fHFXPot!2Fm=Y$L56N_4jw?Iz8^VyvhK$4iQw-vdVK9Qo!{kR
zZE?iASN8o2ZvB*Y)FM|E_naG-x-Sl6O)IEM)klBieqAROX@u*0Dj+_v=(oRN$O^jzQ43R
z_kW077wr8;(m@TI&YdX5!3))BtAg1awu_J^s;lbA@NLLIN0ZaMwKdL
zZJYzrxH1<4Ir4?6hz0XVu>Md7_9Z_atav8Lf7W$T?>>QWF&~a6>g=~^^NUmlwDKWN
z6yjoPuP?i&TnesKge%S~{5iHIui;BP!*|5o~R|KAa5d42?<
z8Hs5JT~C(##gaVVSctoyjRK5jYxV@maMq>U8}|zxjtF?Z^K9Jf(M-K2&E$mzIbC0hT4#GEmJg~Xj%0qP;d+P@1egpO!VpF+
zH_Y%EYAUK(q+(MPjUiCqZ%Ybj?Woa@hf=qK=w<#tS%Cj9kuUt0F*&>1E!IAB_27>m
zYiv{cbTrI0f$ZWQa9zx_K8d<>?{F}G%k#o;C!h>?nLh-cfD&)89J{fwv?3N6{~tWg
z(eheapDvkYnVvr{eXVL
z5#a=sgE`VwGE?rr=eNnF1!`RClH=R@}*|HN9=!P|R)RdIPqVm4JW4)8%
zfbX;S(=wNwDbE5^bvo|hBso$w2>SHslp0n#lxaKw1k=9-fq`p#dxPT1vk94*$;{2o
z=5dqdgO>z!0BqnJ7~L%`F4ANY6B5!$i3*F_B`s%|xr>dx#!oehsPe){474|TInrf3
zDp7`75f)vgitdy6-%4v^@8aAaDL2!AMRkD*ipgnd8)r+(l>CEq(;#Q2ofR4_f!X-Q
zot}O>7!W-Ib0UXu-obW3h%DfDG6JJio&|EN-@U89jrB%Zir5S-x5CWM
zj{o4nkW;PAm`#6+;%t)bP;bD7I+aNcA?!L{byfiV6d0Q`qVm^)!YYTV-gN=r^
z06JY_sj79tlnult)el({@Os8mdaT4e?2OW6)lgF__rFL7E-e6z`4SRhGh0l_s}wgT
zvuHkDyLIijFMdwtG}1Wvn;VA;6IYPi_@JY?9(sZbZMo4jG9v5Go${K;E|8U8N3L*u
zJU=@;GO5)~f3r=+sC_sZ#Etj%z)%bmLIB@p3;3r;p!Hm2RD
zzTWB>*#_ocsO#;znHj%Di{+w@fa8Ni-H0(o&HWgOJHFoeqB#UqwQC=&&Lgmaipbz1
zvpL(P0!#)PNUQ4=um+H}t?k;rYe4eN)Qh^og7C1gfNPK9>Dv_FCrtt`ru+Dfs2@Gb
z?6aLR>nB{J1gF4?({a0Vo-)UsHDe5Kz7BU<(i~1=l
zu#SJ6+jtFxHo{&)n)%hV-FTLkEB}_Qlva7M!@%5~;p~Ef!s9IiM@)rmOWoV1%InQI
z+v-=3NopwiPZ?;Y>&BY&
zY|W{Qw>k?ua!TYIr*blZ-?Kett96tIVF
zT}ZE!uiWGZQt)|5BTReabhkkSVZ!xysp=-WoYa=^cxT~3#nCK)^g_WV5!kRE|2=OM9Lv<
z3w!~Jl^Lk|bEj)t1&f3CdL{gsKXK;imOrLgo9t)=7Ee~*qS}ZKu=RUD(gS>ahoS5}v~K$JkVW-N@EGxdZfmN%sMJ_h
z`~w_iR(%Lc(ce2Y8xfJAS;Jon3$Nl2zo*o0z7#8Kg*0ecnr_Z7mD~l51OB4TGXmM9
z$oiYCauVJy|GmCX8G&c^tNq9T8Ro=QK@abnkuML`fVeGGAbj_JnL(A$n;Y7xYP4OM
zA(I$a%Nsh|s``2XwjM#8pC3MkIP#^;J!N+MD(tm`M?f%?VLch<`nHu$NqDC8yZy{P
z`DaAwmn0dZmL!X|{`<5EtgP(JxLjLbNGZuS1LpzEq`4!yUfg&Kq5m2YqkjC6BM+($|Iy^w^tB$XU+j$%Z`o8
zDJw26maQ=>D=W*eXep<(F~+)o$jcrFU%rv`i$!Q*E@;9M<)H{SgD`5jJ4*4}h74Qx
zLwGa^!hs|B7C419G=g0|h#`ekFR$o8Kui7{3vX+0dq?8^7Gf|ZRH=?jaAQs
z1j~=Zs3rZx>}f7&p_5(&EX61oP8S+QEpBFKrPClGRa1dlDge4e>r_6ujGZI9Nsya(
z9sU$@?Rb`BZvK2@G<4jVQWm1u6c8>(LqtSmT^|#*nyCpI^E(cRr|aw~9R7aVe(~~U
zraiEQb}fAcOdWD9*~TcH3F)T5{#93h97zRjsaKYgn$V|LT^mBajel`uVV)BK(|4}|
zDeSp+ClMG}%~
zxK(HhPhUF7nY6xtsBk{denV5+99IW}>5eH;(9lda9_GsN7YK9d|3$__Jw4&C_&hcT
zg$89^jv0p9)H!gS^2}0;5t!Xr{C{dM^X>xxm8a0M2gw#Ep{ATiFCndi_WXH
zW;q*nl?KAse;3qmE~s}5RN9|F{&P@?>B(9yuSaqaELPd`IZUnaSs$#>D2oht;kHWW
z0Gq2C(|It%xpB>nVRFXvz;fCvSI$MTQg@_-AerB}&}3PSUVUJKQ=bv2Ew<*$kXfN4
zuQwA=Ab!s^I*tR#2ZR+^vJS|$ZkY7zZ7h4pEOlp#4Eg7y47o7nE>HypJ75>>t|j#m
zN1kS?jwcDRRK*TylmW*Sm@7p?DlgKZPOb&voTv*}tetN?r&yJ%9mILPYd623?HF&^
zM&)ZSugu(_>~^--=;!1#$34d*Y3!;)hTQD=!2H$SohS2@rj@JE#f
z|4LHNSGGCg*U5|TwR~o{u&`7DfQ+{6&OJgZ=ar=uC}apxU*9${AnyX<^le!fhhdcT
zs=c+_-v+XF*E%+JK7WDR0rF-Gc_|64fa9tP?Y;X;D=UHfXQ!KCGCq2BPT?6)Fz%b4
zku8siL038BRUkN`|1GA+w>E6bI06?S3SlQp36%v#$Zqc|36txzmdkL&i`)Y1Gv)&U
zLTUk?`8r$Tz?;sE+kCCsUYm*JPf=iA-U#3Y6*H!JQ+TDNr2)ir>h*KK5_WQ&9eBxm
z5kgn+8>-{?K55%?=i>cly>l1X60w??5K
zLTK%PspuR(N{LQy)&Z)bwl;xq^RDjxNfdp%>c@OtkT3%=1+SA97boY7mrtLHx4h}L
zfS%CA)Pc&uE<=B$MaPc#-z+786+6Qp*`Z+6;{T1?=UsMCZp8ohX-YqCV`06dxP|w>
zKk>hBiOl~G7DFZ$RxdEE{eQ9OKY&nC$g7|bwtv8_5QgViyFg@^RhFw~g>x4N8|TyA
zyLYj1K7ab$4y@AI1cd%=Dmzqm_GNSPVQ{cgs80Gxw~~K=RR@IEsvW!w`(6l(!1MpS
zt8!r>xU|>b=Zu1^U85^RZlar0EGDr={J99HP(H-ev@n?UIhbpv#w~B3$!)Nl*W(V_
z-Bf7fV6bQ6k7FMU4ISm)QkTg4w}A^k;O@cA~7c^#f@2gGpV+i4|vT}mHTpI(hUJ
z6Czb&kpSnJ)V}~Dp2x}mbJ1Ua{YpJN6zzWXg6kPq?hmei(T4W}&m4`ybQUw=@;A5N
zcwId#yOCfN+Z*MWbkwvHmwFVQ&oduEY}9|Q^cWY{4HvI6aZwHe5BegB`PY1+1=Xj3
zCN6Taxn)W
zv_ifG8bM7cNQuh*GK0Z?<<-$kuwGzvDeku7iv1#qP5YleXc1s1M;6Zb^5vjiP4_34
z03)N#TocsIe2OY5_tL60>+YL>U=mtS-35hc(qT_e)2f5>ZM_%JIN530&KHIu^hjw)
zCrhlonr2fQVHx~tDe6COV`WlSO^2}=y|9dSdlUllCi!_*F)@`_Xcwy4YF{u{7fUU2
zlC_(E2dJ6`0^Y_8A16FdswRhc&)F;@hkZ}`zrnm548AwoFTy!W0JKI{R0?l8FAp;vV0!SDZu~q
zsVRD$SEt0o^aiCWzV{n5Vg)QmephFEPT0*B=gm;x^f*H@CanrTKBPcK+gVA~4lkO>}+>o;%-kGhpsD?}y^lS0S(cJJ|iNr*{I`TSD+D
z2l*Om7^=L`DYC~nc1*1Q7MaCZ5Y`P|Z%WH0?){?~_r
zR?ExF<3;hzBPn$beckh9we$VgKi69z^QAKJh`8C=N(KR(EC+H4yTZe4_TpEy@NS#U@;9rc+sQ
zag~qhggk9hX2vEjiZe6+jBugUFI#T?_xlK<1@_&$lMb@HI=@h(S59vESM!@_PBfw*
z13EeIZRgh58=pJ4B9H!sfH_IYoPF1aZ28GAUG~%cF96eI+c!lJK!!&UuKtGr9PL5t
z96W88K^{G6HzmF1S!U|E%~|on|F)I4l#!ZRq3aCze<7dSS$Dtu+rBkVh&4?Fx3ev%
zn0G(?*9(+n#6(0z)UwaA)U!HINK2mlXZuSS`4N{K33!wxJqZhUF8#s(U$2^)zLzxT
zUiLQfos5f_5PA7j?}PJyV#s}4#)xqHLTU?*Cfo<1YJb3JK2X%qKY#t|`EvYxAO79#TW$Has;X97d$(3>rDp6cR;pGJlptb$RZ(h>q;^}eN9;X{nlWmx
zL?~hlg4pi-9{lfz|KomgKfR74$C2y$T%XT%zF+5gM(XLPJ)nL@eeK${2O8?i2G_1#
zkH2>9)~kPRP_8JSWRG6E_U|y36LR|v)Bn#GSOk5DGwJEb{A;(Ko97Ja}
z9X;)gjGQRu_+4x}YB=1sw@p?sE;c!AnaXyV%L|j1SYBDVI$Bi#HrUk%;bu!M#7(8A
zn_yQzbNQneW4Fzxic$*nK(Qa+T^tUdM`ZhsJPlgDoD-J667b%dsdQ~ZrQBzh{P?kB
z*-5vi!IXumva*sj;9Sb~xVdWB_isW|3XMsL`PstKFjJ?n!r7Wr{()(MfSP1^KaT=_
z=Hghfd-qq8j_=_~&$vFMZ#2aeZ~3okoSbh@*lL#3{VA=IhfH*tSqZ$If@u=^Jnk^L
z-QU^4!%5dcLsdbSs!l?G!o39k$dp?OA*C|X({p0o8ctNSjo=7E(ON-4+vzobUJM`JNg0gyhY^wvzvv@zCY70P$6h
z*~H_URE9@H1-&gyu9d2T`0rs6ji!mWyv@?-7xx*&|PWZ
zPU>%YV_=^dF1gnlNM*MNk4a{6TrI=>fY7H9J_y)Y`E9c_$=++USf!!G`)^uws$R-D
zhEx-KztL=)UlD&anb&^c>m-}A=PN$_2CUupexVX63U2Glfe@qq)N(tF5FMmjE6FI;=%yM`q4xOFem?-Z1
z^+`ojHHNY6{e-mb9h6mbVEPkNut&l`x_6en>4{%I?Uj)EdlNZnpAY^@_$-FZUF6|Z|DB}aHmCbw{}mNeYtO9vCHQ*y?*ifctMjJUttuogxh8K`uMG{wXT(y?;}I&i
z;IoSaRra)irppVzOu6H6A(lBt!=Y~{Js5Wy(QvT;;@-N##pT68Y9&kCQ06k}&f(_HOcTEG1)pwC&3QT!{Bbv!
zgnI~%=65cSKjgOI2p;Vsbr{kzr6)6|3%6))@>wa{%L_hOv}e*Jr_#`jl_h~@W}PQX5{&pgCN{kH
z^?Yvf?({uf0?n)q^2*e>uwV6C`yIxR&0M4_O?$Hg_D0xRCVm9E(0kq01g#^Q*u-HR
zj8gCouaopwgigZXGt9Sv0WA`pS`6cAO+c-yZtE%GtOTrG>)j`RlH?7$Fh+h`>d{U+
z-De_NAcxb50o6gB4roTqP~U63;L`3!dIO1B((J%PH27>Yq$jINhwGm5Wl-8lkBqzQ
zRE^a|bDUHY2AI
z$iqL8W|Fs<9q{6+yQk|!o-@LYcZ5fG(rgS^YX$FrisQ{Sj?>}vH%F3V5Vv~oNxvY;g30Vq$fv}Ta4Q8
zk@@h6CbcQvB{iK)$&LN5!{TZ{8{^B1g>8n_i@i)KgC{#3ZNbU%-#;HqN)3Gjv8VT<
z6{6Wr+Be%SB=?S6<)Rbl+D?Ytzjlho;knrWd!$9o+XvNi9{cqN3X08yC^y(0cU4-#wurrNQ9R
zOp{o(`nb8_vEzV2xKSQNVs6SXSd0m@e9Q~w$#w~fg7|=SFBIw3BzkP#f{(X*?!E=X
zXPJ4~4u-M8f!wWovzYEc39Tpkl%`Epxy#GY-j?sWzY?xkhgZwf?s!
zn`6c4L?)e7iB7D)Jv7msV{RZ_SixTB6=_N#5X`A*ofmLcqGD#3^`94G>#TA;)@~{yLX#;bIt6p<3I{WtXrr(loec5t{j
z?SXhJvuuYP?nC1wO2E4PG%&mmWi@@c@v7kB374t#PFg(7VuJ6k$a9sM9@geD=C=2T
z=4ey7tL0FG4Md#FWOahk(}?@eB!dp9_PNjUNAlRW?~VSl(`oZaYC3<}qX8YL(!~mU
z8?QK_>X#5M(Fv8bkm-7r6lL732BAW~)+uKKI^U*3RcCnTASoCrVBsX2oF`XREnND@
zq3E*d1E?bV@KjT`ciw{Yh4oRvYL@RWW?^kxYeOJiw=2M!^TC(OFLr*Uv97T=9Jj6A
z4~=niT0zytjz(!~_~q+V3n#YbgAQnkAtG7KDqZC+uRJ(-qRZezVl9TLti`DIiEXo3
zU{Ru3bM+dN+)2S1Q=f$B!e@C$1|Qrh;j5TnUm}uMSF&u?+tdmY3$B^HNXdE09<8l@
znH9YMVkXGWE=l!}6CcxuqF!&})a&rtWY--%8>#q=Dp!`X$}~r{#=Sd8*1LmyA2wEl
zEM(s(kS|p`ypdn#f0AM$vVs-2j(DNwQ4thgHDW1B1x;4u{>Wo-D*@j|bv*r4Q~7n%Do3
z$2k|F3$CtzF3e$3Y+U+Yp7TMqgMC0(k$4Of;MMF>GPl|0M4_t=d^EBLRJ_eu>2|>d
zt#^zQGME<&=X{Jp6#{iy6F>%tTO}sq?Eyd_F)XJnA+8dlbJv?%C+$Cd^+9N>bfWmnphgY@Tv!vvc~I
zpnU73yQF~0c~)Q*9OFZbD>j)p)ss%i4p@wt>0-&vy*U!lFtcvxb2FC6KWNvzi@p8T
z&LUMV|9gr2niuTe)Q+5g37-ECqpFP0j@xX#&3D~|%yvJ$nw{Ly^DS|Y2bleeAioyB
zn$n~RjUp!+^jtZ7c>X6;`I))-N97CYh?a6?RLwMz8<7^k4z0Jw={X7vRcf0fv(_nJ
zPmy5)tv;?%V=^M<*?7%S)YHtVpw#rA)Xj1bTo&X(iFw$CR31Zikoq$MWp<@$3C
z!;<9R;J}^NIrlErN6vnaym>Z`wxt0Fo`g+i^SI5m<^;)+s}y}~JMW=eZ1Yl>;js#3
zFoVHo6v2LVfMJ{LGq3sZUTmeeo8#~?>2st=Vq!Cdt^5ADoPW^jk?Wwgo^*p89dm6V
z&f7&l1tjwF#S3|n4&1!u#qQ&y#Ej-KzdyB;3>$2%bH0VCW(PN=NmJj$asK4=PJ=h<
zCDyordfOhM&17rggG76&+kIrm#lwv!rGKq1htl~AJ6rX8^M!BMjb5;4{j5&B;V>s5
zm>c6w(a53Az{cGobDD{{#IH}%#7ghLZN3s>22E5pkK)sjO;>M#iTRwxfpe35JWji5
zyC)GKlac}_M}Z>4qiuyJdbsd&rHaK~`H
zXggF+8b=Lm+&@3d+Fj)?Uk6(R=HZ^M5GJgC|!cRn9Ru6h3OBJRe(*HE0BroMc981}oWx+!
zl+w}*bYGDg(}Op@AEd4k(Ar$Ly0aP1k^XbWE@(z&^_v{3vOPWG=fYfQwVA!Y&i=+^
zLtvXc&i-ts!G(N~8r*znt*91qVB6aS(p*W=B4RM=!f2`H1w5z2nSC)rrR5CWc98&*
zNNxM5aWy9N^kcWmK6!AQEwq^w63R;`PY#KxPJDcJ@b#{>VXdo`R}f)L_RFC{xq0A@gKSfz4EsioPCh>q#{LE4m|EM(+po)5Z=Q6SQxY4qX^?yIsbC(
zISR@@<%WV7My|2~2PNnHy9X5H&wZn({3Re}cxJ?f$%dgw?)V@WPP&Qa%{_8uMc4_8
z>)eiaf9=!{OC)wD0`84W8b&N*impl5xZ4KhOwFJEI#wM`l>0kY(aj`LU7am
z=DR=Aw6yeIxOHL6gRadu%cOg3sI|nM$O;0bI_Rs@o0Qy02$JSyV!92P=@4q%m~_y7
zcGdKukmxSn=#y~MLNg~+wg2IxFAn^LizV$hV_5Qhwtwa!vV$*3QJcgK%7}T;bUTfz
zurF)8jVRo9<;vnDx3oAQdM7qpY~st3T6Vw*#v_RdP@>>?`4Y+{-0h0n(RLGtE9jG*0G&uImt%>}4y+&Oo*(QUI4I3CI
zyCk!J5OQ0A^`Gh&-rLv_>|^dA60Z5=t8}e|a=n0coDX<~>Va3*fYZpfkiiJs371hN!-IH=YZ0IEi2DWvI)W
z_@9Igs=4f;KMd**0Jgg#+f4XQRM}e2ioAg?=2pQIoSx@;oG&6YprHw{B-^+IlOSY6
zpRC27jjTB|z(V4NtNJ4{u~omCw|@Y0^p5i7`D_g@`$pjAtuyBzNem~_UX4ST5{NI0
zjal?5_^iaqwG__?iEwP_-2Wr+4gYaF7iqcX_yXAXcfE($L=l#aEJ2l@n7z59Pj>6&
z^N5Jh?qSko73t20Cs
z2_FP+g-OxKnC5`-ojT2uW28`#;v-j!n9H@&7misDI4Zlbfa7_KhzEh~#1pK^D6Nd=
zbn)au{31nPkDd$usJUZDlBx!BsvLbp>mq#{iDl`mjjK;KUC?c(k3bBQN`v+c*`xzo
z71K)*hP=<81bj9_6uX&grv>NwPTt;WIoM(s2}w;Cc@9=Y^!-N{r9%@xA(&0yfBVK<
zt=!HJ!6kVE$st6czW9dHi${f91&=o*U@W89nPZxOigYRx*q
z->m#N5Fs*0k@PK(xl7%5U~fg__b9^iVD@o}qm%CWd8c$ui;O|Q)cQ=QE{n{DoD-|u
zmTR$b%L29?9C*}fY6zi>-qgD}d@bstq@5Q3Dv_$(ANr@L772D$+sF1
zK$*(WU`$lR?jFrb&1g8MmHXkwL?kWtjGt<*BN-?Ve
zD(&*>J`0G{f10p8DybpODX#5ckrw}nta9tcVCO!LNxXpwl
zYZk52Oe4|c%|AfR-N|j43?0*)JKmw=pjzT|YHET#OmbsFgZiMDWvtA~f0QN{0MSEF
zV}%jbWjojw840d#zmBvNvF+3vf>fJ!ita#v|9qu)lNNYvb>OBU1uIMzdSQUtN`4ud
z`f*7rjZkvdcycugWgav#(Y3ayUpKx#DEj<=1LG-=%_2rcYdML0n7}&i
z;B)cd(BYNt(CKFJ6EcXje-rE0gd*nEcr(?VOF~U9N4!=&49(3OOY&OI_?Cr^f(Fy%
zg01cn9gJQowkC|Nn^$|S9Dp5+*X}3d6i5ui7qG(nD9T9*n{jU_T1hZiWZ1Q+3L4|K
z<+5LWtZYnCd;lwdkHaEkBd0(TPPgZ9XR=!V;@^C5CFD>z_;RdCjXRVq!KGk@4F$e79yCrJ{
zz0+%JQR&x{l&b+cMHG3d->p5USmql3SKBWK97f#fMBpdH0y5O>A?A>;b@Z+{Kk?z=d93R9B4DhV+R
zJ~l35`HC!)uK>qA7j~Lz1g(kcN%2XPN1sW8h~vJ0_gWGYALT-vWG$<`CWuu8x7kLk
zs6kv)wZb+kh>%Pnwl0?)xuduK6e!{-6VL{>eRw-^vJ5Zghpj732Q-}jtfE1oV$V(^
z4w>pCGlx*-;zKfFD66J65
z{>l}qD_7x`Y0g&wQ#h4KC>Q
z+1c4mN~{%pMq$VdnKg3TYTr~O1;xAj4ShEpz{CS_ifOcV5BsQUTyO!s-;-kahZ)(d
z@_kAv%qpE?b*OFe3qLdHfz}YWGO%~?=j9!N3=>=CkrTr*F5f+T<5i?Lvu_W}`An^$
z^B}m^rHV5*31MpK^Rc}`Cnwr@c;FY!`L9OOL6?C#Kxb!&me*OWpfIy%<3O;g!=&b0
z1?iK|^67PMNRhA-q=aC)wa+4`8~0(Klxcu5tM?5XXl5QhJ=hJt3%5PV2t2(u)9$k~
z%TJ|nA`~K#{v7jLF6m0(o49|7KrnoQbCs6Hej*sKM224p$(%)Bul#E;M$>YM?c8s<
zA(*Y#I?^1B?6>IqwbgKsLD)k4!@}Ln**Zk7*Iy60sgk*Bww5;+y8%N2~exOT>m|4WDsY>lU+0v8=7gOcEsr#|7@f1<)yOv-4Z@yEQ9v>yJb^-{lzKkpw=l&@TKcfcGW88
zMnZ0&5}uWtOf2Qo?2>2%>|t-&k0Zh9+HsYNFe0|q;Q_g2!vu}notbt=aiYQ5JNo(w
z3YX`rv|Qa0
zofJXe;rW}yxZ}SI?MBe|g{jiaG;qL7(emKcxqs_aQ=iQJs{{VR3JF*2cQqu9%cOgd
zvM!mdPn8Q2^mOH^yjkF^+xzRnPQ2
z1DX2Xq<3Pqu5oD5H@oyL;JlSfJmsk#X`h<^=gUF)sZyr)jYS7B{r#
z;ejzNJ@X$@S~6bZzIu)~;^W;fU1sdXK#SF{egEZ1$#XnLn{(O6^Ousz#CP8#NZmi=BJ=Jir*N-auW
zu2EgRFI+BQy#CH%l3kF&=%{LJ>hqqv#u*n>*um9qL3Z-iocoqQpue~4L~{j%{;k4O
zt5-Aaq5fpH1HWzeDncM&2_VJSi?A$p{xon>68xl~-=Kn1;I8LPw*ABA-R=nEM~{}L
zCyLd2xWei@PXju{c=crc@PqmZ4-J}`CH66lZI|8#SMs1#kb!IR=yNRq1QzyV90efZ&IdWhg3A+u_&dBa#M5o=Z;?^Y?TeA3eNiDRbu)>OdS4pXA+
zR7J{?<97gv(7^EP&@{uHxshVc@yH1iWQ@=zD~tYaVnS;|cuk+I5bncL{n8E7q&y9e
ztpv*TGD`vfuL6t|N|4hC*(BrPr3sg8aaqqjo?nzdeE6_Zvko%3-0>p6@7NkAt*%?(
zk*;JorL)bYomug2ggU3l4a*1gQBTc%k8Bo}dZZp{+%Zxw^`{DbpA;~o*j?pO@ml0+
zanYvIj1_(wbNeeY)AK!Bq~^>E&gVPzd(@zNZ||(M?N5GrZuOaRwloqdNzsdBh06}2
z1KoN9FduLK(tb43FesUmS7o8zjGTDxJW7UAyLXsv6(QMw3NWv%3*4&?P&!vN;7PhE
zM-}n?|FD2g%EH!um_ObU#R4=EXuMWh-x_q}kmo-nXRZpERm#DU$@_MS4GJr$py5^t
z$kuOSc0m0CK{c@mnL)H}HZJBm-yI_+S~6t%H_B=OOSsfxUFTrc2QZh6k}U|YLSsf*
z4U+_>*iP%rXXgynw+M_CyYPdFN>jC-p888Is|OiB_y}6=(liYZ3)9dyZQ$3EqnM$N
zQuDjbW)vAvNj2|{N#QqfKlUHGA@#A3>t%2vFTgs`Mn|WNnSW!dIMdP>@UE~~z1mfG
z9?pYnflZWJxpd~0b{me2j5LvVknBH3jM}^{_Q6*I@uidM{EX}oHPAcRFLSx23i2Es
z@*L|i4|xk!R&osQJjN20^N=?eC|)rVpj8C&GW;8pjw7oT8uO2w>s82qwswpxLUk0o
zQNig>eD;ao_6&7c{-Eti-Q6_r5(T@PFMj_==coBFaI=4Cqy4h9j`-Q%!tHV@p1`Q@9Y@P+mOv-
zlyhN}tz7dhcoM}fcf+-k$Zdp}OXo^i2=@ED%4(L41eycnCiWax*x$l@#JK&B>L{aK
zaFR(lOB1!5KBwGMf?-8PmW^)2pTQ|hipH{YLd7ercT(ir@R;si>)o7jagK32j3%(8
z0`9WU%iVZWZU(wE{9JQhc;=5Qi!Molu`LMtdO2>d;}~4oIH!Le+YC!H$NTh~;z{Jl7kZ^V@LqCo|Eq
z572qZ&IS9LDR%1YX2EzBfQs@m{QjrHRdys%T3ZA8_^9p1?*OwJY4SjblT*aDW3}p>lmhk$I>(m1-&k6XggC>)fd=eKtWmcXN|#B5`y^G;ZFh&g8Kxr_0j4OD==kPsPfwx(6**1ueN!
zdg>IIFZRdCOuE~3Vs(t#?v<{bCQ$baDw@Z)L&n`u{f1kqz{F4ka)L<~eevYQ{u%Sk
zo29?+Q3LLod2gGZRMu!ByIXE6FERg`>(IU8-@1Qkd=_foahxsf=ZmO=DN
zBY&5PHz?P53b&H3jEb@D5S&XiEt`2Brp*2~m+sBz25QM)6W$C|I!|0FMkO*W2Xdsl
zH4@(Q_LN$+fhgm#4)Vs~p{ezEJ{5}W3YN0ja3Mwy3%Kw{vK)xaBfc#+xE36%CoL
z=9K0#-(cV8)=&r@iJ~N#!n6FdOL66WPlbaGija30NZKd?zm5NvX6u|@TnLORaN0`1
zfg_HY9-gGUnB~ksVvj50%1UUeIcz5h)*^k(1n|Ot_u8z@Bt!^VHAozE>QuPDwE8?M
z@gh)b6yh~=+Y!j0=(U4l>3?O-Gss3rAFx`;YPsX>qG|Q!$w=E*Qy_oI-uNe#9g7SA
zwcl)h^C}p6nIgpDgiMKVTs0xHXytxf6;B-pHF|xh`ncx_*9ARR{pU&3a-hb9wRv7{
zg^9dotd;>9RQKc9uR1`6l>Uq`U{RvKu7)KR^A+JO4LdZVxGw*E{ne-&zKvVf(qNoh|ww?={UX&JLNc25E=eEWwv#x{73e$`d|~0Y
zsU!abMzQ`>G);Q-*X|DwwpT$t!ND-pKw>rK&Q+`8yxkaLdsL$N{lsh*PFk}kNi0K?
zyBZXn(1}yaj@|rIHW98pM>+qwE*{m$F&rTtr5btT&(=s3L<+pOgRUa<7Nh=UwPQsk
z>}(b)XsoYiv~zQtpilGj(*#=nZ8bdFnE^@D*$>OYKDLt+f8VWKrW{Sauf(@6=g>8+
zk>ggVG40koFK^AX^F6Yx)X@`v23g8it#)7AzTPS6jwTX4U(Y$trha=C@~O>v;lEY`
z4gg{~7Uy45N~JiNhJ2367r^&^P4>#WI-Nq&`$r;JVJ^JY7gU1GO
zplYv_&pJ!thq=S%elJjA`cd7g?6&`oX0eNN^S%~y8GW}ME-2zY>R&7yHkl8G>(1(C
z2QB*;+x%}l&a>yW36d*aKweMmp+uEJMtnJ3`BN$SkvU$^FiZL>1({*XtXkG|-aOo+
zn?U_l|KP)1(aZ`196t{_8klpdH#&VE9(TI2N9C5}1L9S6WhIv~3vqxrdZTZ&?h#WM
zXc=YJW!5j_12uqvRT}xOP2BPqkEub^=u79Qhdv^yj}xi<%zciwX1Hw<{w21V+ZopC
z%`2zb+Pfpswvc~EKXc)I4iB7YRh1q3Rcyqg`YB7l
zGB~OxsJe7_4)_V{cadw`n)d$jmc3I3{PbE?>SPgUDSXs}t8~OlCY=nYsa-ZLvBuZ`
z$Y#0%dcPSpX>7!a;;Z$0d<2oDmZq;lt;u6T0ne|HrZJ}hiCc^N+zE~)BDs*DCm%=J
z+!Q6!-(Zw46IirnRdM;2S||FGm=%W1!>;4P?%$MU+ul97TNwb{j}agd+fkm`dENeNw#WsGW2ZHUcRz
zGzUOTidRX$8^Pjl%9G{fTQa7gGH~
ziTWNPz4wTliToCZdsmylfFWyBueNhiy_~rfBYN#K1W;M54lYVAvXfFz%C4!I;OKe1
z9$z^>-fEVp+$|Sk+;<`bFC@zyjyAo$lPLgSBy);HRQ5gN4hz?bY5{>}a?|_Xq-+0M
zd7cXxgS9-pQ7Rrxr2B{wzyS8Mvd_;41|Ti~rLMk2KV==awJJf95046(OEBr%o*6`CgMC8x=M0e{N4stKzLaO&*-%Yj6;^QMu>m&^p!&z-5UH&$A14-K2
z=g_Bi(8#a+do<5;xs7M(>;jPIZn|m~=_c2Qe+qRm^XXD$;Qymh;Z;&Z1joBR|6@)f
z%U`b>s0?5yo_I*|ahE3N^R_6<1&X-0YW}i8PfK_hJB8%dlw?>IsuV@NPc1Fx;DEImGggtk^kRgyX~BbaneHM(nnb21DM~XFfB>$CR~W|
zqcra3P(ZHd$`8*zF_8tM9h+1wR@Lck@s}@cnQioa)RJ*RSJHe3W!Kc`KsP``=K}eL
zmxH~-bI7IEy%L;566b>6PTQ;WGuFEz|L$CxhRt9ZUcjceY_cqPC9zkHR9Le9#|A9z
zctz!!mp&s>{b$OO3xIc0gLDE5*nm1te?URC6bbWO46F{m#UDGU)2?>#l
z2^BOnN-Coy(hVK*>kspiLb^a+_>j0rJp^Tr$2tD1dKSP-q4l>G`HW-=*^Z
zdRjgwXTR0qFOnxkBG|JIKuui^=(r~M?c)%Bh(T+9fuD`+FYzRm68^ks{=OBEn~?1<
z!`gnb5mYvKr@B97Kx7oCq5X$f#w_Ps{p>?}|IPlZ`=BD3O3^wyl;&xKra3&3ADh>)
z?E`aw_k*(fZS`?~2zSvUcM#$!(Kh7ma!8m{L2ww3lxf-h-}$OnV?>Kvm$`L+>FK+Y
z$WIxGH9PUObP-c>^bDw-tDOa*!)!e{fo5$lCDNB~GzohSo{Zo&FPC0OqbGb8g$)zX
zmEy32aezB6v8{TCim0Vt;&soK(
z7PtGV34dmvPgot)z@we+@>?{BkVv__9JtgvUKw`uM|<^;8Bf4fYDV<=oTb1Dzi5M$
z+;!jzfBuM9N-=iOSV{+pGL97m-{9vJDN@mr1sO|M#+$MLDIs(AiOzpuU4K|@U;s!M
zzpgDp1ZdGe`^`8?W!w87TG}MJrEeR_u$4wAW`%DqXfd}W#NLYE;Ahl7GA8B$NXNX=
z0WGWepjSBDa%oX`ShiKJ&)Jvy(bB9=|2sIRl>?(wtka59L1NnXhsFlA&QDp!c#4>J
zJ_kk5C8o4WiN3voVX^RuXmVR~i-S3u)Hjj;n0y0q?z}#T;^Q^wEBg^^ER|@1=uQ%G
zFiE{Z`3ro9D&d-jzh1+7kzyxje=f5P$*5%wZ|%b`rG7d}2H%Q5bbYS(E9s)#`tKbl
zmtm-Bp=Sj7|FD3YoG5Gsr+&(|=Va6u2z~l@Jyw(a{GGlDHfH;tZTV!{MHB*QPF|
zK7J*|-;tp2yv^(S6awOsz5U00O
zO--^hQPzK<8AhM6@~))guSI3aS=i%}mmMru4TcOr4TOe?PT6Zq!~kEQMh#Et%ecep
zM>px5%sz=N}hl0QLMjuw4UR!+VZ<~VIG%3nBo3vmJ$%7Jvv
z{9^U)%wa2>tF-b8s2w|LpVvOaHof_hx?=tLHk}`$Xbej2-#R$<&E5{-S@T8vpSnH6
zB2TWbMo%DRtLGNF*(HfxJB%OMlrjjjq~w&)<~Y+SLuJW)qF+*}<&&=s-+@RF=lpdV
z_d4nvh&`vpmA5#PcuaHx;`reiAm3bs|6kL^M}j;y@SpvX`R!0=f5kn%`G+QQvPwU0
z^nOa|a>@8=q1Pco!&LV35u$0xt%NhUd6Z9DB%?9~JgV!g3SSo~whOVpxE6s6yc627
zNJ)H_PM~~z$3vR29)&VWa#_AK3la2a*}$-!(B`JBpJ~5;^BO$`HJ@bh*(koyq#vh_
ziTbfe3SA(+D_c{Cy-<1)|7!RomC%{!VB!Qc>uy`@8DPB6N$&*1npGu~Bzc3@U7(@^
zd)Ftm^#lK`i)dQ(gPs~!k~qTEjPBkuEqGPwTO6U{J6mp~xM{rjqTATA^v@=bPG*i-
zBIpNz=*O%0j$YiV!IGvalb%j_aJTpJZluU==Z}`5zf&T;nOQpR9rqlb-f-I`1U-B>}XR}B+44~Hi3KgYp
zD|#pS&ST*piFLyAGAb|kcIv`AAkf-6OWOJAb%Q7+6DQB1#mn4+BCRtro4SU!W|6Td
z7nk{Zj+N25Ft0ztJ~xxz!jL{oal`ZEB&2n3jM;RNPXMxKtdm~!M1LkT)X~bz)lW6=
zH8U&ge62A_UarZ?cV}Uti;7n)P=1GrQIcFTwzTx=!t=vm;{y?o{UIw#EcQI;RG2cK
zxr~>$-Y;e^RaO37TQblQL~Hy-#VB20vW5c6yWdx>=Knw)gmlCyY!sRk?)K+=hKR
z$d1Z3cd4x4jzrby;w3JPZ%Y9NDEuwO*97`K9bIHvY+mY2Ka)l=Zg_(!E<(`hh=1sL
z06&k}Lwx8%)Yd(>zd6qIEv
z_-vYgML^0HTppY7%Xze*J*(yHCHn<{rFSE~+S#lv3-3tsgxcf!f{Q#gqL)_~E_Nl9
zU))BRrVa)zJM+wdW-Mym(I&ahJ~~6~$>VuN8EV0;yU1
z!M`6CeZU6bR&G);3*TI7%Q{-*&K^#aBTgweVqRNzYo+aXQn2Stv$sE`+7W(V{%}k2
zW^S??N8o1?QN;Ew{vK{8gZ;zz2tW(G8ueku-`~2LD_xE2S00sV``~(
z8Vg}@Ws|zeee=HL92HH3ql5iaX{Hw?(|Wi$_SX34tMoz0dw@B8OtB3;Ch##DoB7jq
z6CqAloKCyaX=|I4i%m=O`9N$96sMyjZ!0|a{zYkBbStZ^uF5Pe*=A