Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/servo-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Servo compatibility

on:
workflow_dispatch:

permissions:
contents: read

jobs:
worker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: "1.88"
components: rustfmt, clippy

- name: Check worker
working-directory: servo-renderer
run: |
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo check

servo-smoke:
needs: worker
runs-on: ubuntu-latest
env:
BUNDLE_GEMFILE: gemfiles/rails_8.1.gemfile
RAILS_ENV: test
CI: "true"
steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true

- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: "1.88"

- name: Install Servo runtime libraries
run: |
sudo apt-get update
sudo apt-get install -y libdbus-1-3 libegl1 libfontconfig1 libfreetype6 libgl1 libharfbuzz0b libx11-6 libxcb1 libxkbcommon0

- name: Download pinned Servo 0.4.0
env:
SERVO_SHA256: 419f6579a22704a6b4a5f48348401d43a0ed8098103bca560c22ac009e3a0b2f
run: |
curl --fail --location --retry 3 \
--output "${RUNNER_TEMP}/servo.tar.gz" \
https://github.com/servo/servo/releases/download/v0.4.0/servo-x86_64-linux-gnu.tar.gz
echo "${SERVO_SHA256} ${RUNNER_TEMP}/servo.tar.gz" | sha256sum --check
mkdir "${RUNNER_TEMP}/servo"
tar -xzf "${RUNNER_TEMP}/servo.tar.gz" -C "${RUNNER_TEMP}/servo"
find "${RUNNER_TEMP}/servo" -type f -name servoshell -perm -111 -print -quit > "${RUNNER_TEMP}/servoshell-path"
test -s "${RUNNER_TEMP}/servoshell-path"

- name: Start dummy Rails app, Servo, and worker
run: |
bundle exec ruby -I. -e '
require_relative "spec/dummy/config/environment"
require "puma"
server = Puma::Server.new(Rails.application)
server.add_tcp_listener("127.0.0.1", 3010)
server.run
sleep
' > "${RUNNER_TEMP}/rails.log" 2>&1 &
"$(cat "${RUNNER_TEMP}/servoshell-path")" \
--headless --webdriver 7000 --window-size 240x120 about:blank \
> "${RUNNER_TEMP}/servo.log" 2>&1 &
cargo run --manifest-path servo-renderer/Cargo.toml -- \
--allowed-origins http://127.0.0.1:3010 \
--capture-root "${GITHUB_WORKSPACE}/spec/dummy/tmp/animate_it" \
--webdriver-url http://127.0.0.1:7000 \
> "${RUNNER_TEMP}/worker.log" 2>&1 &

for attempt in $(seq 1 120); do
if curl --fail --silent http://127.0.0.1:3010/animate_it/compositions/client-runtime-spec/player?pp=disable > /dev/null && \
curl --fail --silent http://127.0.0.1:4178/v1/health | grep -q '"status":"ready"'; then
exit 0
fi
sleep 1
done
tail -100 "${RUNNER_TEMP}/rails.log" "${RUNNER_TEMP}/servo.log" "${RUNNER_TEMP}/worker.log"
exit 1

- name: Capture a real Servo PNG
run: |
curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data '{
"request_id":"ci-servo-frame",
"url":"http://127.0.0.1:3010/animate_it/compositions/client-runtime-spec/player?pp=disable",
"composition":"client-runtime-spec",
"width":240,
"height":120,
"duration":18,
"manifest_version":1,
"frames":[0],
"transparency":true,
"ready_timeout_ms":30000
}' \
--output "${RUNNER_TEMP}/servo-frame.png" \
http://127.0.0.1:4178/v1/captures/frame
file "${RUNNER_TEMP}/servo-frame.png" | grep -q 'PNG image data, 240 x 120.*RGBA'

- name: Show process logs on failure
if: failure()
run: tail -100 "${RUNNER_TEMP}/rails.log" "${RUNNER_TEMP}/servo.log" "${RUNNER_TEMP}/worker.log"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
Gemfile.lock
gemfiles/*.gemfile.lock
node_modules/
/servo-renderer/target/
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.6.0] - 2026-08-13

### Added
- Experimental, explicitly certified Servo capture through `servo_compatible!`,
with `:playwright`, `:servo`, and automatic fallback backends.
- A localhost Rust worker that drives Servo's official headless WebDriver,
validates render origins and player manifests, streams batch progress, and
supports cancellation without changing the existing PNG/FFmpeg pipeline.
- Controller-native PNG generation through `render animate_it:`, including
strict render props, short-lived opaque render tickets, ETags, and Rails cache
reuse.
- Chromium-versus-Servo verification using the existing RGB and alpha PSNR
gates, including structural and chapter-boundary sampling.

### Changed
- Player readiness failures now expose `data-animate-it-error` so renderers can
distinguish broken assets from timeouts.
- Browser capture is isolated behind a reusable frame-capturer interface while
preserving shared PNG captures, audio, progress, cancellation, and encoders.

### Compatibility
- Chromium remains the default. Servo is used automatically only by compositions
that explicitly call `servo_compatible!`, and operational failures fall back
to Playwright in `:auto` mode.

## [0.5.0] - 2026-08-13

### Added
Expand Down Expand Up @@ -123,7 +148,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.5.0...HEAD
[Unreleased]: https://github.com/joinbuildit/animate_it/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/joinbuildit/animate_it/compare/v0.5.0...v0.6.0
[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
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ and reload in development.
class HelloVideo < AnimateIt::Composition
id "hello"
client_driven!
# Explicitly certify this composition before using the experimental Servo backend.
# servo_compatible!
fps 30
size 1080, 1080
duration 3.seconds
Expand Down Expand Up @@ -446,9 +448,50 @@ AnimateIt.configure do |config|
# compositions re-use host partials/components that expect their CSS. Names
# are passed to `stylesheet_link_tag`. Default [].
config.render_stylesheets = %w[application components/star-ratings]

# Optional Servo worker. :auto uses Servo only for compositions that call
# `servo_compatible!` and falls back to Playwright on worker failures.
config.capture_backend = :auto
config.servo_endpoint = "http://127.0.0.1:4178"
config.servo_allowed_origins = ["http://127.0.0.1:3000"]
config.servo_version = ENV.fetch("ANIMATE_IT_SERVO_VERSION", "0.4.0")

# Opt in to short-lived private render pages and PNG controller responses.
config.internal_rendering = Rails.env.local?
config.render_asset_origins = ["https://cdn.example.com"]
config.render_cache_version = ENV.fetch("ANIMATE_IT_RENDER_CACHE_VERSION", "development")
end
```

With internal rendering enabled, a controller can return a generated still:

```ruby
render animate_it: {
composition: "hello",
frame: 45,
props: { counter_start: 100 },
cache: true
}
```

The response is an inline PNG with an ETag. Props are stored in a 60-second
opaque cache ticket rather than the render URL. Unknown or incorrectly typed
props are rejected, and asset props may use only relative URLs or configured
origins. A shared, writable Rails cache is required.

For local development with AnimateIt 0.6 installed, add the two optional
processes below to the host application's `Procfile.dev`. The capture root must
contain AnimateIt's normal `tmp/animate_it` frame directories.

```procfile
servo_engine: servoshell --headless --webdriver 7000 --window-size 1200x630 about:blank
servo_worker: cargo run --manifest-path $(bundle show animate_it)/servo-renderer/Cargo.toml -- --allowed-origins http://127.0.0.1:3000 --capture-root $PWD/tmp/animate_it --webdriver-url http://127.0.0.1:7000
```

Servo is pinned to 0.4.0 for this experimental integration. The dedicated
`Servo compatibility` workflow verifies the Rust protocol and performs an
opt-in capture with the checksum-pinned official Servo binary.

When one composition declares multiple formats for the same frame range,
AnimateIt captures the ordered Chromium frames once and reuses them for each
encoder. Studio rendering intentionally keeps one sequential capture stream so
Expand Down Expand Up @@ -486,6 +529,11 @@ ANIMATE_IT_PROPS_MATRIX_JSON='[{}, {"title":"Variant"}]' bin/rails animate_it:ve
Verification requires a running server and writes comparison screenshots under
`tmp/animate_it/verify`.

To compare a `servo_compatible!` player with Servo instead of comparing the
Chromium player with the legacy filmstrip, set
`ANIMATE_IT_VERIFY_BACKEND=servo`. Certification also samples chapter
boundaries and rejects compositions that depend on native CSS/Web Animations.

## Claude skill

If you use [Claude Code](https://claude.com/claude-code), this repo ships an
Expand Down
3 changes: 2 additions & 1 deletion animate_it.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ Gem::Specification.new do |spec|
spec.metadata["rubygems_mfa_required"] = "true"

spec.files = Dir.chdir(__dir__) do
Dir["{app,config,exe,lib}/**/*", "CHANGELOG.md", "MIT-LICENSE", "README.md"].select do |path|
Dir["{app,config,exe,lib}/**/*", "servo-renderer/{Cargo.lock,Cargo.toml,README.md,src/**/*}",
"CHANGELOG.md", "MIT-LICENSE", "README.md"].select do |path|
File.file?(path)
end
end
Expand Down
31 changes: 31 additions & 0 deletions app/controllers/animate_it/render_pages_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module AnimateIt
class RenderPagesController < ApplicationController
layout false
skip_before_action :ensure_local_environment
skip_forgery_protection
before_action :ensure_internal_rendering

def show
ticket = RenderTicketStore.read(params[:token])
return head :not_found unless ticket

@composition = AnimateIt.registry.fetch(ticket.fetch("composition"))
return head :not_found unless @composition.client_driven?

@props = ticket.fetch("props").deep_symbolize_keys
@track_document = @composition.track_document(props: @props)
TrackDocumentSchema.validate!(@track_document)
@player_manifest = @composition.player_manifest
@embedded_player = false
@host_navigation = false
@public_player = false
render "animate_it/frames/player"
end

private

def ensure_internal_rendering
head :not_found unless AnimateIt.config.internal_rendering?
end
end
end
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
get "public/compositions/:id/audio/:index", to: "public_players#audio", as: :public_composition_audio,
constraints: { index: /\d+/ }

get "internal/render_pages/:token", to: "render_pages#show", as: :internal_render_page,
constraints: { token: /[0-9A-Za-z_-]+/ }

get "compositions/:id", to: "studio#show", as: :composition
get "compositions/:id/frame/:frame", to: "frames#show", as: :composition_frame, constraints: { frame: /-?\d+/ }
get "compositions/:id/filmstrip", to: "frames#filmstrip", as: :composition_filmstrip
Expand Down
3 changes: 3 additions & 0 deletions lib/animate_it.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
require_relative "animate_it/runtime"
require_relative "animate_it/embed_helper"
require_relative "animate_it/output"
require_relative "animate_it/frame_capturers"
require_relative "animate_it/video_renderer"
require_relative "animate_it/render_ticket_store"
require_relative "animate_it/image_renderer"
require_relative "animate_it/verification"
require_relative "animate_it/asset_renderer"
require_relative "animate_it/asset_manifest"
Expand Down
14 changes: 14 additions & 0 deletions lib/animate_it/composition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(:@servo_compatible, false)
subclass.instance_variable_set(:@chapters, Chapters.new(subclass))
super
end
Expand Down Expand Up @@ -78,6 +79,19 @@ def client_driven?
@client_driven == true
end

# Marks a client-driven composition as eligible for the experimental
# Servo capture backend. Servo renders the same player document as
# Chromium; it does not interpret composition tracks independently.
def servo_compatible!
raise ArgumentError, "servo_compatible! requires client_driven!" unless client_driven?

@servo_compatible = true
end

def servo_compatible?
@servo_compatible == true
end

# Explicitly expose this composition through the production-safe public
# player endpoint. Studio, frame, filmstrip, props, and render endpoints
# remain local-only. Public playback always uses schema-default props.
Expand Down
35 changes: 32 additions & 3 deletions lib/animate_it/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ module AnimateIt
# config.mount_path = "/studio"
# end
class Configuration
attr_accessor :mount_path
CAPTURE_BACKENDS = %i[playwright servo auto].freeze

attr_accessor :mount_path, :render_stylesheets, :servo_endpoint,
:servo_allowed_origins, :render_asset_origins,
:render_cache_version, :internal_rendering,
:render_ticket_ttl, :render_props_max_bytes,
:render_prop_string_max_bytes, :servo_ready_timeout,
:servo_version

attr_reader :capture_backend

# Host-app stylesheets to load into every rendered frame/filmstrip <head>.
# Compositions that re-use host partials (which expect the host's component
Expand All @@ -17,11 +26,31 @@ class Configuration
# Names are passed straight to `stylesheet_link_tag`, so they resolve
# through the host's asset pipeline. Default empty — a composition built
# from self-contained markup needs none.
attr_accessor :render_stylesheets

def initialize
@mount_path = "/animate_it"
@render_stylesheets = []
@capture_backend = :playwright
@servo_endpoint = nil
@servo_allowed_origins = []
@render_asset_origins = []
@render_cache_version = "development"
@internal_rendering = false
@render_ticket_ttl = 60
@render_props_max_bytes = 65_536
@render_prop_string_max_bytes = 16_384
@servo_ready_timeout = 30_000
@servo_version = ENV.fetch("ANIMATE_IT_SERVO_VERSION", "unknown")
end

def capture_backend=(value)
backend = value.to_sym
raise ArgumentError, "capture_backend must be one of: #{CAPTURE_BACKENDS.join(", ")}" unless CAPTURE_BACKENDS.include?(backend)

@capture_backend = backend
end

def internal_rendering?
internal_rendering == true
end
end
end
Loading
Loading